This commit is contained in:
polwex 2026-03-24 16:12:30 +07:00
commit d42e47b15b
31 changed files with 3045 additions and 0 deletions

396
src/components/GameMode.tsx Normal file
View 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>
)
}