missiles
This commit is contained in:
parent
0734364269
commit
4d3395fa1c
10 changed files with 1560 additions and 9 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -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
32
CLAUDE.md
Normal 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
65
devenv.lock
Normal 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
56
devenv.nix
Normal 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
15
devenv.yaml
Normal 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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
924
src/components/MissileGame.tsx
Normal file
924
src/components/MissileGame.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/sounds.ts
125
src/sounds.ts
|
|
@ -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
|
||||||
|
|
|
||||||
253
src/styles/missile.module.css
Normal file
253
src/styles/missile.module.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue