396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|