diff --git a/.gitignore b/.gitignore index a547bf3..2e262fe 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,14 @@ dist-ssr *.njsproj *.sln *.sw? + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d4a1e25 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +A typing tutor app for a child (Leo), built with React 19 + TypeScript + Vite. Uses Bun as the package manager/runtime. + +## Commands + +- `bun run dev` — start dev server +- `bun run build` — typecheck (`tsc -b`) then build for production +- `bun run lint` — ESLint +- `bun run preview` — serve production build locally + +## Architecture + +**Modes** — The app has four tabs, each a top-level component rendered by `App.tsx`: +- **LessonMode** — structured lessons with progressive key introduction; lessons unlock sequentially (90%+ accuracy required) +- **FreeMode** — free typing practice using random quotes +- **GameMode** — arcade-style falling-words game with combo system and sound effects +- **StatsView** — charts (recharts) showing WPM/accuracy trends and per-key heatmaps + +**Core hook: `useTypingEngine`** — shared typing logic used by LessonMode and FreeMode. Tracks cursor position, errors, WPM (updated every 500ms), per-key hit/miss stats, and fires `onComplete` when text is finished. GameMode has its own input handling since its mechanics differ (falling words, not linear text). + +**State: `useStats`** — persists `UserProgress` (completed lessons, session history, game high score) to localStorage under key `leo-typing-progress`. + +**Sound: `sounds.ts`** — all audio is synthesized via Web Audio API oscillators (no audio files). Exports: `playKeyClick`, `playWordComplete`, `playCombo`, `playMiss`, `playGameOver`. + +**Data files** — `src/data/lessons.ts` (lesson definitions with word lists), `src/data/quotes.ts` (free mode texts), `src/data/keyboard.ts` (key-to-finger mapping for the visual keyboard). + +**Styling** — CSS Modules (`src/styles/*.module.css`) plus a global stylesheet. diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..82413ad --- /dev/null +++ b/devenv.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1774428097, + "narHash": "sha256-yQAutPgbsVHsN/SygZDyzMRxQn6Im53PJkrI377N8Sg=", + "owner": "cachix", + "repo": "devenv", + "rev": "957d63f663f230dc8ac3b85f950690e56fe8b1e0", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1774287239, + "narHash": "sha256-W3krsWcDwYuA3gPWsFA24YAXxOFUL6iIlT6IknAoNSE=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1773840656, + "narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..b82ba65 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,56 @@ +{ + pkgs, + lib, + config, + inputs, + ... +}: { + # https://devenv.sh/basics/ + env.GREET = "devenv"; + + # https://devenv.sh/packages/ + packages = with pkgs; [ + git + nodePackages.typescript-language-server + ]; + + # https://devenv.sh/languages/ + languages.javascript = { + enable = true; + bun.enable = true; + }; + + # https://devenv.sh/processes/ + # processes.dev.exec = "${lib.getExe pkgs.watchexec} -n -- ls -la"; + + # https://devenv.sh/services/ + # services.postgres.enable = true; + + # https://devenv.sh/scripts/ + scripts.hello.exec = '' + echo hello from $GREET + ''; + + # https://devenv.sh/basics/ + enterShell = '' + hello # Run scripts directly + git --version # Use packages + ''; + + # https://devenv.sh/tasks/ + # tasks = { + # "myproj:setup".exec = "mytool build"; + # "devenv:enterShell".after = [ "myproj:setup" ]; + # }; + + # https://devenv.sh/tests/ + enterTest = '' + echo "Running tests" + git --version | grep --color=auto "${pkgs.git.version}" + ''; + + # https://devenv.sh/git-hooks/ + # git-hooks.hooks.shellcheck.enable = true; + + # See full reference at https://devenv.sh/reference/options/ +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..116a2ad --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +# If you're using non-OSS software, you can set allowUnfree to true. +# allowUnfree: true + +# If you're willing to use a package that's vulnerable +# permittedInsecurePackages: +# - "openssl-1.1.1w" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend diff --git a/package.json b/package.json index d109811..d36d0f6 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --port 5174 --host", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/src/components/GameMode.tsx b/src/components/GameMode.tsx index 0f6a950..dd973f8 100644 --- a/src/components/GameMode.tsx +++ b/src/components/GameMode.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react' import styles from '../styles/game.module.css' +import selectorStyles from '../styles/missile.module.css' import { playKeyClick, playWordComplete, playCombo, playMiss, playGameOver } from '../sounds' +import { MissileGame } from './MissileGame' const WORD_POOL = [ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her', @@ -64,12 +66,12 @@ type Props = { onGameOver: (score: number) => void } -export function GameMode({ highScore, onGameOver }: Props) { +function FallingWordsGame({ highScore, onGameOver }: Props) { const [words, setWords] = useState([]) const [input, setInput] = useState('') const inputRef = useRef('') const [score, setScore] = useState(0) - const [lives, setLives] = useState(3) + const [lives, setLives] = useState(5) const [gameOver, setGameOver] = useState(false) const [started, setStarted] = useState(false) const [focused, setFocused] = useState(false) @@ -84,7 +86,7 @@ export function GameMode({ highScore, onGameOver }: Props) { const frameRef = useRef(0) const lastSpawnRef = useRef(0) const difficultyRef = useRef(1) - const livesRef = useRef(3) + const livesRef = useRef(5) const gameOverRef = useRef(false) livesRef.current = lives @@ -100,7 +102,7 @@ export function GameMode({ highScore, onGameOver }: Props) { text, x: Math.random() * 70 + 5, // 5%-75% from left y: -5, - speed: 0.3 + difficulty * 0.08, + speed: 0.2 + difficulty * 0.05, matched: 0, } setWords(prev => [...prev, word]) @@ -110,7 +112,7 @@ export function GameMode({ highScore, onGameOver }: Props) { setWords([]) updateInput('') setScore(0) - setLives(3) + setLives(5) setGameOver(false) setStarted(true) setExplosions([]) @@ -134,7 +136,7 @@ export function GameMode({ highScore, onGameOver }: Props) { lastTime = now // Spawn - const spawnInterval = Math.max(1500 - difficultyRef.current * 80, 600) + const spawnInterval = Math.max(2200 - difficultyRef.current * 80, 900) if (now - lastSpawnRef.current > spawnInterval) { spawnWord() lastSpawnRef.current = now @@ -154,6 +156,11 @@ export function GameMode({ highScore, onGameOver }: Props) { } if (lostLife) { playMiss() + difficultyRef.current = 1 + const baseSpeed = 0.2 + 1 * 0.05 + for (const w of next) { + w.speed = baseSpeed + } setLives(l => { const nl = l - 1 if (nl <= 0) { @@ -166,7 +173,7 @@ export function GameMode({ highScore, onGameOver }: Props) { }) // Increase difficulty - difficultyRef.current = 1 + score / 50 + difficultyRef.current = 1 + score / 100 frameRef.current = requestAnimationFrame(tick) } @@ -195,6 +202,12 @@ export function GameMode({ highScore, onGameOver }: Props) { return } + if (e.key === 'Escape') { + updateInput('') + setWords(prev => prev.map(w => ({ ...w, matched: 0 }))) + return + } + if (e.key === 'Backspace') { const newInput = inputRef.current.slice(0, -1) updateInput(newInput) @@ -292,7 +305,7 @@ export function GameMode({ highScore, onGameOver }: Props) {
Lives - {'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 3 - lives))} + {'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 5 - lives))}
@@ -402,3 +415,60 @@ export function GameMode({ highScore, onGameOver }: Props) { ) } + +type GameChoice = 'select' | 'falling' | 'missile' + +export function GameMode({ highScore, onGameOver }: Props) { + const [choice, setChoice] = useState('select') + + if (choice === 'falling') { + return ( +
+
+ +
+ +
+ ) + } + + if (choice === 'missile') { + return ( +
+
+ +
+ +
+ ) + } + + return ( +
+

Games

+

+ High Score: {highScore} +

+
+
setChoice('falling')}> +
🌧️
+
Falling Words
+
+ Type falling words before they hit the ground. Build combos for bonus points! +
+
+
setChoice('missile')}> +
🚀
+
Missile Strike
+
+ Launch missiles at the enemy city. Type words to dodge interceptors! +
+
+
+
+ ) +} diff --git a/src/components/MissileGame.tsx b/src/components/MissileGame.tsx new file mode 100644 index 0000000..bb742b3 --- /dev/null +++ b/src/components/MissileGame.tsx @@ -0,0 +1,924 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import styles from '../styles/missile.module.css' +import { playKeyClick, playLaunch, playDodge, playMissileHit, playExplosion, playGameOver } from '../sounds' +import { LESSONS } from '../data/lessons' + +// Build word pool from lesson data +const WORD_POOL: string[] = [] +for (const lesson of LESSONS) { + for (const w of lesson.words) { + for (const part of w.split(' ')) { + if (part.length >= 2 && part.length <= 8 && /^[a-z]+$/.test(part)) { + WORD_POOL.push(part) + } + } + } +} +// Deduplicate +const WORDS = [...new Set(WORD_POOL)] + +function pickWord(minLen: number, maxLen: number): string { + const pool = WORDS.filter(w => w.length >= minLen && w.length <= maxLen) + if (pool.length === 0) return 'dodge' + return pool[Math.floor(Math.random() * pool.length)] +} + +// ---- Types ---- + +type Particle = { + x: number + y: number + vx: number + vy: number + life: number + maxLife: number + color: string + size: number +} + +type Interceptor = { + x: number + y: number + speed: number + word: string +} + +type Phase = 'ready' | 'flying' | 'dodging' | 'hit' | 'impact' | 'dead' | 'gameover' + +type GameState = { + phase: Phase + missilesLeft: number + hp: number + wordsCompleted: number + score: number + currentWord: string + typedIndex: number + missileX: number + missileY: number + missileSpeed: number + interceptor: Interceptor | null + particles: Particle[] + groundOffset: number + shakeTimer: number + flashTimer: number + flashColor: string + smokeLevel: number // 0-2 based on damage + cityScale: number + phaseTimer: number + missileIndex: number // which missile we're on (0-2) + fireballRadius: number + cityDestroyed: boolean +} + +const CANVAS_W = 800 +const CANVAS_H = 500 +const MISSILE_START_X = 60 +const MISSILE_Y = 250 +const CITY_X = 740 +const INTERCEPTOR_TIMEOUT_X = 120 // if interceptor reaches this X, it hits + +const INITIAL_STATE: GameState = { + phase: 'ready', + missilesLeft: 3, + hp: 3, + wordsCompleted: 0, + score: 0, + currentWord: '', + typedIndex: 0, + missileX: MISSILE_START_X, + missileY: MISSILE_Y, + missileSpeed: 1, + interceptor: null, + particles: [], + groundOffset: 0, + shakeTimer: 0, + flashTimer: 0, + flashColor: '', + smokeLevel: 0, + cityScale: 0.3, + phaseTimer: 0, + missileIndex: 0, + fireballRadius: 0, + cityDestroyed: false, +} + +type Props = { + highScore: number + onGameOver: (score: number) => void +} + +export function MissileGame({ highScore, onGameOver }: Props) { + const canvasRef = useRef(null) + const wrapperRef = useRef(null) + const stateRef = useRef({ ...INITIAL_STATE }) + const frameRef = useRef(0) + const [focused, setFocused] = useState(false) + const [started, setStarted] = useState(false) + const [gameOver, setGameOver] = useState(false) + // HUD state (synced from game state on each frame) + const [hudScore, setHudScore] = useState(0) + const [hudMissilesLeft, setHudMissilesLeft] = useState(3) + const [hudHp, setHudHp] = useState(3) + const [hudWord, setHudWord] = useState('') + const [hudTypedIndex, setHudTypedIndex] = useState(0) + const [hudProgress, setHudProgress] = useState(0) + const [hudPhase, setHudPhase] = useState('ready') + const [finalScore, setFinalScore] = useState(0) + const [shaking, setShaking] = useState(false) + + const gameOverFiredRef = useRef(false) + + const syncHud = useCallback((s: GameState) => { + setHudScore(s.score) + setHudMissilesLeft(s.missilesLeft) + setHudHp(s.hp) + setHudWord(s.currentWord) + setHudTypedIndex(s.typedIndex) + setHudProgress(s.wordsCompleted / 4) + setHudPhase(s.phase) + }, []) + + const triggerShake = useCallback(() => { + setShaking(true) + setTimeout(() => setShaking(false), 300) + }, []) + + const spawnInterceptor = useCallback((s: GameState) => { + const missileNum = s.missileIndex + const minLen = 3 + Math.floor(missileNum * 0.5) + const maxLen = 5 + missileNum + const word = pickWord(minLen, maxLen) + const speed = 1.2 + missileNum * 0.4 + s.interceptor = { + x: CANVAS_W + 20, + y: MISSILE_Y - 20 + (Math.random() - 0.5) * 60, + speed, + word, + } + s.currentWord = word + s.typedIndex = 0 + s.phase = 'dodging' + }, []) + + const launchMissile = useCallback((s: GameState) => { + s.phase = 'flying' + s.hp = 3 + s.wordsCompleted = 0 + s.missileX = MISSILE_START_X + s.missileY = MISSILE_Y + s.missileSpeed = 1 + s.interceptor = null + s.currentWord = '' + s.typedIndex = 0 + s.smokeLevel = 0 + s.cityScale = 0.3 + s.phaseTimer = 0 + s.fireballRadius = 0 + s.cityDestroyed = false + playLaunch() + // Add launch particles + for (let i = 0; i < 20; i++) { + s.particles.push({ + x: s.missileX - 10, + y: s.missileY + (Math.random() - 0.5) * 10, + vx: -2 - Math.random() * 3, + vy: (Math.random() - 0.5) * 2, + life: 0.5 + Math.random() * 0.5, + maxLife: 1, + color: Math.random() > 0.5 ? '#f39c12' : '#e74c3c', + size: 3 + Math.random() * 4, + }) + } + }, []) + + const startGame = useCallback(() => { + const s = stateRef.current + Object.assign(s, { ...INITIAL_STATE, particles: [] }) + gameOverFiredRef.current = false + setStarted(true) + setGameOver(false) + launchMissile(s) + syncHud(s) + wrapperRef.current?.focus() + }, [launchMissile, syncHud]) + + // Canvas drawing + const draw = useCallback((ctx: CanvasRenderingContext2D, s: GameState) => { + const w = CANVAS_W + const h = CANVAS_H + + // Screen shake offset (stronger during impact) + let sx = 0, sy = 0 + if (s.shakeTimer > 0) { + const intensity = s.phase === 'impact' ? 16 : 8 + sx = (Math.random() - 0.5) * intensity * s.shakeTimer + sy = (Math.random() - 0.5) * intensity * s.shakeTimer + } + ctx.save() + ctx.translate(sx, sy) + + // Sky gradient + const sky = ctx.createLinearGradient(0, 0, 0, h) + sky.addColorStop(0, '#0a0a2e') + sky.addColorStop(0.6, '#1a1a4e') + sky.addColorStop(1, '#2a1a3e') + ctx.fillStyle = sky + ctx.fillRect(0, 0, w, h) + + // Stars + ctx.fillStyle = 'rgba(255,255,255,0.4)' + for (let i = 0; i < 40; i++) { + const starX = (i * 97 + 13) % w + const starY = (i * 53 + 7) % (h * 0.6) + const starSize = (i % 3 === 0) ? 2 : 1 + ctx.fillRect(starX, starY, starSize, starSize) + } + + // Scrolling ground + const groundY = h - 60 + ctx.fillStyle = '#1a1a2e' + ctx.fillRect(0, groundY, w, 60) + + // Ground texture (parallax mountains) + ctx.fillStyle = '#252545' + ctx.beginPath() + ctx.moveTo(0, groundY) + for (let x = 0; x <= w; x += 40) { + const off = Math.sin((x + s.groundOffset * 0.3) * 0.02) * 15 + ctx.lineTo(x, groundY - 10 + off) + } + ctx.lineTo(w, h) + ctx.lineTo(0, h) + ctx.fill() + + // Closer ground layer + ctx.fillStyle = '#1e1e3a' + ctx.beginPath() + ctx.moveTo(0, groundY + 20) + for (let x = 0; x <= w; x += 30) { + const off = Math.sin((x + s.groundOffset * 0.7) * 0.03) * 8 + ctx.lineTo(x, groundY + 15 + off) + } + ctx.lineTo(w, h) + ctx.lineTo(0, h) + ctx.fill() + + // City skyline (right side) + if (s.phase !== 'ready') { + const cx = CITY_X - 40 * s.cityScale + const cScale = s.cityScale + const buildings = [ + { x: 0, w: 20, h: 80 }, + { x: 22, w: 15, h: 60 }, + { x: 40, w: 25, h: 100 }, + { x: 68, w: 18, h: 70 }, + { x: 88, w: 22, h: 90 }, + ] + + if (s.cityDestroyed) { + // Ruined buildings — shorter, jagged, darker + ctx.fillStyle = '#1a1a2a' + for (const b of buildings) { + const bx = cx + b.x * cScale + const bw = b.w * cScale + const bh = b.h * cScale * 0.3 // rubble height + const by = groundY - bh + // Jagged rubble shape + ctx.beginPath() + ctx.moveTo(bx, groundY) + ctx.lineTo(bx, by + Math.random() * 4) + ctx.lineTo(bx + bw * 0.3, by - 2 + Math.random() * 6) + ctx.lineTo(bx + bw * 0.5, by + 3 + Math.random() * 4) + ctx.lineTo(bx + bw * 0.7, by - 1 + Math.random() * 5) + ctx.lineTo(bx + bw, by + Math.random() * 6) + ctx.lineTo(bx + bw, groundY) + ctx.closePath() + ctx.fill() + } + } else { + ctx.fillStyle = '#3a3a5a' + for (const b of buildings) { + const bx = cx + b.x * cScale + const bw = b.w * cScale + const bh = b.h * cScale + const by = groundY - bh + ctx.fillRect(bx, by, bw, bh) + // Windows + ctx.fillStyle = '#5a5a8a' + for (let wy = by + 6 * cScale; wy < by + bh - 4 * cScale; wy += 12 * cScale) { + for (let wx = bx + 3 * cScale; wx < bx + bw - 3 * cScale; wx += 8 * cScale) { + ctx.fillRect(wx, wy, 3 * cScale, 4 * cScale) + } + } + ctx.fillStyle = '#3a3a5a' + } + } + } + + // Fireball (during impact) + if (s.fireballRadius > 0) { + const fbx = CITY_X - 10 + const fby = MISSILE_Y - 10 + const r = s.fireballRadius + + // Outer glow + const glow = ctx.createRadialGradient(fbx, fby, 0, fbx, fby, r * 2) + glow.addColorStop(0, 'rgba(255, 200, 50, 0.4)') + glow.addColorStop(0.5, 'rgba(255, 100, 20, 0.2)') + glow.addColorStop(1, 'rgba(255, 50, 0, 0)') + ctx.fillStyle = glow + ctx.beginPath() + ctx.arc(fbx, fby, r * 2, 0, Math.PI * 2) + ctx.fill() + + // Core fireball + const core = ctx.createRadialGradient(fbx, fby, 0, fbx, fby, r) + core.addColorStop(0, '#ffffff') + core.addColorStop(0.2, '#fff3b0') + core.addColorStop(0.5, '#f39c12') + core.addColorStop(0.8, '#e74c3c') + core.addColorStop(1, 'rgba(231, 76, 60, 0)') + ctx.fillStyle = core + ctx.beginPath() + ctx.arc(fbx, fby, r, 0, Math.PI * 2) + ctx.fill() + } + + // Interceptor + if (s.interceptor && (s.phase === 'dodging' || s.phase === 'hit')) { + const ic = s.interceptor + ctx.save() + // Interceptor body (pointed shape coming from right) + ctx.fillStyle = '#e74c3c' + ctx.beginPath() + ctx.moveTo(ic.x + 20, ic.y) + ctx.lineTo(ic.x - 10, ic.y - 6) + ctx.lineTo(ic.x - 10, ic.y + 6) + ctx.closePath() + ctx.fill() + + // Interceptor trail + ctx.strokeStyle = 'rgba(231, 76, 60, 0.4)' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(ic.x + 20, ic.y) + ctx.lineTo(ic.x + 60, ic.y + (Math.random() - 0.5) * 4) + ctx.stroke() + + // Word above interceptor + ctx.font = 'bold 16px monospace' + ctx.textAlign = 'center' + // Background for word + const wordW = ctx.measureText(ic.word).width + 16 + ctx.fillStyle = 'rgba(0,0,0,0.7)' + ctx.fillRect(ic.x - wordW / 2 + 5, ic.y - 32, wordW, 22) + // Draw each char + for (let i = 0; i < ic.word.length; i++) { + ctx.fillStyle = i < s.typedIndex ? '#2ecc71' : '#ffffff' + const charX = ic.x + 5 - (ic.word.length * 4.5) + i * 9.5 + ctx.fillText(ic.word[i], charX, ic.y - 15) + } + ctx.restore() + } + + // Missile (hide once fireball has detonated) + if (s.phase !== 'gameover' && s.phase !== 'ready' && !(s.phase === 'impact' && s.fireballRadius > 0)) { + const mx = s.missileX + const my = s.missileY + + // Smoke trail when damaged + if (s.smokeLevel > 0) { + ctx.fillStyle = `rgba(100,100,100,${0.15 * s.smokeLevel})` + for (let i = 0; i < 5; i++) { + const sx2 = mx - 15 - i * 12 + (Math.random() - 0.5) * 6 + const sy2 = my + (Math.random() - 0.5) * (4 + s.smokeLevel * 3) + ctx.beginPath() + ctx.arc(sx2, sy2, 3 + i * 1.5, 0, Math.PI * 2) + ctx.fill() + } + } + + // Flame trail + const flameLen = 8 + s.missileSpeed * 6 + ctx.fillStyle = '#f39c12' + ctx.beginPath() + ctx.moveTo(mx - 12, my - 3) + ctx.lineTo(mx - 12 - flameLen + (Math.random() - 0.5) * 4, my) + ctx.lineTo(mx - 12, my + 3) + ctx.closePath() + ctx.fill() + // Inner flame + ctx.fillStyle = '#f1c40f' + ctx.beginPath() + ctx.moveTo(mx - 12, my - 1.5) + ctx.lineTo(mx - 12 - flameLen * 0.5 + (Math.random() - 0.5) * 3, my) + ctx.lineTo(mx - 12, my + 1.5) + ctx.closePath() + ctx.fill() + + // Missile body + ctx.fillStyle = '#ecf0f1' + ctx.beginPath() + ctx.moveTo(mx + 18, my) // nose + ctx.lineTo(mx + 8, my - 6) + ctx.lineTo(mx - 12, my - 5) + ctx.lineTo(mx - 12, my + 5) + ctx.lineTo(mx + 8, my + 6) + ctx.closePath() + ctx.fill() + + // Nose cone + ctx.fillStyle = '#e74c3c' + ctx.beginPath() + ctx.moveTo(mx + 18, my) + ctx.lineTo(mx + 10, my - 4) + ctx.lineTo(mx + 10, my + 4) + ctx.closePath() + ctx.fill() + + // Fins + ctx.fillStyle = '#bdc3c7' + ctx.beginPath() + ctx.moveTo(mx - 10, my - 5) + ctx.lineTo(mx - 14, my - 12) + ctx.lineTo(mx - 6, my - 5) + ctx.closePath() + ctx.fill() + ctx.beginPath() + ctx.moveTo(mx - 10, my + 5) + ctx.lineTo(mx - 14, my + 12) + ctx.lineTo(mx - 6, my + 5) + ctx.closePath() + ctx.fill() + } + + // Particles + for (const p of s.particles) { + const alpha = Math.max(0, p.life / p.maxLife) + ctx.globalAlpha = alpha + ctx.fillStyle = p.color + ctx.beginPath() + ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI * 2) + ctx.fill() + } + ctx.globalAlpha = 1 + + // Screen flash (stronger during impact) + if (s.flashTimer > 0) { + ctx.fillStyle = s.flashColor + ctx.globalAlpha = s.flashTimer * (s.phase === 'impact' ? 0.6 : 0.3) + ctx.fillRect(0, 0, w, h) + ctx.globalAlpha = 1 + } + + ctx.restore() + }, []) + + // Game loop + useEffect(() => { + if (!started || gameOver) return + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + let lastTime = performance.now() + + const tick = (now: number) => { + const dt = Math.min((now - lastTime) / 16.67, 3) // normalize to ~60fps, cap + lastTime = now + const s = stateRef.current + + if (s.phase === 'gameover') { + draw(ctx, s) + syncHud(s) + return + } + + // Update ground scroll + s.groundOffset += s.missileSpeed * dt * 2 + + // Update particles + s.particles = s.particles.filter(p => { + p.x += p.vx * dt + p.y += p.vy * dt + p.vy += 0.05 * dt // gravity + p.life -= 0.016 * dt + return p.life > 0 + }) + + // Decay timers + if (s.shakeTimer > 0) s.shakeTimer = Math.max(0, s.shakeTimer - 0.03 * dt) + if (s.flashTimer > 0) s.flashTimer = Math.max(0, s.flashTimer - 0.04 * dt) + + if (s.phase === 'flying') { + s.phaseTimer += dt + // After brief flight, spawn interceptor + if (s.phaseTimer > 60) { // ~1 second + spawnInterceptor(s) + } + // City grows as we progress + s.cityScale = 0.3 + s.wordsCompleted * 0.15 + } + + if (s.phase === 'dodging' && s.interceptor) { + // Move interceptor toward missile + s.interceptor.x -= s.interceptor.speed * dt + // Check timeout + if (s.interceptor.x <= INTERCEPTOR_TIMEOUT_X) { + // Hit! + s.phase = 'hit' + s.hp -= 1 + s.smokeLevel = 3 - s.hp + s.shakeTimer = 1 + s.flashTimer = 1 + s.flashColor = '#e74c3c' + triggerShake() + playMissileHit() + // Damage particles + for (let i = 0; i < 10; i++) { + s.particles.push({ + x: s.missileX, + y: s.missileY, + vx: (Math.random() - 0.5) * 4, + vy: (Math.random() - 0.5) * 4, + life: 0.4 + Math.random() * 0.3, + maxLife: 0.7, + color: '#e74c3c', + size: 2 + Math.random() * 3, + }) + } + s.interceptor = null + s.currentWord = '' + s.typedIndex = 0 + s.phaseTimer = 0 + + if (s.hp <= 0) { + // Missile destroyed + s.phase = 'dead' + s.phaseTimer = 0 + playExplosion() + // Big explosion particles + for (let i = 0; i < 30; i++) { + const angle = Math.random() * Math.PI * 2 + const speed = 1 + Math.random() * 5 + s.particles.push({ + x: s.missileX, + y: s.missileY, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 0.6 + Math.random() * 0.6, + maxLife: 1.2, + color: ['#f39c12', '#e74c3c', '#f1c40f', '#ecf0f1'][Math.floor(Math.random() * 4)], + size: 3 + Math.random() * 5, + }) + } + } + } + } + + if (s.phase === 'hit') { + s.phaseTimer += dt + if (s.phaseTimer > 40) { // brief pause then continue + s.wordsCompleted += 1 + if (s.wordsCompleted >= 4) { + // Missile reached city — enter impact phase, game loop does the rest + s.phase = 'impact' + s.phaseTimer = 0 + s.fireballRadius = 0 + playExplosion() + const bonus = s.hp * 50 + s.score += bonus + } else { + s.phase = 'flying' + s.phaseTimer = 0 + s.cityScale = 0.3 + s.wordsCompleted * 0.15 + } + } + } + + if (s.phase === 'impact') { + s.phaseTimer += dt + + if (s.phaseTimer < 30) { + // Phase 1: missile flies into city + s.missileX += 5 * dt + s.cityScale = Math.min(1, s.cityScale + 0.02 * dt) + } else if (s.phaseTimer < 35) { + // Phase 2: initial detonation — big white flash, fireball starts + if (s.fireballRadius === 0) { + s.fireballRadius = 1 + s.cityDestroyed = true + s.shakeTimer = 1 + s.flashTimer = 1 + s.flashColor = '#ffffff' + triggerShake() + // Massive initial burst + for (let i = 0; i < 60; i++) { + const angle = Math.random() * Math.PI * 2 + const speed = 2 + Math.random() * 8 + s.particles.push({ + x: CITY_X - 20, + y: MISSILE_Y - 20 + Math.random() * 40, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 2, + life: 1.0 + Math.random() * 1.0, + maxLife: 2.0, + color: ['#ffffff', '#fff3b0', '#f39c12', '#e74c3c', '#f1c40f'][Math.floor(Math.random() * 5)], + size: 4 + Math.random() * 10, + }) + } + } + s.fireballRadius += 3 * dt + } else if (s.phaseTimer < 120) { + // Phase 3: fireball expands, debris rains, secondary explosions + s.fireballRadius += 1.5 * dt + s.shakeTimer = Math.max(s.shakeTimer, 0.5) + + // Continuous debris particles + if (Math.random() < 0.6) { + s.particles.push({ + x: CITY_X - 40 + Math.random() * 80, + y: MISSILE_Y - 30 + Math.random() * 60, + vx: (Math.random() - 0.5) * 5, + vy: -2 - Math.random() * 5, + life: 0.6 + Math.random() * 0.8, + maxLife: 1.4, + color: ['#f39c12', '#e74c3c', '#8b4513', '#555', '#f1c40f'][Math.floor(Math.random() * 5)], + size: 2 + Math.random() * 5, + }) + } + + // Secondary explosion bursts + if (s.phaseTimer > 50 && Math.random() < 0.08) { + const bx = CITY_X - 50 + Math.random() * 100 + const by = MISSILE_Y - 40 + Math.random() * 80 + for (let i = 0; i < 12; i++) { + const angle = Math.random() * Math.PI * 2 + const speed = 1 + Math.random() * 4 + s.particles.push({ + x: bx, y: by, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 0.4 + Math.random() * 0.4, + maxLife: 0.8, + color: ['#f39c12', '#e74c3c', '#f1c40f'][Math.floor(Math.random() * 3)], + size: 3 + Math.random() * 4, + }) + } + } + + // Rising smoke columns + if (Math.random() < 0.3) { + s.particles.push({ + x: CITY_X - 30 + Math.random() * 60, + y: MISSILE_Y + 20, + vx: (Math.random() - 0.5) * 0.5, + vy: -1 - Math.random() * 2, + life: 1.0 + Math.random() * 1.0, + maxLife: 2.0, + color: `rgba(60,60,60,0.6)`, + size: 6 + Math.random() * 8, + }) + } + } else if (s.phaseTimer < 160) { + // Phase 4: fade out, fireball shrinks + s.fireballRadius = Math.max(0, s.fireballRadius - 0.5 * dt) + } else { + // Done, next missile or game over + s.fireballRadius = 0 + s.cityDestroyed = false + s.missilesLeft -= 1 + s.missileIndex += 1 + if (s.missilesLeft <= 0) { + s.phase = 'gameover' + setFinalScore(s.score) + setGameOver(true) + if (!gameOverFiredRef.current) { + gameOverFiredRef.current = true + playGameOver() + onGameOver(s.score) + } + } else { + launchMissile(s) + } + } + } + + if (s.phase === 'dead') { + s.phaseTimer += dt + if (s.phaseTimer > 90) { + s.missilesLeft -= 1 + s.missileIndex += 1 + if (s.missilesLeft <= 0) { + s.phase = 'gameover' + setFinalScore(s.score) + setGameOver(true) + if (!gameOverFiredRef.current) { + gameOverFiredRef.current = true + playGameOver() + onGameOver(s.score) + } + } else { + launchMissile(s) + } + } + } + + // Flame particles (continuous during flight) + if (s.phase === 'flying' || s.phase === 'dodging') { + if (Math.random() < 0.4) { + s.particles.push({ + x: s.missileX - 14, + y: s.missileY + (Math.random() - 0.5) * 4, + vx: -1 - Math.random() * 2, + vy: (Math.random() - 0.5) * 0.5, + life: 0.2 + Math.random() * 0.2, + maxLife: 0.4, + color: Math.random() > 0.5 ? '#f39c12' : '#e74c3c', + size: 2 + Math.random() * 2, + }) + } + } + + draw(ctx, s) + syncHud(s) + + frameRef.current = requestAnimationFrame(tick) + } + + frameRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frameRef.current) + }, [started, gameOver, draw, syncHud, spawnInterceptor, launchMissile, triggerShake, onGameOver]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (gameOver) return + if (!started) { + startGame() + return + } + + const s = stateRef.current + if (s.phase !== 'dodging') return + if (e.key.length !== 1) return + e.preventDefault() + playKeyClick() + + const expected = s.currentWord[s.typedIndex] + if (e.key === expected) { + s.typedIndex += 1 + if (s.typedIndex >= s.currentWord.length) { + // Word completed - dodge! + const points = s.currentWord.length * 10 + s.score += points + s.wordsCompleted += 1 + s.missileSpeed = Math.min(4, s.missileSpeed + 0.5) + s.interceptor = null + s.currentWord = '' + s.typedIndex = 0 + s.flashTimer = 0.8 + s.flashColor = '#2ecc71' + playDodge() + + // Dodge particles + for (let i = 0; i < 8; i++) { + s.particles.push({ + x: s.missileX + 20, + y: s.missileY, + vx: 2 + Math.random() * 3, + vy: (Math.random() - 0.5) * 4, + life: 0.3 + Math.random() * 0.3, + maxLife: 0.6, + color: '#2ecc71', + size: 2 + Math.random() * 3, + }) + } + + if (s.wordsCompleted >= 4) { + // Enter impact phase — game loop handles the full explosion sequence + s.phase = 'impact' + s.phaseTimer = 0 + s.fireballRadius = 0 + playExplosion() + const bonus = s.hp * 50 + s.score += bonus + } else { + // Next interceptor after brief flight + s.phase = 'flying' + s.phaseTimer = 0 + s.cityScale = 0.3 + s.wordsCompleted * 0.15 + } + } + } + // Wrong key: do nothing (no penalty for wrong chars, just no progress) + }, [started, gameOver, startGame, triggerShake]) + + return ( +
+
+

Missile Strike

+ + High Score: {highScore} + + {started && !gameOver && ( + + )} +
+ +
setFocused(true)} + onBlur={() => setFocused(false)} + > + + + {/* HUD overlay */} + {started && !gameOver && ( +
+
+ Score + {hudScore} +
+
+ Missiles +
+ {[0, 1, 2].map(i => ( + + 🚀 + + ))} +
+
+
+ HP +
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+
+ )} + + {/* Word prompt */} + {started && !gameOver && hudWord && hudPhase === 'dodging' && ( +
+ {hudWord.split('').map((ch, i) => ( + + {ch} + + ))} +
+ )} + + {/* Progress bar */} + {started && !gameOver && ( +
+
+
+ )} + + {/* Ready screen */} + {!started && ( +
+
Missile Strike
+
+ Type words to dodge interceptors and strike the city! +
+
+ 3 missiles, 3 HP each. Type 4 words per missile to reach the target. +
+ +
+ )} + + {/* Game over */} + {gameOver && ( +
+
Mission Complete
+
Score: {finalScore}
+ {finalScore > highScore && finalScore > 0 && ( +
New High Score!
+ )} + +
+ )} +
+
+ ) +} diff --git a/src/sounds.ts b/src/sounds.ts index b9fabc0..b485658 100644 --- a/src/sounds.ts +++ b/src/sounds.ts @@ -105,6 +105,131 @@ export function playMiss() { noise.start(t) } +export function playLaunch() { + const c = getCtx() + const t = c.currentTime + + // Rising sawtooth sweep (rocket rumble) + const osc = c.createOscillator() + const gain = c.createGain() + osc.type = 'sawtooth' + osc.frequency.setValueAtTime(80, t) + osc.frequency.exponentialRampToValueAtTime(200, t + 0.5) + gain.gain.setValueAtTime(0.12, t) + gain.gain.linearRampToValueAtTime(0.18, t + 0.2) + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.6) + osc.connect(gain).connect(c.destination) + osc.start(t) + osc.stop(t + 0.6) + + // Noise burst for exhaust + const bufferSize = c.sampleRate * 0.4 + const buffer = c.createBuffer(1, bufferSize, c.sampleRate) + const data = buffer.getChannelData(0) + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize) + } + const noise = c.createBufferSource() + const noiseGain = c.createGain() + noise.buffer = buffer + noiseGain.gain.setValueAtTime(0.1, t) + noiseGain.gain.exponentialRampToValueAtTime(0.001, t + 0.4) + noise.connect(noiseGain).connect(c.destination) + noise.start(t) +} + +export function playDodge() { + const c = getCtx() + const t = c.currentTime + + // Quick high whoosh + const osc = c.createOscillator() + const gain = c.createGain() + osc.type = 'sine' + osc.frequency.setValueAtTime(400, t) + osc.frequency.exponentialRampToValueAtTime(1600, t + 0.15) + gain.gain.setValueAtTime(0.12, t) + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15) + osc.connect(gain).connect(c.destination) + osc.start(t) + osc.stop(t + 0.15) +} + +export function playMissileHit() { + const c = getCtx() + const t = c.currentTime + + // Heavy thud + const osc = c.createOscillator() + const gain = c.createGain() + osc.type = 'sine' + osc.frequency.setValueAtTime(120, t) + osc.frequency.exponentialRampToValueAtTime(40, t + 0.3) + gain.gain.setValueAtTime(0.25, t) + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35) + osc.connect(gain).connect(c.destination) + osc.start(t) + osc.stop(t + 0.35) + + // Crunch noise + const bufferSize = c.sampleRate * 0.15 + const buffer = c.createBuffer(1, bufferSize, c.sampleRate) + const data = buffer.getChannelData(0) + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize) + } + const noise = c.createBufferSource() + const noiseGain = c.createGain() + noise.buffer = buffer + noiseGain.gain.setValueAtTime(0.12, t) + noiseGain.gain.exponentialRampToValueAtTime(0.001, t + 0.15) + noise.connect(noiseGain).connect(c.destination) + noise.start(t) +} + +export function playExplosion() { + const c = getCtx() + const t = c.currentTime + + // Low sine burst + const osc = c.createOscillator() + const gain = c.createGain() + osc.type = 'sine' + osc.frequency.setValueAtTime(60, t) + osc.frequency.exponentialRampToValueAtTime(20, t + 0.8) + gain.gain.setValueAtTime(0.25, t) + gain.gain.exponentialRampToValueAtTime(0.001, t + 1.0) + osc.connect(gain).connect(c.destination) + osc.start(t) + osc.stop(t + 1.0) + + // Long noise decay + const bufferSize = c.sampleRate * 0.8 + const buffer = c.createBuffer(1, bufferSize, c.sampleRate) + const data = buffer.getChannelData(0) + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2) + } + const noise = c.createBufferSource() + const noiseGain = c.createGain() + noise.buffer = buffer + noiseGain.gain.setValueAtTime(0.15, t) + noiseGain.gain.exponentialRampToValueAtTime(0.001, t + 0.8) + noise.connect(noiseGain).connect(c.destination) + noise.start(t) + + // Sub-bass rumble + const sub = c.createOscillator() + const subGain = c.createGain() + sub.type = 'sine' + sub.frequency.setValueAtTime(30, t) + subGain.gain.setValueAtTime(0.2, t) + subGain.gain.exponentialRampToValueAtTime(0.001, t + 1.0) + sub.connect(subGain).connect(c.destination) + sub.start(t) + sub.stop(t + 1.0) +} + export function playGameOver() { const c = getCtx() const t = c.currentTime diff --git a/src/styles/missile.module.css b/src/styles/missile.module.css new file mode 100644 index 0000000..3631339 --- /dev/null +++ b/src/styles/missile.module.css @@ -0,0 +1,253 @@ +.wrapper { + position: relative; + border-radius: var(--radius); + overflow: hidden; + height: 500px; + outline: none; + border: 2px solid transparent; + transition: border-color 0.2s; +} + +.wrapper:focus { + border-color: var(--accent); +} + +.wrapper.blurred::after { + content: 'Click to focus!'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(26, 27, 46, 0.8); + border-radius: var(--radius); + color: var(--text-dim); + font-size: 18px; + z-index: 5; +} + +.canvas { + display: block; + width: 100%; + height: 100%; +} + +/* HUD overlay */ +.hud { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 12px 20px; + z-index: 2; + font-family: var(--font-mono); + pointer-events: none; +} + +.hudLeft, .hudRight { + display: flex; + flex-direction: column; + gap: 4px; +} + +.hudLabel { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim); +} + +.hudValue { + font-size: 20px; + font-weight: 700; + color: var(--accent); +} + +.missiles { + display: flex; + gap: 6px; +} + +.missilePip { + font-size: 18px; + opacity: 0.3; +} + +.missilePip.active { + opacity: 1; +} + +.hpPips { + display: flex; + gap: 4px; +} + +.hpPip { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--error); + opacity: 0.25; + transition: opacity 0.3s; +} + +.hpPip.filled { + opacity: 1; + box-shadow: 0 0 6px var(--error); +} + +/* Word prompt */ +.wordPrompt { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: var(--bg); + padding: 10px 24px; + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 22px; + color: var(--text); + border: 2px solid var(--accent); + min-width: 220px; + text-align: center; + z-index: 3; + pointer-events: none; +} + +.typedChar { + color: var(--success); +} + +.cursor { + border-bottom: 2px solid var(--accent); + animation: blink 0.8s step-end infinite; +} + +@keyframes blink { + 50% { border-color: transparent; } +} + +/* Progress bar */ +.progressWrap { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 6px; + background: rgba(255, 255, 255, 0.05); + z-index: 2; +} + +.progressFill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--success)); + transition: width 0.4s ease; +} + +/* Overlay screens */ +.overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(26, 27, 46, 0.95); + z-index: 10; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.overlayTitle { + font-size: 36px; + font-weight: 700; + margin-bottom: 16px; +} + +.overlaySubtitle { + font-size: 20px; + color: var(--text-dim); + margin-bottom: 8px; +} + +.newHighScore { + color: var(--warning); + font-weight: 600; +} + +.overlayBtn { + margin-top: 24px; + background: var(--accent); + color: #fff; + font-weight: 600; + font-size: 18px; + padding: 14px 36px; + border: none; + border-radius: var(--radius); + cursor: pointer; +} + +/* Screen shake */ +.shake { + animation: shake 0.3s ease-out; +} + +@keyframes shake { + 0%, 100% { transform: translate(0, 0); } + 15% { transform: translate(-4px, 2px); } + 30% { transform: translate(4px, -2px); } + 45% { transform: translate(-3px, 3px); } + 60% { transform: translate(3px, -1px); } + 75% { transform: translate(-2px, 1px); } +} + +/* Game selector */ +.selector { + display: flex; + gap: 20px; + justify-content: center; + align-items: stretch; + padding: 40px 20px; +} + +.selectorCard { + background: var(--bg-card); + border: 2px solid var(--bg-hover); + border-radius: var(--radius); + padding: 32px 28px; + cursor: pointer; + transition: border-color 0.2s, transform 0.15s; + text-align: center; + flex: 1; + max-width: 280px; +} + +.selectorCard:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.selectorIcon { + font-size: 48px; + margin-bottom: 16px; +} + +.selectorTitle { + font-size: 20px; + font-weight: 700; + margin-bottom: 8px; +} + +.selectorDesc { + font-size: 14px; + color: var(--text-dim); + line-height: 1.5; +}