init
This commit is contained in:
commit
d42e47b15b
31 changed files with 3045 additions and 0 deletions
396
src/components/GameMode.tsx
Normal file
396
src/components/GameMode.tsx
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import styles from '../styles/game.module.css'
|
||||
|
||||
const WORD_POOL = [
|
||||
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her',
|
||||
'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how',
|
||||
'its', 'may', 'new', 'now', 'old', 'see', 'way', 'who', 'did', 'got',
|
||||
'let', 'say', 'she', 'too', 'use', 'big', 'cat', 'dog', 'fun', 'hat',
|
||||
'jump', 'kick', 'love', 'make', 'nice', 'open', 'play', 'quiz', 'run',
|
||||
'star', 'time', 'up', 'very', 'walk', 'xray', 'year', 'zero', 'code',
|
||||
'type', 'fast', 'hero', 'epic', 'cool', 'fire', 'bolt', 'zoom', 'dash',
|
||||
'pixel', 'blaze', 'storm', 'power', 'quest', 'magic', 'super', 'turbo',
|
||||
'cyber', 'ultra', 'royal', 'brave', 'swift', 'light', 'dream', 'shine',
|
||||
]
|
||||
|
||||
type FallingWord = {
|
||||
id: number
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
speed: number
|
||||
matched: number // how many chars matched
|
||||
}
|
||||
|
||||
type Explosion = {
|
||||
id: number
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
points: number
|
||||
}
|
||||
|
||||
type Particle = {
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
tx: number
|
||||
ty: number
|
||||
color: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
const PARTICLE_COLORS = ['#2ecc71', '#27ae60', '#7c6ff7', '#f1c40f', '#e84393', '#3498db', '#00d2d3']
|
||||
|
||||
function spawnParticles(x: number, y: number, count: number): Particle[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5
|
||||
const dist = 40 + Math.random() * 80
|
||||
return {
|
||||
id: Date.now() + i,
|
||||
x,
|
||||
y,
|
||||
tx: Math.cos(angle) * dist,
|
||||
ty: Math.sin(angle) * dist,
|
||||
color: PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)],
|
||||
duration: 0.4 + Math.random() * 0.3,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type Props = {
|
||||
highScore: number
|
||||
onGameOver: (score: number) => void
|
||||
}
|
||||
|
||||
export function GameMode({ highScore, onGameOver }: Props) {
|
||||
const [words, setWords] = useState<FallingWord[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef('')
|
||||
const [score, setScore] = useState(0)
|
||||
const [lives, setLives] = useState(3)
|
||||
const [gameOver, setGameOver] = useState(false)
|
||||
const [started, setStarted] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [explosions, setExplosions] = useState<Explosion[]>([])
|
||||
const [particles, setParticles] = useState<Particle[]>([])
|
||||
const [flash, setFlash] = useState(0)
|
||||
const [combo, setCombo] = useState(0)
|
||||
const [showCombo, setShowCombo] = useState(0)
|
||||
const comboTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nextIdRef = useRef(0)
|
||||
const frameRef = useRef<number>(0)
|
||||
const lastSpawnRef = useRef(0)
|
||||
const difficultyRef = useRef(1)
|
||||
const livesRef = useRef(3)
|
||||
const gameOverRef = useRef(false)
|
||||
|
||||
livesRef.current = lives
|
||||
gameOverRef.current = gameOver
|
||||
|
||||
const spawnWord = useCallback(() => {
|
||||
const difficulty = difficultyRef.current
|
||||
const maxLen = Math.min(3 + Math.floor(difficulty / 3), 7)
|
||||
const pool = WORD_POOL.filter(w => w.length <= maxLen)
|
||||
const text = pool[Math.floor(Math.random() * pool.length)]
|
||||
const word: FallingWord = {
|
||||
id: nextIdRef.current++,
|
||||
text,
|
||||
x: Math.random() * 70 + 5, // 5%-75% from left
|
||||
y: -5,
|
||||
speed: 0.3 + difficulty * 0.08,
|
||||
matched: 0,
|
||||
}
|
||||
setWords(prev => [...prev, word])
|
||||
}, [])
|
||||
|
||||
const startGame = () => {
|
||||
setWords([])
|
||||
updateInput('')
|
||||
setScore(0)
|
||||
setLives(3)
|
||||
setGameOver(false)
|
||||
setStarted(true)
|
||||
setExplosions([])
|
||||
setParticles([])
|
||||
setCombo(0)
|
||||
difficultyRef.current = 1
|
||||
lastSpawnRef.current = 0
|
||||
nextIdRef.current = 0
|
||||
containerRef.current?.focus()
|
||||
}
|
||||
|
||||
// Game loop
|
||||
useEffect(() => {
|
||||
if (!started || gameOver) return
|
||||
|
||||
let lastTime = performance.now()
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (gameOverRef.current) return
|
||||
const dt = (now - lastTime) / 16.67 // normalize to ~60fps
|
||||
lastTime = now
|
||||
|
||||
// Spawn
|
||||
const spawnInterval = Math.max(1500 - difficultyRef.current * 80, 600)
|
||||
if (now - lastSpawnRef.current > spawnInterval) {
|
||||
spawnWord()
|
||||
lastSpawnRef.current = now
|
||||
}
|
||||
|
||||
// Move words
|
||||
setWords(prev => {
|
||||
const next: FallingWord[] = []
|
||||
let lostLife = false
|
||||
for (const w of prev) {
|
||||
const ny = w.y + w.speed * dt
|
||||
if (ny > 100) {
|
||||
lostLife = true
|
||||
} else {
|
||||
next.push({ ...w, y: ny })
|
||||
}
|
||||
}
|
||||
if (lostLife) {
|
||||
setLives(l => {
|
||||
const nl = l - 1
|
||||
if (nl <= 0) {
|
||||
setGameOver(true)
|
||||
}
|
||||
return nl
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Increase difficulty
|
||||
difficultyRef.current = 1 + score / 50
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frameRef.current)
|
||||
}, [started, gameOver, spawnWord, score])
|
||||
|
||||
// Fire onGameOver
|
||||
useEffect(() => {
|
||||
if (gameOver && started) {
|
||||
onGameOver(score)
|
||||
}
|
||||
}, [gameOver, started, score, onGameOver])
|
||||
|
||||
const updateInput = (val: string) => {
|
||||
inputRef.current = val
|
||||
setInput(val)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (gameOver) return
|
||||
if (!started) {
|
||||
startGame()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
const newInput = inputRef.current.slice(0, -1)
|
||||
updateInput(newInput)
|
||||
setWords(prev => prev.map(w => ({
|
||||
...w,
|
||||
matched: w.text.startsWith(newInput) && newInput.length > 0 ? newInput.length : 0,
|
||||
})))
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key.length !== 1) return
|
||||
e.preventDefault()
|
||||
|
||||
const newInput = inputRef.current + e.key
|
||||
|
||||
// Do matching + removal in one atomic setWords call
|
||||
setWords(prev => {
|
||||
const completed = prev.find(w => w.text === newInput)
|
||||
if (completed) {
|
||||
const points = completed.text.length * 10
|
||||
setScore(s => s + points)
|
||||
updateInput('')
|
||||
|
||||
// Spawn explosion at word position
|
||||
const now = Date.now()
|
||||
setExplosions(ex => [...ex, {
|
||||
id: now,
|
||||
text: completed.text,
|
||||
x: completed.x,
|
||||
y: completed.y,
|
||||
points,
|
||||
}])
|
||||
// Spawn particles
|
||||
setParticles(p => [...p, ...spawnParticles(completed.x, completed.y, 12)])
|
||||
// Screen flash
|
||||
setFlash(now)
|
||||
// Combo tracking
|
||||
setCombo(c => {
|
||||
const next = c + 1
|
||||
if (next >= 3) setShowCombo(now)
|
||||
return next
|
||||
})
|
||||
clearTimeout(comboTimerRef.current)
|
||||
comboTimerRef.current = setTimeout(() => setCombo(0), 2000)
|
||||
|
||||
// Auto-clean effects after animation
|
||||
setTimeout(() => {
|
||||
setExplosions(ex => ex.filter(e => e.id !== now))
|
||||
}, 500)
|
||||
setTimeout(() => {
|
||||
setParticles(p => p.slice(-24))
|
||||
}, 800)
|
||||
|
||||
return prev.filter(w => w.id !== completed.id)
|
||||
}
|
||||
// No completion — update partial matches
|
||||
updateInput(newInput)
|
||||
return prev.map(w => ({
|
||||
...w,
|
||||
matched: w.text.startsWith(newInput) ? newInput.length : 0,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Falling Words</h2>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: 14 }}>
|
||||
High Score: {highScore}
|
||||
</span>
|
||||
{started && !gameOver && (
|
||||
<button onClick={startGame} style={{ marginLeft: 'auto' }}>Restart</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${styles.gameContainer} ${!focused ? styles.blurred : ''}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
>
|
||||
<div className={styles.gameHud}>
|
||||
<div className={styles.hudItem}>
|
||||
<span className={styles.hudLabel}>Score</span>
|
||||
<span className={styles.hudValue}>{score}</span>
|
||||
</div>
|
||||
<div className={styles.hudItem}>
|
||||
<span className={styles.hudLabel}>Lives</span>
|
||||
<span className={`${styles.hudValue} ${styles.lives}`}>
|
||||
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 3 - lives))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{words.map(w => (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`${styles.word} ${w.matched > 0 ? styles.wordMatched : ''}`}
|
||||
style={{ left: `${w.x}%`, top: `${w.y}%` }}
|
||||
>
|
||||
{w.text.split('').map((ch, i) => (
|
||||
<span key={i} className={i < w.matched ? styles.wordMatchedChar : ''}>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Exploding words */}
|
||||
{explosions.map(ex => (
|
||||
<div
|
||||
key={ex.id}
|
||||
className={`${styles.word} ${styles.wordExploding}`}
|
||||
style={{ left: `${ex.x}%`, top: `${ex.y}%` }}
|
||||
>
|
||||
{ex.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Particles */}
|
||||
{particles.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={styles.particle}
|
||||
style={{
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
background: p.color,
|
||||
boxShadow: `0 0 6px ${p.color}, 0 0 12px ${p.color}`,
|
||||
'--tx': `${p.tx}px`,
|
||||
'--ty': `${p.ty}px`,
|
||||
'--duration': `${p.duration}s`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Score popups */}
|
||||
{explosions.map(ex => (
|
||||
<div
|
||||
key={`score-${ex.id}`}
|
||||
className={styles.scorePopup}
|
||||
style={{ left: `${ex.x + 3}%`, top: `${ex.y - 2}%` }}
|
||||
>
|
||||
+{ex.points}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Screen flash */}
|
||||
{flash > 0 && <div key={flash} className={styles.screenFlash} />}
|
||||
|
||||
{/* Combo text */}
|
||||
{showCombo > 0 && combo >= 3 && (
|
||||
<div key={showCombo} className={styles.comboText}>
|
||||
{combo}x COMBO!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.dangerZone} />
|
||||
|
||||
{started && !gameOver && (
|
||||
<div className={styles.inputDisplay}>
|
||||
{input || <span style={{ opacity: 0.3 }}>type a word...</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!started && (
|
||||
<div className={styles.gameOver}>
|
||||
<div className={styles.gameOverTitle}>Falling Words</div>
|
||||
<div className={styles.gameOverScore}>
|
||||
Type the words before they fall!
|
||||
</div>
|
||||
<button className={styles.gameOverButtons} onClick={startGame}
|
||||
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 18, padding: '14px 36px' }}>
|
||||
Start Game
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameOver && (
|
||||
<div className={styles.gameOver}>
|
||||
<div className={styles.gameOverTitle}>Game Over!</div>
|
||||
<div className={styles.gameOverScore}>Score: {score}</div>
|
||||
{score > highScore && score > 0 && (
|
||||
<div className={styles.highScore}>New High Score!</div>
|
||||
)}
|
||||
<div className={styles.gameOverButtons}>
|
||||
<button
|
||||
onClick={startGame}
|
||||
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 16, padding: '12px 28px' }}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue