This commit is contained in:
polwex 2026-03-25 23:24:54 +09:00
parent 0734364269
commit 4d3395fa1c
10 changed files with 1560 additions and 9 deletions

11
.gitignore vendored
View file

@ -22,3 +22,14 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

32
CLAUDE.md Normal file
View file

@ -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.

65
devenv.lock Normal file
View file

@ -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
}

56
devenv.nix Normal file
View file

@ -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/
}

15
devenv.yaml Normal file
View file

@ -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

View file

@ -4,7 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --port 5174 --host",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"

View file

@ -1,6 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import styles from '../styles/game.module.css' import styles from '../styles/game.module.css'
import selectorStyles from '../styles/missile.module.css'
import { playKeyClick, playWordComplete, playCombo, playMiss, playGameOver } from '../sounds' import { playKeyClick, playWordComplete, playCombo, playMiss, playGameOver } from '../sounds'
import { MissileGame } from './MissileGame'
const WORD_POOL = [ const WORD_POOL = [
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her', 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her',
@ -64,12 +66,12 @@ type Props = {
onGameOver: (score: number) => void onGameOver: (score: number) => void
} }
export function GameMode({ highScore, onGameOver }: Props) { function FallingWordsGame({ highScore, onGameOver }: Props) {
const [words, setWords] = useState<FallingWord[]>([]) const [words, setWords] = useState<FallingWord[]>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const inputRef = useRef('') const inputRef = useRef('')
const [score, setScore] = useState(0) const [score, setScore] = useState(0)
const [lives, setLives] = useState(3) const [lives, setLives] = useState(5)
const [gameOver, setGameOver] = useState(false) const [gameOver, setGameOver] = useState(false)
const [started, setStarted] = useState(false) const [started, setStarted] = useState(false)
const [focused, setFocused] = useState(false) const [focused, setFocused] = useState(false)
@ -84,7 +86,7 @@ export function GameMode({ highScore, onGameOver }: Props) {
const frameRef = useRef<number>(0) const frameRef = useRef<number>(0)
const lastSpawnRef = useRef(0) const lastSpawnRef = useRef(0)
const difficultyRef = useRef(1) const difficultyRef = useRef(1)
const livesRef = useRef(3) const livesRef = useRef(5)
const gameOverRef = useRef(false) const gameOverRef = useRef(false)
livesRef.current = lives livesRef.current = lives
@ -100,7 +102,7 @@ export function GameMode({ highScore, onGameOver }: Props) {
text, text,
x: Math.random() * 70 + 5, // 5%-75% from left x: Math.random() * 70 + 5, // 5%-75% from left
y: -5, y: -5,
speed: 0.3 + difficulty * 0.08, speed: 0.2 + difficulty * 0.05,
matched: 0, matched: 0,
} }
setWords(prev => [...prev, word]) setWords(prev => [...prev, word])
@ -110,7 +112,7 @@ export function GameMode({ highScore, onGameOver }: Props) {
setWords([]) setWords([])
updateInput('') updateInput('')
setScore(0) setScore(0)
setLives(3) setLives(5)
setGameOver(false) setGameOver(false)
setStarted(true) setStarted(true)
setExplosions([]) setExplosions([])
@ -134,7 +136,7 @@ export function GameMode({ highScore, onGameOver }: Props) {
lastTime = now lastTime = now
// Spawn // Spawn
const spawnInterval = Math.max(1500 - difficultyRef.current * 80, 600) const spawnInterval = Math.max(2200 - difficultyRef.current * 80, 900)
if (now - lastSpawnRef.current > spawnInterval) { if (now - lastSpawnRef.current > spawnInterval) {
spawnWord() spawnWord()
lastSpawnRef.current = now lastSpawnRef.current = now
@ -154,6 +156,11 @@ export function GameMode({ highScore, onGameOver }: Props) {
} }
if (lostLife) { if (lostLife) {
playMiss() playMiss()
difficultyRef.current = 1
const baseSpeed = 0.2 + 1 * 0.05
for (const w of next) {
w.speed = baseSpeed
}
setLives(l => { setLives(l => {
const nl = l - 1 const nl = l - 1
if (nl <= 0) { if (nl <= 0) {
@ -166,7 +173,7 @@ export function GameMode({ highScore, onGameOver }: Props) {
}) })
// Increase difficulty // Increase difficulty
difficultyRef.current = 1 + score / 50 difficultyRef.current = 1 + score / 100
frameRef.current = requestAnimationFrame(tick) frameRef.current = requestAnimationFrame(tick)
} }
@ -195,6 +202,12 @@ export function GameMode({ highScore, onGameOver }: Props) {
return return
} }
if (e.key === 'Escape') {
updateInput('')
setWords(prev => prev.map(w => ({ ...w, matched: 0 })))
return
}
if (e.key === 'Backspace') { if (e.key === 'Backspace') {
const newInput = inputRef.current.slice(0, -1) const newInput = inputRef.current.slice(0, -1)
updateInput(newInput) updateInput(newInput)
@ -292,7 +305,7 @@ export function GameMode({ highScore, onGameOver }: Props) {
<div className={styles.hudItem}> <div className={styles.hudItem}>
<span className={styles.hudLabel}>Lives</span> <span className={styles.hudLabel}>Lives</span>
<span className={`${styles.hudValue} ${styles.lives}`}> <span className={`${styles.hudValue} ${styles.lives}`}>
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 3 - lives))} {'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 5 - lives))}
</span> </span>
</div> </div>
</div> </div>
@ -402,3 +415,60 @@ export function GameMode({ highScore, onGameOver }: Props) {
</div> </div>
) )
} }
type GameChoice = 'select' | 'falling' | 'missile'
export function GameMode({ highScore, onGameOver }: Props) {
const [choice, setChoice] = useState<GameChoice>('select')
if (choice === 'falling') {
return (
<div className="fade-in">
<div style={{ marginBottom: 8 }}>
<button onClick={() => setChoice('select')} style={{ fontSize: 14, padding: '6px 14px' }}>
Back
</button>
</div>
<FallingWordsGame highScore={highScore} onGameOver={onGameOver} />
</div>
)
}
if (choice === 'missile') {
return (
<div className="fade-in">
<div style={{ marginBottom: 8 }}>
<button onClick={() => setChoice('select')} style={{ fontSize: 14, padding: '6px 14px' }}>
Back
</button>
</div>
<MissileGame highScore={highScore} onGameOver={onGameOver} />
</div>
)
}
return (
<div className="fade-in">
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Games</h2>
<p style={{ color: 'var(--text-dim)', fontSize: 14, marginBottom: 24 }}>
High Score: {highScore}
</p>
<div className={selectorStyles.selector}>
<div className={selectorStyles.selectorCard} onClick={() => setChoice('falling')}>
<div className={selectorStyles.selectorIcon}>🌧</div>
<div className={selectorStyles.selectorTitle}>Falling Words</div>
<div className={selectorStyles.selectorDesc}>
Type falling words before they hit the ground. Build combos for bonus points!
</div>
</div>
<div className={selectorStyles.selectorCard} onClick={() => setChoice('missile')}>
<div className={selectorStyles.selectorIcon}>🚀</div>
<div className={selectorStyles.selectorTitle}>Missile Strike</div>
<div className={selectorStyles.selectorDesc}>
Launch missiles at the enemy city. Type words to dodge interceptors!
</div>
</div>
</div>
</div>
)
}

View file

@ -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<HTMLCanvasElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const stateRef = useRef<GameState>({ ...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<Phase>('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 (
<div className="fade-in">
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Missile Strike</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={wrapperRef}
className={`${styles.wrapper} ${!focused ? styles.blurred : ''} ${shaking ? styles.shake : ''}`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
<canvas
ref={canvasRef}
className={styles.canvas}
width={CANVAS_W}
height={CANVAS_H}
/>
{/* HUD overlay */}
{started && !gameOver && (
<div className={styles.hud}>
<div className={styles.hudLeft}>
<span className={styles.hudLabel}>Score</span>
<span className={styles.hudValue}>{hudScore}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span className={styles.hudLabel}>Missiles</span>
<div className={styles.missiles}>
{[0, 1, 2].map(i => (
<span key={i} className={`${styles.missilePip} ${i < hudMissilesLeft ? styles.active : ''}`}>
🚀
</span>
))}
</div>
</div>
<div className={styles.hudRight}>
<span className={styles.hudLabel}>HP</span>
<div className={styles.hpPips}>
{[0, 1, 2].map(i => (
<div key={i} className={`${styles.hpPip} ${i < hudHp ? styles.filled : ''}`} />
))}
</div>
</div>
</div>
)}
{/* Word prompt */}
{started && !gameOver && hudWord && hudPhase === 'dodging' && (
<div className={styles.wordPrompt}>
{hudWord.split('').map((ch, i) => (
<span
key={i}
className={i < hudTypedIndex ? styles.typedChar : i === hudTypedIndex ? styles.cursor : ''}
>
{ch}
</span>
))}
</div>
)}
{/* Progress bar */}
{started && !gameOver && (
<div className={styles.progressWrap}>
<div className={styles.progressFill} style={{ width: `${hudProgress * 100}%` }} />
</div>
)}
{/* Ready screen */}
{!started && (
<div className={styles.overlay}>
<div className={styles.overlayTitle}>Missile Strike</div>
<div className={styles.overlaySubtitle}>
Type words to dodge interceptors and strike the city!
</div>
<div style={{ color: 'var(--text-dim)', fontSize: 14, marginTop: 8, maxWidth: 400, textAlign: 'center', lineHeight: 1.6 }}>
3 missiles, 3 HP each. Type 4 words per missile to reach the target.
</div>
<button className={styles.overlayBtn} onClick={startGame}>
Launch!
</button>
</div>
)}
{/* Game over */}
{gameOver && (
<div className={styles.overlay}>
<div className={styles.overlayTitle}>Mission Complete</div>
<div className={styles.overlaySubtitle}>Score: {finalScore}</div>
{finalScore > highScore && finalScore > 0 && (
<div className={styles.newHighScore}>New High Score!</div>
)}
<button className={styles.overlayBtn} onClick={startGame}>
Play Again
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -105,6 +105,131 @@ export function playMiss() {
noise.start(t) 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() { export function playGameOver() {
const c = getCtx() const c = getCtx()
const t = c.currentTime const t = c.currentTime

View file

@ -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;
}