diff --git a/.claude/hooks/block-risky-bash.sh b/.claude/hooks/block-risky-bash.sh new file mode 100755 index 0000000..0a68d6b --- /dev/null +++ b/.claude/hooks/block-risky-bash.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# PreToolUse Bash guard: block dangerous shell commands. +# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777, +# fork bombs, block device overwrites, mkfs, building Rust on macOS for Linux. +set -euo pipefail + +INPUT=$(cat) +CMD=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('command', '')) +except: pass +" <<< "$INPUT") +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('cwd', '')) +except: pass +" <<< "$INPUT") +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Normalize: collapse whitespace, strip leading/trailing +CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +deny() { + local reason="$1" + python3 -c " +import json +print(json.dumps({ + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': '$reason' + } +})) +" + exit 0 +} + +# Dangerous patterns +case "$CMD_NORM" in + *"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;; + *"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;; + *"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;; + *"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;; + *"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;; + *":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;; + *"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;; + *"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;; +esac + +# Block building Rust locally on macOS (should always build on dev server) +if [[ "$(uname)" == "Darwin" ]]; then + if echo "$CMD_NORM" | grep -qE '^\s*cargo\s+build'; then + # Allow if it's clearly an SSH command (building on remote) + if ! echo "$CMD_NORM" | grep -qE 'ssh|sshpass'; then + deny "NEVER build Rust on macOS — use ./scripts/deploy-to-target.sh --live or build on dev server via SSH" + fi + fi +fi + +# Check for path traversal escaping project root +if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then + if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then + if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then + if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then + deny "Path traversal with rm blocked" + fi + fi + fi +fi + +exit 0 diff --git a/.claude/hooks/post-deploy-check.sh b/.claude/hooks/post-deploy-check.sh new file mode 100755 index 0000000..ea49b0a --- /dev/null +++ b/.claude/hooks/post-deploy-check.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# PostToolUse Bash hook: detect deploy commands and remind to test. +# Triggers after deploy-to-target.sh runs. +set -euo pipefail + +INPUT=$(cat) + +CMD=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('command', '')) +except: pass +" <<< "$INPUT") + +# Only trigger on deploy commands or git push +if ! echo "$CMD" | grep -qE 'deploy-to-target|git\s+push'; then + exit 0 +fi + +TIMESTAMP=$(date '+%Y-%m-%d %H:%M') + +python3 -c " +import json + +message = '''Deploy detected at $TIMESTAMP. + +Post-deploy checklist: +1. Test the web UI at http://192.168.1.228 +2. Verify modified apps load correctly +3. Check backend logs: sudo journalctl -u archipelago -n 20 +4. Check nginx: sudo tail -f /var/log/nginx/error.log +5. If building ISO, sync system configs to image-recipe/configs/ +6. Update CHANGELOG.md if this is a notable change''' + +output = { + 'hookSpecificOutput': { + 'hookEventName': 'PostToolUse', + 'deployReminder': message + } +} +print(json.dumps(output)) +" diff --git a/.claude/hooks/post-push-progress.sh b/.claude/hooks/post-push-progress.sh new file mode 100755 index 0000000..fa90935 --- /dev/null +++ b/.claude/hooks/post-push-progress.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# PostToolUse Bash hook: detect git push/commit and prompt Claude to update PROGRESS.md. +# Returns structured feedback with recent commits so Claude can write a session log entry. +# Uses python3 instead of jq for JSON (guaranteed on macOS). +set -euo pipefail + +INPUT=$(cat) + +# Extract command from JSON using python3 +CMD=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('command', '')) +except: pass +" <<< "$INPUT") + +# Only trigger on git push or git commit commands +if ! echo "$CMD" | grep -qE '\bgit\s+(push|commit)\b'; then + exit 0 +fi + +# Gather context for the progress update +BASE="${CLAUDE_PROJECT_DIR:-$(pwd)}" +BRANCH=$(git -C "$BASE" branch --show-current 2>/dev/null || echo "unknown") +PROGRESS_FILE="$BASE/PROGRESS.md" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M') + +# Get recent commits (branch vs main, or last 10) +if git -C "$BASE" rev-parse --verify main &>/dev/null; then + COMMITS=$(git -C "$BASE" log --oneline main..HEAD 2>/dev/null | head -15) + if [ -z "$COMMITS" ]; then + COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null) + fi +else + COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null) +fi + +# Get changed files in recent commits +CHANGED_FILES=$(git -C "$BASE" diff --name-only main..HEAD 2>/dev/null | head -20 || \ + git -C "$BASE" diff --name-only HEAD~5..HEAD 2>/dev/null | head -20 || \ + echo "unknown") + +# Build the feedback message and output as JSON using python3 +python3 -c " +import json, sys + +message = '''Progress Update Needed + +A git push/commit was detected on branch \`$BRANCH\` at $TIMESTAMP. + +Recent commits: +\`\`\` +$COMMITS +\`\`\` + +Changed files: +\`\`\` +$CHANGED_FILES +\`\`\` + +Please update PROGRESS.md: +1. Add a session log entry under '## Session Log' with format: ### $TIMESTAMP — $BRANCH +2. Summarize what was accomplished (2-4 bullet points based on the commits above) +3. Update any roadmap checkboxes if tasks were completed +4. Commit the PROGRESS.md update''' + +output = { + 'hookSpecificOutput': { + 'hookEventName': 'PostToolUse', + 'progressUpdate': message + } +} +print(json.dumps(output)) +" diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh new file mode 100755 index 0000000..3ff5453 --- /dev/null +++ b/.claude/hooks/protect-files.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# PreToolUse Edit|Write guard: block edits outside project and to protected paths. +# Denies: paths outside project, .git/, .env*, lockfiles, node_modules/, deploy-config.sh +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('file_path', '')) +except: pass +" <<< "$INPUT") +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('cwd', '')) +except: pass +" <<< "$INPUT") +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Resolve to absolute path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi +ABS_BASE=$(cd "$BASE" 2>/dev/null && pwd) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$BASE" 2>/dev/null) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE="$BASE" +[[ "$ABS_BASE" != */ ]] && ABS_BASE="${ABS_BASE}/" +if [[ "$FILE_PATH" != /* ]]; then + ABS_PATH="$ABS_BASE${FILE_PATH#./}" +else + ABS_PATH="$FILE_PATH" +fi +ABS_PATH=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$ABS_PATH" 2>/dev/null) || true +[[ -z "$ABS_PATH" ]] && ABS_PATH="$ABS_BASE${FILE_PATH#./}" + +deny() { + local reason="$1" + echo "Blocked: $ABS_PATH — $reason" >&2 + python3 -c " +import json +print(json.dumps({ + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': '$reason' + } +})) +" + exit 0 +} + +# Protected patterns +PROTECTED_PATTERNS=( + ".git/" + ".env" + ".env.local" + "node_modules/" + "package-lock.json" + "scripts/deploy-config.sh" +) + +for pattern in "${PROTECTED_PATTERNS[@]}"; do + if [[ "$ABS_PATH" == *"$pattern"* ]] || [[ "$ABS_PATH" == *"/$pattern" ]]; then + deny "Edit blocked: path matches protected pattern ($pattern)" + fi +done + +# .env.*.local +if [[ "$ABS_PATH" =~ \.env\..*\.local$ ]]; then + deny "Edit blocked: .env.*.local files contain secrets" +fi + +# Ensure path is under project root +if [[ "$ABS_PATH" != "$ABS_BASE"* ]] && [[ "$ABS_PATH" != "$BASE"* ]]; then + deny "Edit blocked: path is outside project directory" +fi + +exit 0 diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..65bd0de --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,13 @@ +# BOTFIGHTS Project Memory + +## Architecture +- **Frontend**: Vue 3 + Kaplay game engine, Vite, TypeScript +- **Server**: Node.js + Drizzle ORM, at `/Users/dorian/projects/botfights/server` +- **Frontend path**: `/Users/dorian/Projects/botfights/frontend` + +## Content Counts +- **100 archetypes** in `sprites/archetypes/` (6 original + 19 individual + 75 in 5 batch files) +- **300 choreographies** (42 hand-crafted + 258 from 9 factories) +- **20 music tracks** (4 hand-crafted + 16 via genTrack) +- **30 voice profiles** (6 original + 24 new) +- **22 challenge types** in CHALLENGE_THEMED diff --git a/.claude/plans/concurrent-meandering-muffin.md b/.claude/plans/concurrent-meandering-muffin.md new file mode 100644 index 0000000..e76beff --- /dev/null +++ b/.claude/plans/concurrent-meandering-muffin.md @@ -0,0 +1,330 @@ +# Botfights: 30-Day Production Plan + +## Context + +Botfights is a procedural fighting game (Vue 3 + Kaplay + Hono/SQLite) where AI bots battle through challenge rounds with pixel art sprites, synthesized audio, and choreographed fight animations. Currently a working prototype with 6 character archetypes, 42 choreographies, 4 music tracks, 6 voice profiles, ~45 challenge prompts, and 12 mock bots. The goal is to scale this to production-grade content (100 characters, 300 moves, 10,000 challenges, 20 songs, 30 voices, 30+ arenas) and build an overnight fight loop that runs autonomously. + +--- + +## Phase 1: Module Splitting (Days 1-6) + +The 3 monolith files must be split before any content scaling. Without this, adding content to 3500-line files is unmaintainable. + +### Day 1-2: Split `sprites.ts` (954 lines) + +``` +frontend/src/game/sprites/ + index.ts -- re-exports generateSpriteSheet, getBotColors, etc. + palette.ts -- makePal, parseHSL, Pal interface + constants.ts -- FRAME_SIZE, ANIMATIONS, INTERNAL, SCALE + drawing.ts -- px(), box(), fill() drawing primitives + base-body.ts -- drawFrame() core body/head/arms/legs/pose logic + archetypes/ + index.ts -- archetype registry + roll function + standard.ts -- base (no extra features) + lobster.ts -- claws, antennae, segmented body, tail + sheep.ts -- wool, floppy ears, hooves + cyborg.ts -- metal plating, robot eye, wiring + blob.ts -- wobbly outline, googly eyes, drool + tank.ts -- armor, rivets, treads + judge.ts -- generateJudgeSpriteSheet() +``` + +Each archetype exports `{ name, weight, drawFeatures(ctx, pal, params) }`. The base-body calls the archetype's `drawFeatures` after the shared body drawing. + +**Test:** Sprite preview page renders all 6 archetypes at all tiers identically to before. + +### Day 3-4: Split `FightScene.ts` (~3500 lines) + +``` +frontend/src/game/fight/ + index.ts -- createFightScene() orchestrator, returns controller + types.ts -- FightContext interface (k, W, H, GROUND_Y, HOME_A, HOME_B, helpers) + constants.ts -- ARENA_THEMES, spriteAnims, positions + particles.ts -- spawnSparks, spawnBulletHoles, spawnExhaust, etc. + projectiles.ts -- spawnProjectile, spawnBullet, spawnProp, spawnBarrage + effects.ts -- glitchRGB, scanlineGlitch, vhsTracking, dimensionalShift, etc. + grotesque.ts -- spawnGrotesqueDetails, destroyGrotesqueDetails + arena-decor.ts -- drawArenaDecor() per-arena backgrounds + choreography/ + index.ts -- choreographyMap registry, pickChoreography + challenge-map.ts -- CHALLENGE_THEMED + critMoves + basic.ts -- dashPunch, aerialSlam, flyingKick, uppercut + ranged.ts -- projectile, gunBurst, sniperShot, minigunSpray + movement.ts -- dashThrough, fullScreenDash, teleportStrike + heavy.ts -- groundPound, jetpackDive, bodySlam, suplex + weapons.ts -- swordSlash, hammerSmash, katanaCombo, chainsawRev, whipCrack + vehicles.ts -- motorbikeCharge, carSmash, boatCannon + themed.ts -- golfClubSmash, fireBreath, riddleBarrage, coinShower, etc. + finishers.ts -- playKO (4 styles), playPerfect + round.ts -- playRound, playTaunt, playDodge +``` + +**Critical pattern:** Choreography functions close over Kaplay context. Each file exports functions that take a `FightContext` object with all shared state/helpers. + +**Test:** Replay several fights, verify all 42 choreographies work. + +### Day 5-6: Split `sounds.ts` (1381 lines) + server modules + +``` +frontend/src/game/sounds/ + index.ts -- re-exports + context.ts -- AudioContext setup, gain nodes + primitives.ts -- tone(), noise(), sweep() helpers + sfx.ts -- all sfx functions (punch, kick, special, etc.) + voice.ts -- voice profiles, speak(), announce*(), voice lines + music/ + index.ts -- startMusic(), stopMusic(), setMusicIntensity() + types.ts -- MusicTrack interface + player.ts -- playMusicBar() scheduler + tracks/ + neon-fury.ts + dark-circuit.ts + pixel-blitz.ts + skull-crusher.ts + +server/src/engine/ + challenges/ + index.ts -- pickChallenge(), registry + types.ts -- Challenge interfaces + speed-blitz.ts -- prompts array + riddle.ts + ... (one per type) + mock/ + index.ts -- runMockFight(), seedMockFights() + bots.ts -- MOCK_BOTS array + answers.ts -- MOCK_ANSWERS, BAD_ANSWERS + trash-talk.ts -- TRASH_TALK lines +``` + +**Test:** Full end-to-end fight replay with music, SFX, voice. + +**Deliverable:** Identical functionality, ~40 small focused modules instead of 3 monoliths. + +--- + +## Phase 2: Characters (Days 7-12) + +### Day 7-8: New archetypes batch 1 (8 new) +- `dog.ts` -- floppy ears, wagging tail, snout with tongue, collar +- `cat.ts` -- pointed ears, whiskers, curled tail, slit eyes +- `cactus.ts` -- spikes everywhere, small flower on head, no real arms +- `pizza.ts` -- triangular body, cheese drip, pepperoni spots +- `mushroom.ts` -- dome cap head, spotted cap, stubby legs, spore particles +- `shark.ts` -- dorsal fin, teeth row, tail fin +- `penguin.ts` -- tuxedo coloring, beak, flippers +- `octopus.ts` -- 4 visible tentacles, large head, suction cups + +### Day 9-10: New archetypes batch 2 (8 more) +- `skeleton.ts` -- visible ribs, skull head, bony limbs +- `ghost.ts` -- semi-transparent, wavy bottom (no legs), glowing eyes +- `alien.ts` -- big head, huge eyes, antenna, green tint +- `dinosaur.ts` -- tail, tiny arms, spiky back, big jaw +- `pirate.ts` -- eye patch, hat, peg leg, hook hand +- `ninja.ts` -- mask, headband, throwing star +- `cowboy.ts` -- hat, bandana, boots with spurs +- `wizard.ts` -- pointed hat, robe, staff replaces one arm, beard + +### Day 11: Final archetypes + hybrid system +- `bee.ts` -- stripes, wings, stinger, antennae +- `frog.ts` -- wide mouth, bulging eyes, webbed feet, long tongue +- `snail.ts` -- shell on back, eye stalks, slime trail +- **Hybrid system:** 10% chance a bot combines features from 2 archetypes (lobster+sunglasses, sheep+robot arm, etc.) +- **Total:** 25 archetypes + +### Day 12: 100 Named Bots + Seed Data +Expand `MOCK_BOTS` from 12 to 100 with distribution: +- ~5 tier-5, ~10 tier-4, ~20 tier-3, ~25 tier-2, ~25 tier-1, ~15 tier-0 + +Funny names by theme: +- **Tech:** `segfault_sarah`, `stack_overflow_sam`, `infinite_loop_larry`, `null_reference_nancy` +- **Food:** `pizza_pete`, `sushi_sensei`, `taco_tornado`, `burrito_bomber` +- **Animals:** `mega_lobster_xl`, `tactical_penguin`, `stealth_shark_3000` +- **Pop culture:** `keyboard_warrior`, `meme_lord_420`, `reply_guy_9000` +- **Absurd:** `sentient_toaster`, `angry_calculator`, `philosophical_fork` + +Pre-seed 200 fight history records. + +**Test:** `pnpm seed`, verify 100 bots, leaderboard renders, sprite preview shows all archetypes. + +--- + +## Phase 3: Choreographies (Days 13-19) + +Scale from 42 to 300 choreographies. Each follows the established function signature. + +### Day 13-14: Food choreographies (~30 moves) +`choreography/food.ts`: +- `pizzaThrow`, `bananaSlip`, `pieFace`, `hotDogWhip`, `sushiRoll` +- `watermelonSmash`, `popcornBarrage`, `burritoWrap`, `cakeExplosion`, `iceCreamFreeze` +- `tacoSlam`, `donutSpin`, `breadSlap`, `eggCrack`, `soupSplash` +- `cheeseWheel`, `grapeShot`, `pineappleGrenade`, `cornCobGatling`, `steakSlap` +- Plus ~10 more food-themed variations + +### Day 15-16: Spins/flips (~25) + vehicles (~15) +`choreography/spins.ts`: +- `spin720`, `backflipKick`, `tornadoSpin`, `corkscrewDive`, `cartwheel` +- `helicopterSpin`, `barrelRoll`, `frontflipSlam`, `spinningPiledriver` +- `doubleBackflip`, `wallflip`, `moonwalk`, `breakdanceSpin`, `drillKick` +- Plus more rotation/flip-based moves + +`choreography/vehicles.ts` expansion: +- `skateboard`, `helicopter`, `shoppingCart`, `zamboni`, `rocketSled` +- `segway`, `unicycle`, `bulldozer`, `rollercoaster`, `cannonball` +- Plus existing (motorbikeCharge, carSmash, boatCannon) + +### Day 17-18: Animal (~20) + energy (~20) + silly (~25) +`choreography/animal.ts`: +- `sharkBite`, `beeSwarm`, `frogTongue`, `stampede`, `spiderWeb` +- `snakeStrike`, `bearHug`, `eagleDive`, `ramCharge`, `crocodileRoll` + +`choreography/energy.ts`: +- `kamehameha`, `hadouken`, `spiritBomb`, `thunderStrike`, `dragonPunch` +- `novaBlast`, `blackHole`, `sonicBoom`, `plasmaWhip`, `quantumTunnel` + +`choreography/silly.ts`: +- `pillowFight`, `tickleAttack`, `selfieStun`, `vibeCheck`, `dabOnEm` +- `yeetThrow`, `airHorn`, `rubberChicken`, `bananaPeelChain`, `confettiCannon` +- `invisibleWall`, `slipNSlide`, `trampolineBounce`, `bubbleWrap`, `kazooBlast` + +### Day 19: Wire all 300 choreographies +- Register all in `choreographyMap` +- Update `CHALLENGE_THEMED` with new move pools per challenge type +- Balance `pickChoreography` weights for maximum variety +- Add new moves to `critMoves` and `wild` arrays + +**Test:** Run 50 fight replays, verify no crashes, observe variety. + +--- + +## Phase 4: Challenges (Days 20-22) + +Scale from ~45 prompts to ~10,000 challenges. Hilarious, modern, pop culture, stupid/silly. + +### Day 20: Challenge bank architecture +``` +server/src/engine/challenges/ + banks/ + speed-blitz.ts -- 1,200+ prompts + riddle.ts -- 500+ riddles + code-golf.ts -- 400+ challenges + roast-battle.ts -- 600+ roast prompts + hallucination.ts -- 800+ true/false + token-economy.ts -- 500+ explain prompts + creative-writing.ts -- 400+ creative prompts + math-blitz.ts -- 1,500+ math problems (many templated) + trap-card.ts -- 600+ prompt injections + meme-knowledge.ts -- 500+ meme identification (NEW TYPE) + emoji-translate.ts -- 400+ emoji challenges (NEW TYPE) + debate.ts -- 300+ debate topics (NEW TYPE) + fill-the-blank.ts -- 400+ completions (NEW TYPE) + reverse-engineer.ts -- 300+ output puzzles (NEW TYPE) + survival.ts -- 500+ escalating difficulty (NEW TYPE) +``` + +### Day 21: Build prompt banks (~10,000 total) +- Mix of hand-written and procedural templates +- Pop culture: "Is it true Elon Musk once fought a kangaroo?", "What would happen if the Minecraft creeper attended a job interview?" +- Modern/silly: "Explain blockchain to a medieval peasant", "Write a breakup text from a printer to its owner" +- Procedural templates for math/trivia: fill in numbers, countries, units + +### Day 22: New challenge types + mock answers +- 6 new types: meme_knowledge, emoji_translate, debate, fill_the_blank, reverse_engineer, survival +- Scoring for each, mock answers for all 100 bots +- Wire into frontend `CHALLENGE_THEMED` + +--- + +## Phase 5: Audio (Days 23-25) + +### Day 23: 16 new music tracks (20 total) +Each track ~40 lines. Genre variety: +- 2x Synthwave (180-200 BPM), 2x Metal (250-280 BPM) +- 2x Chiptune (200-220 BPM), 2x Jazz Fusion (160-180 BPM) +- 2x Drum & Bass (170-175 BPM), 2x Lo-fi (140-160 BPM) +- 2x Ska Punk (210-230 BPM), 2x Boss Fight (280-300 BPM) + +Intensity-based genre grouping: +- Low: lo-fi, jazz, chiptune | Medium: synthwave, ska punk +- High: metal, drum & bass | Max: boss fight + +### Day 24: 24 new voice profiles (30 total) +New: whisper, manic, gravelly, chipper, deadpan, sports_caster, old_timey, drill_sergeant, surfer_dude, valley_girl, pirate_voice, robot_glitch, auctioneer, news_anchor, horror_narrator, lullaby, rage_quit, stoner, brit_posh, aussie, game_show, wrestling_announcer, noir_detective, anime_narrator + +Expand voice line libraries to 300+ unique lines. + +### Day 25: New SFX + arena-specific ambience +- 20 new SFX for new choreography types +- Arena-specific ambient background layers (beach waves, space hum, forest chirps) + +--- + +## Phase 6: Scenes & Polish (Days 26-27) + +### Day 26: 15+ new arenas (30+ total) +volcano, underwater, rooftop, train, kitchen, arcade, library, parking_lot, school, hospital, factory, castle, moon_base, ice_rink, stadium + +New modifiers: fire_boost, ice_slow, gravity_low, double_damage, chaos_mode + +### Day 27: Scene transitions + animated elements +- 4 transition styles: wipe, circle close/open, pixel dissolve, glitch cut +- Animated arena backgrounds: floating particles, dynamic lighting +- Enhanced spectator crowd reactions tied to round outcomes + +--- + +## Phase 7: Production Loop (Days 28-30) + +### Day 28: Fight scheduler + matchmaking +`server/src/engine/scheduler.ts`: +- `startScheduler(intervalMs)` -- Elo-based matchmaking loop +- Routes: `POST /api/scheduler/start|stop`, `GET /api/scheduler/status` +- Queue with rate limiting + bot cooldown + +### Day 29: Headless mode + overnight operation +- `pnpm run fight-loop` for headless scheduler +- Env vars: `FIGHT_INTERVAL_MS`, `FIGHTS_PER_BATCH`, `MAX_CONCURRENT` +- Structured JSON logging, graceful SIGTERM shutdown +- `GET /api/stats` endpoint, health monitoring + +### Day 30: Final seed data + integration test +- Generate: 100 bots, 500+ fight history +- Verify: Elo bell curve, tier distribution +- Full E2E: seed -> dev -> replay 10 fights -> fight-loop -> 100 fights -> typecheck + lint + +--- + +## Dependency Graph + +``` +Phase 1 (Split) --- BLOCKS EVERYTHING + | + +-- Phase 2 (Characters) -- independent after Phase 1 + +-- Phase 3 (Choreographies) -- independent after Phase 1 + +-- Phase 4 (Challenges) -- independent after Phase 1 + +-- Phase 5 (Audio) -- independent after Phase 1 + | + +-- Phase 6 (Scenes) -- needs Phase 1 + Phase 3 + +-- Phase 7 (Production) -- needs Phase 2 + Phase 4 +``` + +## Testing Strategy + +After every day: +1. `pnpm typecheck` -- catches import/export errors +2. Sprite preview page -- verify all archetypes render +3. Watch 5 random fight replays -- no crashes, visual variety +4. Audio check -- music, voices, SFX all play +5. `pnpm seed` -- verify DB has expected data + +## Critical Files + +- `frontend/src/game/sprites.ts` -> split into `sprites/` directory +- `frontend/src/game/FightScene.ts` -> split into `fight/` directory +- `frontend/src/game/sounds.ts` -> split into `sounds/` directory +- `frontend/src/components/FightViewer.vue` -> update imports +- `server/src/engine/challenges.ts` -> split into `challenges/` directory +- `server/src/engine/mock.ts` -> split into `mock/` + expand to 100 bots +- `server/src/engine/arenas.ts` -> expand to 30+ arenas +- `server/src/seed.ts` -> update imports +- `server/src/routes/fights.ts` -> add scheduler routes diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d7d8d16 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh" + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" + } + ] + } + ], + "PostToolUse": [] + } +} diff --git a/.claude/skills/add-app/SKILL.md b/.claude/skills/add-app/SKILL.md new file mode 100644 index 0000000..b47b64f --- /dev/null +++ b/.claude/skills/add-app/SKILL.md @@ -0,0 +1,49 @@ +--- +name: add-app +description: Step-by-step guide for adding a new containerized app to Archipelago +disable-model-invocation: true +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +argument-hint: "[app-name]" +--- + +Add a new containerized app ($ARGUMENTS) to Archipelago. + +## Steps + +### 1. Create the manifest + +Create `apps/{app-id}/manifest.yml` following the spec in `docs/app-manifest-spec.md`: +- `app.id` (kebab-case), `app.name`, `app.version` (SemVer) +- `container.image` (pinned version, **NEVER** `latest`) +- `security`: readonly_root, dropped capabilities, non-root UID > 1000 +- `health_check`, `dependencies` + +### 2. Add app icon + +Place icon at `neode-ui/public/assets/img/app-icons/{app-id}.{png|webp|svg}` + +### 3. Create status UI (if no native web UI) + +For apps without their own web interface, create a UI container in `docker/{app-id}-ui/` following the patterns in `.cursor/rules/APP-UI-STANDARDS.md`. + +Reference implementations: +- Bitcoin UI: `docker/bitcoin-ui/` +- LND UI: `docker/lnd-ui/` + +### 4. Update backend + +- Add port mapping in `core/archipelago/src/container/docker_packages.rs` +- Add env vars in `get_app_config()` in `core/archipelago/src/api/rpc.rs` + +### 5. Deploy and test + +- Deploy: `./scripts/deploy-to-target.sh --live` +- Install from marketplace UI at http://192.168.1.228 +- Verify it launches and auto-connects to dependencies +- Check logs: `sudo podman logs {container-name}` + +### 6. Security review + +- Verify readonly root, dropped caps, non-root user +- Check network isolation +- No hardcoded secrets diff --git a/.claude/skills/encrypted-records/encrypted-records.md b/.claude/skills/encrypted-records/encrypted-records.md new file mode 100644 index 0000000..da7be47 --- /dev/null +++ b/.claude/skills/encrypted-records/encrypted-records.md @@ -0,0 +1,147 @@ +--- +description: E2E encrypted records and group encryption for the Syntropy Institute portal +match: + - encrypt + - decrypt + - record + - NIP-44 + - group key + - channel + - community +--- + +# Encrypted Records Skill + +## When to Use +When working with E2E encrypted client records, community posts, or group key management. + +## NIP-44 Encryption (Practitioner <-> Client) +```typescript +import { nip44 } from 'nostr-tools'; + +// Encrypt record for a specific client +function encryptRecord( + practitionerSecretKey: Uint8Array, + clientPubKey: string, + recordData: object +): string { + const plaintext = JSON.stringify(recordData); + const conversationKey = nip44.v2.utils.getConversationKey(practitionerSecretKey, clientPubKey); + return nip44.v2.encrypt(plaintext, conversationKey); +} + +// Decrypt record (client side) +function decryptRecord( + clientSecretKey: Uint8Array, + practitionerPubKey: string, + encryptedData: string +): object { + const conversationKey = nip44.v2.utils.getConversationKey(clientSecretKey, practitionerPubKey); + const plaintext = nip44.v2.decrypt(encryptedData, conversationKey); + return JSON.parse(plaintext); +} +``` + +## Group Encryption (Community Channels) + +### Creating a Channel with Group Key +```typescript +// Generate a symmetric group key for the channel +function generateGroupKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} + +// Encrypt group key for a specific member using NIP-44 +function wrapGroupKeyForMember( + adminSecretKey: Uint8Array, + memberPubKey: string, + groupKey: Uint8Array +): string { + const conversationKey = nip44.v2.utils.getConversationKey(adminSecretKey, memberPubKey); + return nip44.v2.encrypt( + btoa(String.fromCharCode(...groupKey)), + conversationKey + ); +} + +// Member unwraps their group key +function unwrapGroupKey( + memberSecretKey: Uint8Array, + adminPubKey: string, + wrappedKey: string +): Uint8Array { + const conversationKey = nip44.v2.utils.getConversationKey(memberSecretKey, adminPubKey); + const decoded = nip44.v2.decrypt(wrappedKey, conversationKey); + return Uint8Array.from(atob(decoded), c => c.charCodeAt(0)); +} +``` + +### Encrypting Community Posts with Group Key +```typescript +// Encrypt post content with the channel's symmetric group key +async function encryptPostWithGroupKey( + groupKey: Uint8Array, + content: string +): Promise { + const key = await crypto.subtle.importKey( + 'raw', groupKey, 'AES-GCM', false, ['encrypt'] + ); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(content); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, key, encoded + ); + const combined = new Uint8Array([...iv, ...new Uint8Array(ciphertext)]); + return btoa(String.fromCharCode(...combined)); +} + +// Decrypt post content +async function decryptPostWithGroupKey( + groupKey: Uint8Array, + encrypted: string +): Promise { + const combined = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0)); + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + const key = await crypto.subtle.importKey( + 'raw', groupKey, 'AES-GCM', false, ['decrypt'] + ); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, key, ciphertext + ); + return new TextDecoder().decode(decrypted); +} +``` + +### Key Rotation (when member removed) +```typescript +// When a member is removed from a channel: +// 1. Generate new group key +// 2. Re-wrap for all remaining members +// 3. Update ChannelMember.encryptedGroupKey for each +// 4. New posts use new key; old posts remain readable with old key +// (store key version/epoch on each post) +``` + +## Record Types +```typescript +interface ClientRecord { + type: 'session_notes' | 'assessment' | 'treatment_plan'; + title: string; + content: string; // Rich text or structured data + attachments?: { + name: string; + mimeType: string; + encryptedData: string; // Each file encrypted separately + }[]; + createdAt: string; // ISO timestamp +} +``` + +## Rules +- ALL encryption happens client-side — server stores only encrypted blobs +- Use NIP-44 v2 (NOT NIP-04 — it's deprecated and has known weaknesses) +- Group keys: AES-256-GCM with random IVs +- Include key version/epoch on group-encrypted content for rotation support +- Never log or expose plaintext content on the server +- Media files are encrypted individually before upload diff --git a/.claude/skills/harden/SKILL.md b/.claude/skills/harden/SKILL.md new file mode 100644 index 0000000..6736cec --- /dev/null +++ b/.claude/skills/harden/SKILL.md @@ -0,0 +1,49 @@ +--- +name: harden +description: Security hardening review and fixes for Archipelago code and infrastructure +disable-model-invocation: true +allowed-tools: Read, Edit, Write, Glob, Grep, Bash +argument-hint: "[area: backend|frontend|containers|scripts|all]" +--- + +Perform a security hardening pass on $ARGUMENTS (default: all). + +## Backend Hardening (Rust) + +- [ ] No hardcoded credentials — check for Base64-encoded auth strings, passwords in source +- [ ] Secrets use `core/security/secrets_manager.rs` — verify encryption is implemented (not plaintext) +- [ ] All RPC endpoints validate inputs before processing +- [ ] No `unwrap()` on user-supplied data — handle errors gracefully +- [ ] Rate limiting on auth endpoints (login, password change) +- [ ] Session tokens have proper expiry and rotation +- [ ] File permissions: keys at 0o600, dirs at 0o700 +- [ ] Tracing never logs secrets, passwords, keys, or tokens + +## Frontend Hardening (Vue/TypeScript) + +- [ ] No secrets in source (API keys, passwords, tokens) +- [ ] No `eval()` or `innerHTML` with untrusted content +- [ ] XSS prevention — sanitize all user inputs +- [ ] CSRF protection on state-changing requests +- [ ] Credentials use `credentials: 'include'` not localStorage tokens +- [ ] No sensitive data in console.log statements + +## Container Hardening + +- [ ] All manifests: `readonly_root: true` (unless documented exception) +- [ ] All manifests: capabilities dropped, only required ones added +- [ ] All manifests: non-root user (UID > 1000) +- [ ] All manifests: `no-new-privileges: true` +- [ ] All images pinned to specific versions (no `:latest`) +- [ ] Network isolation — no `host` network unless required and documented +- [ ] AppArmor profiles defined and enforced + +## Script Hardening + +- [ ] All scripts use `set -euo pipefail` +- [ ] No hardcoded passwords (use deploy-config.sh or env vars) +- [ ] SSH uses proper key-based auth where possible +- [ ] No `chmod 777` or overly permissive permissions +- [ ] Temp files use `mktemp` not predictable paths + +Report all findings with file paths and line numbers. Fix issues directly where safe to do so. Flag anything that needs discussion. diff --git a/.claude/skills/lint/SKILL.md b/.claude/skills/lint/SKILL.md new file mode 100644 index 0000000..d684f64 --- /dev/null +++ b/.claude/skills/lint/SKILL.md @@ -0,0 +1,52 @@ +--- +name: lint +description: Run all linters and type checks for the Archipelago project +allowed-tools: Bash, Read, Grep +argument-hint: "[backend|frontend|all]" +--- + +Run linters and type-checks for $ARGUMENTS (default: all). + +## Frontend Linting + +```bash +cd neode-ui + +# Type check +npm run type-check 2>&1 + +# Check for any `any` types (should be zero) +grep -rn ': any' src/ --include='*.ts' --include='*.vue' | grep -v node_modules | grep -v '.d.ts' + +# Check for inline Tailwind violations (long class strings) +grep -rn 'class="[^"]\{100,\}"' src/ --include='*.vue' + +# Check for TODO/FIXME +grep -rn 'TODO\|FIXME' src/ --include='*.ts' --include='*.vue' + +# Check for console.log (should be cleaned before production) +grep -rn 'console\.\(log\|warn\|error\)' src/ --include='*.ts' --include='*.vue' | wc -l +``` + +## Backend Linting (on dev server) + +```bash +sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 \ + 'source ~/.cargo/env && cd ~/archy/core && cargo clippy --all-targets --all-features 2>&1 && cargo fmt --all -- --check 2>&1' +``` + +## Script Linting + +```bash +# Check for scripts missing set -e +for f in scripts/*.sh; do + if ! head -5 "$f" | grep -q 'set -e'; then + echo "MISSING set -e: $f" + fi +done + +# Check for hardcoded IPs (should use variables) +grep -rn '192\.168\.1\.' scripts/ --include='*.sh' | grep -v deploy-config +``` + +Report all issues found with severity (critical/warning/info). diff --git a/.claude/skills/nostr-auth/nostr-auth.md b/.claude/skills/nostr-auth/nostr-auth.md new file mode 100644 index 0000000..b0d2b7d --- /dev/null +++ b/.claude/skills/nostr-auth/nostr-auth.md @@ -0,0 +1,116 @@ +--- +description: Nostr keypair authentication for the Syntropy Institute portal (auth only, no relays) +match: + - nostr + - auth + - login + - keypair + - sign + - challenge +--- + +# Nostr Auth Skill + +## When to Use +When working with authentication flows, keypair management, or NIP-98 API auth in the portal. + +## Key Principle +Nostr is used ONLY for cryptographic authentication. No relay connections. No event publishing. Just keypairs and signatures. + +## Keypair Generation (Easy Mode) +```typescript +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; + +// Generate new identity +const secretKey = generateSecretKey(); // Uint8Array +const publicKey = getPublicKey(secretKey); // hex string + +// Encode for display (only when user requests it) +const npub = nip19.npubEncode(publicKey); +const nsec = nip19.nsecEncode(secretKey); +``` + +## Private Key Encryption (for localStorage) +```typescript +// Encrypt private key with user's passphrase before storing +async function encryptPrivateKey(secretKey: Uint8Array, passphrase: string): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', encoder.encode(passphrase), 'PBKDF2', false, ['deriveKey'] + ); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const derivedKey = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' }, + keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt'] + ); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, derivedKey, secretKey + ); + // Return salt + iv + ciphertext as base64 + const combined = new Uint8Array([...salt, ...iv, ...new Uint8Array(encrypted)]); + return btoa(String.fromCharCode(...combined)); +} +``` + +## NIP-07 Detection (Nostr Native) +```typescript +// Check for browser extension +function hasNostrExtension(): boolean { + return typeof window !== 'undefined' && 'nostr' in window; +} + +// Sign with extension +async function signWithExtension(event: object): Promise { + return await (window as any).nostr.signEvent(event); +} + +// Get public key from extension +async function getExtensionPubkey(): Promise { + return await (window as any).nostr.getPublicKey(); +} +``` + +## Challenge-Response Auth (NIP-98 style) +```typescript +import { finalizeEvent, verifyEvent } from 'nostr-tools'; + +// Client: Sign auth challenge +function createAuthEvent(secretKey: Uint8Array, url: string, method: string) { + const event = finalizeEvent({ + kind: 27235, // NIP-98 HTTP Auth + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', url], + ['method', method], + ], + content: '', + }, secretKey); + return event; +} + +// Send as Authorization header: +// Authorization: Nostr + +// Server: Verify auth event +function verifyAuthEvent(event: any, expectedUrl: string, expectedMethod: string): boolean { + if (!verifyEvent(event)) return false; + if (event.kind !== 27235) return false; + const urlTag = event.tags.find((t: string[]) => t[0] === 'u'); + const methodTag = event.tags.find((t: string[]) => t[0] === 'method'); + if (urlTag?.[1] !== expectedUrl) return false; + if (methodTag?.[1] !== expectedMethod) return false; + // Check timestamp is within 60 seconds + if (Math.abs(Date.now() / 1000 - event.created_at) > 60) return false; + return true; +} +``` + +## Rules +- NEVER store private keys on the server +- NEVER connect to any Nostr relay +- NEVER transmit private keys over the network +- ALWAYS encrypt private keys before storing in localStorage +- Use PBKDF2 with at least 600,000 iterations for key derivation +- Auth events expire after 60 seconds +- Server only stores npub (public key) diff --git a/.claude/skills/overnight/SKILL.md b/.claude/skills/overnight/SKILL.md new file mode 100644 index 0000000..128e938 --- /dev/null +++ b/.claude/skills/overnight/SKILL.md @@ -0,0 +1,19 @@ +--- +name: overnight +description: Commit, branch, and start the overnight automation loop +disable-model-invocation: true +allowed-tools: Bash(*), Read, Write, Edit, Glob, Grep +--- + +Prepare and launch the overnight automation loop. Do ALL steps in order, stopping on any failure: + +1. Stage and commit all uncommitted changes: `git add -A && git commit -m "chore: pre-overnight snapshot"` (skip if working tree is clean) +2. Push current branch to origin +3. Get today's date as YYYY-MM-DD. Check if `overnight/$DATE` branch exists: + - If yes: `git checkout overnight/$DATE` + - If no: run `./loop/prepare.sh` +4. Verify `loop/plan.md` has unchecked tasks (`grep -c '^\- \[ \]' loop/plan.md`) +5. Commit plan files if modified: `git add loop/plan.md loop/prompt.md && git commit -m "chore: overnight plan $DATE"` (skip if clean) +6. Push: `git push -u origin overnight/$DATE` +7. Start the loop: run `caffeinate -i ./loop/loop.sh` with `run_in_background: true` +8. Report: branch name, number of tasks, and confirm the loop is running in background diff --git a/.claude/skills/pwa-icon-cache-fix/SKILL.md b/.claude/skills/pwa-icon-cache-fix/SKILL.md new file mode 100644 index 0000000..dbc65f8 --- /dev/null +++ b/.claude/skills/pwa-icon-cache-fix/SKILL.md @@ -0,0 +1,102 @@ +--- +name: pwa-icon-cache-fix +description: Use when the user reports a PWA icon not updating, stale PWA icon, wrong icon after install, or any PWA caching issue. Also applies when changing PWA icons in a Vite + vite-plugin-pwa project. +version: 2.0.0 +--- + +# PWA Icon Cache Fix + +## Problem + +PWA icons are cached at FOUR independent layers: +1. **Service worker cache** (Workbox precache) +2. **Browser HTTP cache** +3. **Browser manifest resources** (Chromium stores resized icons in its profile data, keyed by a permanent extension ID tied to the origin — NEVER re-fetched even after uninstall/reinstall) +4. **macOS .app bundle** (`.icns` file baked into the `.app` in `~/Applications/`) + +Query string cache busting (`?v=2`) and uninstall/reinstall do NOT fix this. Chromium reuses the same extension ID for the same origin, so it keeps the old cached icons. + +## Fix Steps + +### 1. Verify icon files on disk and server are correct + +```bash +# Visual check +Read packages/app/public/pwa-192x192.png +Read packages/app/public/pwa-512x512.png + +# Hash match check +curl -s http://localhost:5173/pwa-192x192.png | md5 +md5 -q packages/app/public/pwa-192x192.png +``` + +### 2. Find the PWA's Chromium extension ID + +Read the installed `.app` bundle's `Info.plist` to get the `CrAppModeShortcutID`: + +```bash +plutil -p "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Info.plist" | grep CrAppModeShortcutID +``` + +This returns an ID like `idemibpphagihbobmgmaojhjfidlfpdl`. + +### 3. Overwrite the cached icons in browser profile + +Chromium stores resized icons at: +`~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons/` + +Overwrite every size using `sips`: + +```bash +ICON_DIR="~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons" +SRC="packages/app/public/pwa-512x512.png" +for size in 32 48 64 96 128 192 256 512; do + sips -z $size $size "$SRC" --out "${ICON_DIR}/${size}.png" +done +``` + +### 4. Rebuild the macOS .icns in the .app bundle + +```bash +ICONSET="/tmp/aiui.iconset" +mkdir -p "$ICONSET" +SRC="packages/app/public/pwa-512x512.png" +sips -z 16 16 "$SRC" --out "$ICONSET/icon_16x16.png" +sips -z 32 32 "$SRC" --out "$ICONSET/icon_16x16@2x.png" +sips -z 32 32 "$SRC" --out "$ICONSET/icon_32x32.png" +sips -z 64 64 "$SRC" --out "$ICONSET/icon_32x32@2x.png" +sips -z 128 128 "$SRC" --out "$ICONSET/icon_128x128.png" +sips -z 256 256 "$SRC" --out "$ICONSET/icon_128x128@2x.png" +sips -z 256 256 "$SRC" --out "$ICONSET/icon_256x256.png" +sips -z 512 512 "$SRC" --out "$ICONSET/icon_256x256@2x.png" +sips -z 512 512 "$SRC" --out "$ICONSET/icon_512x512.png" +cp "$SRC" "$ICONSET/icon_512x512@2x.png" +iconutil -c icns "$ICONSET" -o "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Resources/app.icns" +``` + +### 5. Flush macOS icon cache + +```bash +touch "~/Applications/Brave Browser Apps.localized/AIUI.app" +killall Finder +killall Dock +``` + +### 6. Bump PWA_CACHE_VERSION in main.ts + +Increment the `PWA_CACHE_VERSION` constant — this nukes all SW caches on next page load for web-layer caching. + +### 7. Delete stale build artifacts + +Remove old `dist/` and `dev-dist/` SW/manifest files. + +## Browser-Specific Paths + +- **Brave**: `~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/` +- **Chrome**: `~/Library/Application Support/Google/Chrome/Default/Web Applications/` +- **PWA apps (Brave)**: `~/Applications/Brave Browser Apps.localized/` +- **PWA apps (Chrome)**: `~/Applications/Chrome Apps.localized/` + +## Key Insight + +Chromium assigns a permanent extension ID per origin (e.g., `localhost:5173`). This ID persists across uninstall/reinstall. The icon cache in `Manifest Resources/{ID}/Icons/` is populated ONCE and never refreshed from the manifest. The only fix is to overwrite the files directly on disk. diff --git a/.claude/skills/refactor/SKILL.md b/.claude/skills/refactor/SKILL.md new file mode 100644 index 0000000..8c0e036 --- /dev/null +++ b/.claude/skills/refactor/SKILL.md @@ -0,0 +1,41 @@ +--- +name: refactor +description: Refactor code for quality, maintainability, and adherence to project standards +disable-model-invocation: true +allowed-tools: Read, Edit, Write, Glob, Grep, Bash +argument-hint: "[file-or-area]" +--- + +Refactor the specified code ($ARGUMENTS) following Archipelago coding standards. + +## Checklist + +### Rust Backend +- [ ] No `unwrap()` or `expect()` — use `?` operator with context +- [ ] Replace `#[allow(dead_code)]` — either use it or remove it +- [ ] Functions under 50 lines, single responsibility +- [ ] Custom error types per module with `thiserror` +- [ ] `tracing` for logging — no `println!` or secrets in logs +- [ ] Split files over 500 lines into focused modules +- [ ] Run `cargo clippy --all-targets --all-features` mentally and fix issues + +### Vue Frontend +- [ ] Extract ALL inline Tailwind to global classes in `neode-ui/src/style.css` +- [ ] Use semantic class names: `.glass-card`, `.info-card`, `.glass-button`, `.path-option-card` +- [ ] Replace ALL `.gradient-button` with `.glass-button` (gradient buttons are BANNED) +- [ ] Replace ALL `.gradient-card` / `.gradient-card-dark` with `.glass-card` or `.path-option-card` +- [ ] Settings.vue is the gold standard — all screens should match its patterns +- [ ] Replace `any` types with proper interfaces or `unknown` +- [ ] Ensure ` + + diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue index c71aa9a..c325c08 100644 --- a/frontend/src/components/NavBar.vue +++ b/frontend/src/components/NavBar.vue @@ -1,21 +1,27 @@ diff --git a/frontend/src/components/PixelGlove.vue b/frontend/src/components/PixelGlove.vue new file mode 100644 index 0000000..6cbce5a --- /dev/null +++ b/frontend/src/components/PixelGlove.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/SpritePreview.vue b/frontend/src/components/SpritePreview.vue new file mode 100644 index 0000000..5ab26ee --- /dev/null +++ b/frontend/src/components/SpritePreview.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts new file mode 100644 index 0000000..761557a --- /dev/null +++ b/frontend/src/composables/useNostr.ts @@ -0,0 +1,184 @@ +import { ref, readonly, computed } from 'vue' + +interface BotData { + id: string + name: string + avatarSeed: string + archetype: string + profilePicUrl: string | null + eloRating: number + wins: number + losses: number + winStreak: number + bestStreak: number + tier: number +} + +interface NostrWindow { + getPublicKey(): Promise + signEvent(event: Record): Promise> + getRelays?(): Promise> +} + +declare global { + interface Window { + nostr?: NostrWindow + } +} + +const pubkey = ref(null) +const bot = ref(null) +const profilePicUrl = ref(null) +const isLoading = ref(false) + +export function useNostr() { + const isLoggedIn = computed(() => !!pubkey.value && !!bot.value) + const hasExtension = computed(() => !!window.nostr) + + async function login(): Promise<{ pubkey: string; bot: BotData | null }> { + if (!window.nostr) { + throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.') + } + + isLoading.value = true + try { + const pk = await window.nostr.getPublicKey() + pubkey.value = pk + + // Fetch Nostr profile pic from relay + const pic = await fetchNostrProfilePic(pk) + if (pic) profilePicUrl.value = pic + + // Check if this pubkey has a bot + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pk }), + }) + + if (res.ok) { + const data = await res.json() + if (data.exists) { + bot.value = data.bot + return { pubkey: pk, bot: data.bot } + } + } + + return { pubkey: pk, bot: null } + } finally { + isLoading.value = false + } + } + + async function registerBot(name: string, webhookUrl: string, archetype: string): Promise { + if (!pubkey.value) throw new Error('Not logged in') + + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pubkey: pubkey.value, + name, + webhookUrl, + archetype, + profilePicUrl: profilePicUrl.value, + }), + }) + + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Registration failed') + + bot.value = { + id: data.id, + name: data.name, + avatarSeed: data.name, + archetype: data.archetype, + profilePicUrl: profilePicUrl.value, + eloRating: 1200, + wins: 0, + losses: 0, + winStreak: 0, + bestStreak: 0, + tier: 0, + } + + return bot.value + } + + function logout() { + pubkey.value = null + bot.value = null + profilePicUrl.value = null + } + + return { + pubkey: readonly(pubkey), + bot: readonly(bot), + profilePicUrl: readonly(profilePicUrl), + isLoggedIn, + isLoading: readonly(isLoading), + hasExtension, + login, + registerBot, + logout, + } +} + +// Fetch profile picture from a Nostr relay +async function fetchNostrProfilePic(pk: string): Promise { + const relays = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol', + ] + + for (const relay of relays) { + try { + const pic = await queryRelay(relay, pk) + if (pic) return pic + } catch { + continue + } + } + return null +} + +function queryRelay(url: string, pk: string): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + ws.close() + resolve(null) + }, 3000) + + const ws = new WebSocket(url) + const subId = Math.random().toString(36).slice(2, 10) + + ws.onopen = () => { + // Request kind 0 (metadata) for this pubkey + ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }])) + } + + ws.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data) + if (data[0] === 'EVENT' && data[2]?.kind === 0) { + const meta = JSON.parse(data[2].content) + clearTimeout(timeout) + ws.close() + resolve(meta.picture || null) + } else if (data[0] === 'EOSE') { + clearTimeout(timeout) + ws.close() + resolve(null) + } + } catch { + // ignore parse errors + } + } + + ws.onerror = () => { + clearTimeout(timeout) + resolve(null) + } + }) +} diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 23d15e5..acffa86 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -1,10 +1,18 @@ import kaplay from 'kaplay' -import { generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS } from './sprites' +import { generateSpriteSheet, generateJudgeSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './sprites' +import { + sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit, + sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect, + sfxRoundStart, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly, sfxBoing, + sfxBonk, sfxZap, sfxSlideDown, sfxZoomWhoosh, sfxRapidPunch, + fanfareRound, fanfareFight, fanfareDevastating, fanfareCritical, fanfareCombo, + startMusic, stopMusic, announce, +} from './sounds' export interface FightSceneConfig { canvas: HTMLCanvasElement - botA: { name: string; seed: string; tier: number } - botB: { name: string; seed: string; tier: number } + botA: { name: string; seed: string; tier: number; archetype?: string } + botB: { name: string; seed: string; tier: number; archetype?: string } arena: string onReady?: () => void } @@ -32,6 +40,11 @@ const ARENA_THEMES: Record = { - speed_blitz: ['attack', 'kick'], - riddle: ['attack', 'special'], - code_golf: ['special', 'attack'], - roast_battle: ['special', 'kick'], - hallucination_check: ['attack'], - token_economy: ['kick', 'attack'], - creative_writing: ['special'], - math_blitz: ['attack', 'kick'], - trap_card: ['special', 'kick'], - } - const options = map[challengeType] || ['attack', 'kick'] - return options[Math.floor(Math.random() * options.length)] +// Challenge type -> themed choreography (shown ~60% of the time) + generic fallbacks +const CHALLENGE_THEMED: Record = { + speed_blitz: { themed: ['afterimageDash', 'lightningRush', 'rapidFlurry', 'tornadoSpin', 'corkscrewDive', 'warpDrive', 'backflipKick'], generic: ['dashPunch', 'multiHit', 'fullScreenDash', 'dashThrough', 'zoomRush', 'katanaCombo', 'cycloneKick', 'helicopterArms', 'breakdanceSweep'] }, + riddle: { themed: ['riddleBarrage', 'portalPunch', 'cloneStrike', 'mindBlast', 'hexCurse'], generic: ['teleportStrike', 'projectile', 'zoomRush', 'whipCrack', 'bombThrow', 'captchaTrap', 'glitchBeam'] }, + code_golf: { themed: ['golfClubSmash', 'golfCartDrive', 'stackOverflow', 'segfault'], generic: ['projectile', 'flyingKick', 'dashPunch', 'rapidFlurry', 'hammerSmash', 'bodySlam', 'homeRunSwing'] }, + roast_battle: { themed: ['fireBreath', 'dragonBreath', 'fireball', 'lavaSplash', 'heatVision', 'volcanoErupt'], generic: ['gunBurst', 'jetpackDive', 'multiHit', 'zoomRush', 'laserBeam', 'chainsawRev', 'rocketLauncher', 'dynamiteBlast'] }, + hallucination_check: { themed: ['cloneStrike', 'portalPunch', 'shadowClone', 'glitchBeam', 'voidRift'], generic: ['teleportStrike', 'dashThrough', 'riddleBarrage', 'rapidFlurry', 'sniperShot', 'darkVoid', 'memeBeam'] }, + token_economy: { themed: ['coinShower', 'bitcoinCrash', 'nftRugPull', 'vendingDrop'], generic: ['dashPunch', 'flyingKick', 'uppercut', 'zoomRush', 'bombThrow', 'minigunSpray', 'pocketCannon'] }, + creative_writing: { themed: ['penStab', 'scrollBlast', 'bookStorm', 'enchantedArrow'], generic: ['jetpackDive', 'aerialSlam', 'projectile', 'rapidFlurry', 'katanaCombo', 'arcaneBarrage', 'memeBeam'] }, + math_blitz: { themed: ['mathAttack', 'dataStream', 'pixelBeam', 'hackerAttack'], generic: ['multiHit', 'dashPunch', 'fullScreenDash', 'rapidFlurry', 'laserBeam', 'hammerSmash', 'pinballCombo'] }, + trap_card: { themed: ['trapCardAttack', 'captchaTrap', 'spiderWeb', 'flashBang', 'smokeBomb'], generic: ['gunBurst', 'teleportStrike', 'groundPound', 'zoomRush', 'sniperShot', 'rocketLauncher', 'minigunSpray'] }, + food_fight: { themed: ['pizzaSlam', 'bananaFling', 'pieSmash', 'hotdogWhip', 'watermelonBomb', 'sushiBarrage', 'burgerToss', 'tacoStorm', 'eggBombard', 'tomatoBarrage'], generic: ['popcornBlast', 'iceCreamFling', 'donutBarrage', 'cookieFling', 'nachoVolley', 'meatballStorm', 'candyBarrage'] }, + wrestling_match: { themed: ['piledriver', 'powerbomb', 'chokeslam', 'tombstone', 'rko', 'clothesline', 'suplex', 'germanSuplex'], generic: ['bodySlam', 'grappleFlurry', 'hurricanrana', 'moonsault', 'elbowDrop', 'spear', 'stunner'] }, + music_battle: { themed: ['bassDrop', 'guitarSolo', 'drumSolo', 'dubstepCannon', 'beatboxBlast', 'vinylScratch'], generic: ['airHornBlast', 'vuvuzelaBlast', 'sonicWave', 'bassDropBeam', 'karaokeAttack', 'airGuitar'] }, + magic_duel: { themed: ['kamehameha', 'spiritBomb', 'arcaneBarrage', 'darkVoid', 'holySmite', 'shadowClone', 'hexCurse'], generic: ['fireball', 'iceLance', 'thunderStrike', 'portalPunch', 'crystalShards', 'scrollBlast', 'potionThrow'] }, + sports_showdown: { themed: ['homeRunSwing', 'slapShot', 'servingAce', 'fieldGoalKick', 'bodyCheck', 'soccerKick', 'basketballDunk'], generic: ['baseballBat', 'tennisRacket', 'hockeyStick', 'bowlingBallRoll', 'tennisBallVolley', 'dropKick'] }, + nature_clash: { themed: ['treeSmash', 'icebergDrop', 'lavaSplash', 'tornadoFling', 'tsunamiWave', 'avalanche', 'earthquakeStrike'], generic: ['vineWhip', 'sandstorm', 'thornBarrage', 'pollenCloud', 'windSlash', 'meteorStrike'] }, + space_war: { themed: ['blackHole', 'photonTorpedo', 'tractor_beam', 'warpDrive', 'alienAbduction', 'ionCannon', 'cosmicRay'], generic: ['laserSword', 'plasmaSword', 'asteroidBelt', 'satelliteLaser', 'plasmaBeam', 'solarFlare'] }, + hack_battle: { themed: ['hackerAttack', 'bugSwarm', 'stackOverflow', 'segfault', 'blueScreen', 'malwareInject'], generic: ['fourOhFour', 'ctrlAltDelete', 'aiUprising', 'captchaTrap', 'popupSpam', 'dataStream', 'glitchBeam'] }, + meme_war: { themed: ['selfieStrike', 'dabAttack', 'flossAttack', 'yeetThrow', 'tPoseAssert', 'emojiBarrage', 'memeBeam'], generic: ['rubberChicken', 'fingerGuns', 'micDrop', 'ratioAttack', 'capThrow', 'touchGrass', 'noScope'] }, + animal_kingdom: { themed: ['sharkBite', 'bearSwipe', 'eagleDive', 'bullCharge', 'gorillaSlam', 'wolfPack', 'batSwarm'], generic: ['snakeLunge', 'scorpionSting', 'crabPinch', 'spiderWeb', 'beeSwarm', 'catScratch', 'dogPile', 'dolphinFlip'] }, + demolition: { themed: ['dynamiteBlast', 'c4Detonation', 'nukeStrike', 'grenadeBlast', 'rocketLauncher', 'volcanoErupt'], generic: ['fireworksBurst', 'partyPopper', 'pinataSmash', 'cherryBomb', 'confettiCannon', 'anvilDrop', 'pianoDrop'] }, + vehicle_mayhem: { themed: ['motorbikeCharge', 'carSmash', 'tankRoll', 'helicopterStrike', 'zamboniCrush', 'tractorPlow'], generic: ['shoppingCart', 'forkliftCharge', 'airplaneSwoop', 'rocketRide', 'unicycleRun', 'golfCartDrive', 'boatCannon'] }, + medieval_combat: { themed: ['swordSlash', 'katanaCombo', 'battleAxe', 'maceSwing', 'crossbowBolt', 'shieldBash'], generic: ['flailSwing', 'holyWater', 'dragonBreath', 'enchantedArrow', 'laserSword', 'scrollBlast'] }, } -// Pick defender reaction -function pickDefenderAnim(isCritical: boolean): string { - return isCritical ? 'knockback' : 'hit' +function pickChoreography(challengeType: string, isCritical: boolean, _round: number): string { + // Critical hits always get BIG moves (but can still be themed) + if (isCritical) { + const entry = CHALLENGE_THEMED[challengeType] + // 50% themed, 50% epic generic for crits + if (entry && Math.random() < 0.5) { + return entry.themed[Math.floor(Math.random() * entry.themed.length)] + } + const critMoves = [ + 'groundPound', 'jetpackDive', 'gunBurst', 'fullScreenDash', 'multiHit', 'fireBreath', + 'lightningRush', 'zoomRush', 'rapidFlurry', 'rocketLauncher', 'laserBeam', 'katanaCombo', + 'hammerSmash', 'sniperShot', 'minigunSpray', 'chainsawRev', 'carSmash', 'motorbikeCharge', + 'anvilDrop', 'pocketCannon', 'bodySlam', 'suplex', 'pinballCombo', 'grappleFlurry', + // New epic crits + 'kamehameha', 'nukeStrike', 'tankRoll', 'helicopterStrike', 'chokeslam', 'tombstone', + 'rko', 'piledriver', 'powerbomb', 'meteorStrike', 'spiritBomb', 'blackHole', + 'alienAbduction', 'tsunamiWave', 'earthquakeStrike', 'dragonBreath', 'pianoDrop', + 'fridgeDrop', 'volcanoErupt', 'warpDrive', 'dubstepCannon', 'tornadoSpin', + 'trampolineBounce', 'watermelonBomb', 'nftRugPull', 'blueScreen', 'yeetThrow', + ] + return critMoves[Math.floor(Math.random() * critMoves.length)] + } + + const entry = CHALLENGE_THEMED[challengeType] + if (!entry) { + const fallback = ['dashPunch', 'flyingKick', 'aerialSlam', 'backflipKick', 'fingerGuns', 'rubberChicken', 'pizzaSlam', 'fryingPan', 'snowballFight'] + return fallback[Math.floor(Math.random() * fallback.length)] + } + + // 60% themed, 25% generic fallback, 15% wild card + const roll = Math.random() + if (roll < 0.6) { + return entry.themed[Math.floor(Math.random() * entry.themed.length)] + } else if (roll < 0.85) { + return entry.generic[Math.floor(Math.random() * entry.generic.length)] + } else { + const wild = [ + 'jetpackDive', 'gunBurst', 'groundPound', 'teleportStrike', 'fullScreenDash', 'fireBreath', + 'golfClubSmash', 'zoomRush', 'rapidFlurry', 'swordSlash', 'hammerSmash', 'laserBeam', + 'rocketLauncher', 'bombThrow', 'minigunSpray', 'sniperShot', 'whipCrack', 'katanaCombo', + 'chainsawRev', 'motorbikeCharge', 'carSmash', 'boatCannon', 'anvilDrop', 'pocketCannon', + 'grappleFlurry', 'bodySlam', 'pinballCombo', 'suplex', + // New wild cards — food + 'pizzaSlam', 'bananaFling', 'pieSmash', 'burgerToss', 'tomatoBarrage', 'fishSlap', + // New wild cards — weapons + 'fryingPan', 'baseballBat', 'guitarSmash', 'umbrellaWhack', 'plungerSlam', 'wrenchSmash', + // New wild cards — drops + 'pianoDrop', 'toiletDrop', 'tvDrop', 'chandelierDrop', 'micDrop', + // New wild cards — vehicles + 'shoppingCart', 'zamboniCrush', 'unicycleRun', 'tankRoll', + // New wild cards — spins/flips + 'tornadoSpin', 'backflipKick', 'corkscrewDive', 'barrelRoll', 'skateTrick', + // New wild cards — beams + 'kamehameha', 'freezeRay', 'heatVision', 'rainbowBeam', 'glitchBeam', + // New wild cards — silly + 'rubberChicken', 'selfieStrike', 'fingerGuns', 'yeetThrow', 'emojiBarrage', 'touchGrass', + // New wild cards — wrestling + 'clothesline', 'rko', 'stunner', 'piledriver', + // New wild cards — magic + 'fireball', 'portalPunch', 'crystalShards', 'spiritBomb', + // New wild cards — tech + 'stackOverflow', 'blueScreen', 'aiUprising', 'popupSpam', + // New wild cards — music + 'bassDrop', 'dubstepCannon', 'vinylScratch', + // New wild cards — nature + 'avalanche', 'lavaSplash', 'tornadoFling', + ] + return wild[Math.floor(Math.random() * wild.length)] + } } export function createFightScene(config: FightSceneConfig) { @@ -85,38 +172,916 @@ export function createFightScene(config: FightSceneConfig) { const colorsA = getBotColors(botA.seed) const colorsB = getBotColors(botB.seed) - const sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary) - const sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary) + const sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype) + const sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype) k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }) k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }) + // Judge sprite (red lobster referee) + const judgeSheet = generateJudgeSpriteSheet() + const judgeAnims = { + idle: { from: 0, to: JUDGE_ANIMATIONS.idle.frames - 1, loop: true, speed: 4 }, + call_left: { from: JUDGE_MAX_FRAMES, to: JUDGE_MAX_FRAMES + JUDGE_ANIMATIONS.call_left.frames - 1, loop: false, speed: 8 }, + call_right: { from: JUDGE_MAX_FRAMES * 2, to: JUDGE_MAX_FRAMES * 2 + JUDGE_ANIMATIONS.call_right.frames - 1, loop: false, speed: 8 }, + shocked: { from: JUDGE_MAX_FRAMES * 3, to: JUDGE_MAX_FRAMES * 3 + JUDGE_ANIMATIONS.shocked.frames - 1, loop: false, speed: 10 }, + } + k.loadSprite('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims }) + const W = k.width() const H = k.height() const GROUND_Y = H * 0.78 + const HOME_A = W * 0.28 + const HOME_B = W * 0.72 + + // Particle helpers + function spawnSparks(x: number, y: number, count: number, color: string) { + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2 + const speed = 100 + Math.random() * 300 + const size = 2 + Math.random() * 4 + const spark = k.add([ + k.rect(size, size), + k.pos(x, y), + k.color(k.Color.fromHex(color)), + k.opacity(1), + k.z(20), + k.rotate(Math.random() * 360), + { vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 100 }, + ]) + spark.onUpdate(() => { + spark.pos.x += (spark as any).vx * k.dt() + spark.pos.y += (spark as any).vy * k.dt() + ;(spark as any).vy += 400 * k.dt() // gravity + spark.opacity -= 1.5 * k.dt() + if (spark.opacity <= 0) spark.destroy() + }) + } + } + + function spawnBulletHoles(x: number, y: number, count: number) { + for (let i = 0; i < count; i++) { + const hole = k.add([ + k.circle(3 + Math.random() * 3), + k.pos(x + (Math.random() - 0.5) * 40, y + (Math.random() - 0.5) * 60), + k.color(k.Color.fromHex('#000000')), + k.opacity(0.8), + k.z(9), // behind fighters + ]) + // Fade out after a bit + setTimeout(() => { + const fadeInterval = setInterval(() => { + hole.opacity -= 0.02 + if (hole.opacity <= 0) { hole.destroy(); clearInterval(fadeInterval) } + }, 50) + }, 1500) + } + } + + function spawnExhaust(x: number, y: number, duration: number) { + const endTime = performance.now() + duration * 1000 + const interval = setInterval(() => { + if (performance.now() > endTime) { clearInterval(interval); return } + for (let i = 0; i < 3; i++) { + const p = k.add([ + k.circle(3 + Math.random() * 5), + k.pos(x + (Math.random() - 0.5) * 15, y), + k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), + k.opacity(0.8), + k.z(8), + ]) + p.onUpdate(() => { + p.pos.y += 100 * k.dt() + p.pos.x += (Math.random() - 0.5) * 50 * k.dt() + p.opacity -= 2.5 * k.dt() + if (p.opacity <= 0) p.destroy() + }) + } + }, 40) + return interval + } + + function spawnProjectile(fromX: number, fromY: number, toX: number, toY: number, color: string, size: number = 8): Promise { + return new Promise(resolve => { + const proj = k.add([ + k.circle(size), + k.pos(fromX, fromY), + k.color(k.Color.fromHex(color)), + k.opacity(1), + k.z(15), + ]) + // Trail + const trail = setInterval(() => { + const t = k.add([ + k.circle(size * 0.6), + k.pos(proj.pos.x, proj.pos.y), + k.color(k.Color.fromHex(color)), + k.opacity(0.5), + k.z(14), + ]) + t.onUpdate(() => { t.opacity -= 3 * k.dt(); if (t.opacity <= 0) t.destroy() }) + }, 30) + + const dx = toX - fromX + const dy = toY - fromY + const dist = Math.sqrt(dx * dx + dy * dy) + const speed = 800 + const dur = dist / speed + + k.tween(0, 1, dur, (t) => { + proj.pos.x = fromX + dx * t + proj.pos.y = fromY + dy * t + }, k.easings.linear).then(() => { + clearInterval(trail) + proj.destroy() + spawnSparks(toX, toY, 8, color) + resolve() + }) + }) + } + + function spawnBullet(fromX: number, fromY: number, toX: number, toY: number): Promise { + return new Promise(resolve => { + const bullet = k.add([ + k.rect(6, 2), + k.pos(fromX, fromY), + k.color(k.Color.fromHex('#ffee00')), + k.opacity(1), + k.z(15), + k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI), + ]) + const dur = 0.08 + Math.random() * 0.04 + k.tween(0, 1, dur, (t) => { + bullet.pos.x = fromX + (toX - fromX) * t + bullet.pos.y = fromY + (toY - fromY) * t + (Math.random() - 0.5) * 4 + }, k.easings.linear).then(() => { + bullet.destroy() + // Muzzle flash at impact + spawnSparks(toX, toY, 3, '#ffcc00') + resolve() + }) + }) + } + + function spawnShockwave(x: number, y: number, color: string) { + const wave = k.add([ + k.circle(5), + k.pos(x, y), + k.color(k.Color.fromHex(color)), + k.opacity(0.7), + k.z(5), + k.scale(1), + ]) + k.tween(1, 15, 0.4, (v) => { wave.scaleTo(v, v * 0.3) }, k.easings.easeOutQuad) + k.tween(0.7, 0, 0.4, (v) => { wave.opacity = v }, k.easings.easeOutQuad).then(() => wave.destroy()) + } + + // === VISUAL CHAOS EFFECTS === + + // RGB split glitch — offsets red/blue channels using colored overlays + function glitchRGB(duration: number = 0.2) { + const layers = [ + { color: '#ff0000', dx: 4 + Math.random() * 6, dy: -2 }, + { color: '#0000ff', dx: -(4 + Math.random() * 6), dy: 2 }, + ] + for (const l of layers) { + const overlay = k.add([ + k.rect(W, H), k.pos(l.dx, l.dy), + k.color(k.Color.fromHex(l.color)), k.opacity(0.12), k.z(55), + ]) + overlay.onUpdate(() => { + overlay.pos.x = l.dx + (Math.random() - 0.5) * 4 + overlay.pos.y = l.dy + (Math.random() - 0.5) * 2 + }) + setTimeout(() => { if (overlay.exists()) overlay.destroy() }, duration * 1000) + } + } + + // Scanline glitch — horizontal bands flicker + function scanlineGlitch(duration: number = 0.3) { + const lines: any[] = [] + const count = 6 + Math.floor(Math.random() * 8) + for (let i = 0; i < count; i++) { + const ly = Math.random() * H + const lh = 1 + Math.random() * 3 + const line = k.add([ + k.rect(W, lh), k.pos(0, ly), + k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ffffff' : '#000000')), + k.opacity(0.15 + Math.random() * 0.2), k.z(54), + ]) + line.onUpdate(() => { + line.pos.x = (Math.random() - 0.5) * 8 + line.opacity = Math.random() > 0.3 ? 0.15 : 0 + }) + lines.push(line) + } + setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000) + } + + // VHS tracking distortion — horizontal offset bands + function vhsTracking(duration: number = 0.4) { + const bands: any[] = [] + const bandCount = 3 + for (let i = 0; i < bandCount; i++) { + const by = Math.random() * H + const bh = 10 + Math.random() * 30 + const band = k.add([ + k.rect(W, bh), k.pos(0, by), + k.color(k.Color.fromHex(theme.accent)), k.opacity(0.06), k.z(53), + ]) + band.onUpdate(() => { + band.pos.x = Math.sin(k.time() * 20 + i * 3) * 15 + band.pos.y += (Math.random() - 0.5) * 2 + }) + bands.push(band) + } + setTimeout(() => { bands.forEach(b => { if (b.exists()) b.destroy() }) }, duration * 1000) + } + + // Dimensional shift — background color/hue flash cycle + function dimensionalShift(duration: number = 0.6) { + const colors = ['#ff00ff', '#00ffff', '#ff0000', '#0000ff', '#ffff00'] + let idx = 0 + const overlay = k.add([ + k.rect(W, H), k.pos(0, 0), + k.color(k.Color.fromHex(colors[0])), k.opacity(0.08), k.z(1), + ]) + const interval = setInterval(() => { + idx = (idx + 1) % colors.length + overlay.color = k.Color.fromHex(colors[idx]) + overlay.opacity = 0.05 + Math.random() * 0.06 + }, 80) + setTimeout(() => { clearInterval(interval); if (overlay.exists()) overlay.destroy() }, duration * 1000) + } + + // Schizo cut — rapid zoom/position jitter simulating jump cuts + async function schizoCut() { + const fA = k.get('fighterA')[0] + const fB = k.get('fighterB')[0] + if (!fA || !fB) return + const origAX = fA.pos.x, origBX = fB.pos.x + const origAY = fA.pos.y, origBY = fB.pos.y + // 3-5 rapid cuts + const cuts = 3 + Math.floor(Math.random() * 3) + for (let i = 0; i < cuts; i++) { + // Random offset both fighters + fA.pos.x = origAX + (Math.random() - 0.5) * 30 + fA.pos.y = origAY + (Math.random() - 0.5) * 15 + fB.pos.x = origBX + (Math.random() - 0.5) * 30 + fB.pos.y = origBY + (Math.random() - 0.5) * 15 + screenFlash(Math.random() > 0.5 ? '#000000' : '#ffffff', 0.03) + await k.wait(0.04 + Math.random() * 0.03) + } + fA.pos.x = origAX; fA.pos.y = origAY + fB.pos.x = origBX; fB.pos.y = origBY + } + + // Hyperspeed lines — converging toward a point + function hyperSpeedLines(targetX: number, targetY: number, duration: number = 0.3) { + const lines: any[] = [] + for (let i = 0; i < 16; i++) { + const angle = (i / 16) * Math.PI * 2 + const dist = 300 + Math.random() * 200 + const sx = targetX + Math.cos(angle) * dist + const sy = targetY + Math.sin(angle) * dist + const len = 30 + Math.random() * 60 + const line = k.add([ + k.rect(len, 1.5), k.pos(sx, sy), + k.color(k.Color.fromHex('#ffffff')), k.opacity(0.3), k.z(52), + k.rotate(angle * 180 / Math.PI + 180), + ]) + line.onUpdate(() => { + const dx = targetX - line.pos.x + const dy = targetY - line.pos.y + const d = Math.sqrt(dx * dx + dy * dy) + if (d > 10) { + line.pos.x += (dx / d) * 600 * k.dt() + line.pos.y += (dy / d) * 600 * k.dt() + } + line.opacity -= 0.8 * k.dt() + if (line.opacity <= 0 && line.exists()) line.destroy() + }) + lines.push(line) + } + setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000) + } + + // Flash the whole screen + function screenFlash(color: string, duration: number = 0.1) { + const flash = k.add([ + k.rect(W, H), + k.pos(0, 0), + k.color(k.Color.fromHex(color)), + k.opacity(0.4), + k.z(50), + ]) + k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy()) + } + + // === Ren & Stimpy style grotesque close-up overlays === + // When bots scale up big, we overlay gross details: bulging eyes, veins, teeth, sweat + let grotesqueObjects: any[] = [] + + function spawnGrotesqueDetails(fighter: any, scaleFactor: number) { + const cx = fighter.pos.x + const cy = fighter.pos.y + const dir = fighter.scale.x > 0 ? 1 : -1 + const sz = scaleFactor // bigger = more detail + + // Bulging bloodshot eye (the iconic Ren & Stimpy look) + const eyeX = cx + dir * 8 * sz + const eyeY = cy - 35 * sz + const eyeWhite = k.add([ + k.circle(7 * sz), k.pos(eyeX, eyeY), + k.color(k.Color.fromHex('#ffffdd')), k.opacity(0.85), k.z(32), + ]) + grotesqueObjects.push(eyeWhite) + + // Bloodshot veins on eye + for (let v = 0; v < 4; v++) { + const angle = (v / 4) * Math.PI * 2 + Math.random() * 0.5 + const vLen = 4 * sz + Math.random() * 3 * sz + const vein = k.add([ + k.rect(vLen, 0.8 * sz), k.pos(eyeX, eyeY), + k.color(k.Color.fromHex('#cc2222')), k.opacity(0.7), k.z(33), + k.rotate(angle * 180 / Math.PI), + ]) + grotesqueObjects.push(vein) + } + + // Pupil — twitchy + const pupil = k.add([ + k.circle(3 * sz), k.pos(eyeX + dir * 2 * sz, eyeY), + k.color(k.Color.fromHex('#111111')), k.opacity(0.9), k.z(34), + ]) + pupil.onUpdate(() => { + pupil.pos.x = eyeX + dir * 2 * sz + Math.sin(k.time() * 12) * sz + pupil.pos.y = eyeY + Math.cos(k.time() * 9) * 0.8 * sz + }) + grotesqueObjects.push(pupil) + + // Second eye (smaller, off-axis for grotesque asymmetry) + const eye2X = cx - dir * 4 * sz + const eye2Y = cy - 33 * sz + const eye2 = k.add([ + k.circle(5 * sz), k.pos(eye2X, eye2Y), + k.color(k.Color.fromHex('#ffffcc')), k.opacity(0.8), k.z(32), + ]) + const pupil2 = k.add([ + k.circle(2.5 * sz), k.pos(eye2X - dir * sz, eye2Y + sz), + k.color(k.Color.fromHex('#111111')), k.opacity(0.85), k.z(34), + ]) + pupil2.onUpdate(() => { + pupil2.pos.x = eye2X - dir * sz + Math.sin(k.time() * 15 + 1) * 0.8 * sz + pupil2.pos.y = eye2Y + sz + Math.cos(k.time() * 11 + 1) * 0.6 * sz + }) + grotesqueObjects.push(eye2, pupil2) + + // Grimacing teeth / grin + const teethY = cy - 18 * sz + for (let t = 0; t < 5; t++) { + const tx = cx + (t - 2) * 4 * sz * dir + const tooth = k.add([ + k.rect(3 * sz, 4 * sz + Math.random() * 2 * sz), + k.pos(tx, teethY), + k.color(k.Color.fromHex(Math.random() > 0.3 ? '#ffffcc' : '#cccc88')), + k.opacity(0.8), k.z(33), + ]) + grotesqueObjects.push(tooth) + } + + // Forehead veins — pulsing + for (let v = 0; v < 3; v++) { + const vx = cx + (Math.random() - 0.5) * 18 * sz + const vy = cy - 42 * sz - Math.random() * 8 * sz + const vein = k.add([ + k.rect(8 * sz + Math.random() * 6 * sz, 0.7 * sz), + k.pos(vx, vy), + k.color(k.Color.fromHex('#6633aa')), + k.opacity(0.4), k.z(31), + k.rotate(-20 + Math.random() * 40), + ]) + vein.onUpdate(() => { + vein.opacity = 0.3 + Math.sin(k.time() * 6 + v * 2) * 0.15 + }) + grotesqueObjects.push(vein) + } + + // Sweat drops — animated falling + for (let s = 0; s < 2 + Math.floor(Math.random() * 2); s++) { + const sx = cx + (Math.random() - 0.5) * 20 * sz + const sy = cy - 40 * sz - Math.random() * 10 * sz + const drop = k.add([ + k.circle(1.5 * sz), k.pos(sx, sy), + k.color(k.Color.fromHex('#88ccff')), k.opacity(0.7), k.z(35), + ]) + drop.onUpdate(() => { + drop.pos.y += 40 * sz * k.dt() + drop.opacity -= 0.8 * k.dt() + if (drop.opacity <= 0 && drop.exists()) drop.destroy() + }) + grotesqueObjects.push(drop) + } + + // Nostril flare + const nostrilY = cy - 24 * sz + for (let n = 0; n < 2; n++) { + const nx = cx + (n === 0 ? -2 : 2) * sz * dir + const nostril = k.add([ + k.circle(1.8 * sz), k.pos(nx, nostrilY), + k.color(k.Color.fromHex('#331111')), k.opacity(0.6), k.z(33), + k.scale(1), + ]) + nostril.onUpdate(() => { + const pulse = 1 + Math.sin(k.time() * 8) * 0.3 + nostril.scale.x = pulse; nostril.scale.y = pulse + }) + grotesqueObjects.push(nostril) + } + + // === RANDOM EXTRA EXPRESSION (varies each close-up) === + const expression = Math.floor(Math.random() * 6) + + if (expression === 0) { + // RAGE FACE — eyebrows angled down, mouth wide open, steam from ears + const browL = k.add([ + k.rect(8 * sz, 1.5 * sz), k.pos(eyeX - 4 * sz, eyeY - 6 * sz), + k.color(k.Color.fromHex('#442200')), k.opacity(0.8), k.z(35), + k.rotate(dir > 0 ? 25 : -25), + ]) + const browR = k.add([ + k.rect(8 * sz, 1.5 * sz), k.pos(eye2X - 4 * sz, eye2Y - 5 * sz), + k.color(k.Color.fromHex('#442200')), k.opacity(0.8), k.z(35), + k.rotate(dir > 0 ? -25 : 25), + ]) + grotesqueObjects.push(browL, browR) + // Steam puffs from ears + for (let s = 0; s < 3; s++) { + const earX = cx + dir * 18 * sz + const puff = k.add([ + k.circle(2 * sz + s * sz), k.pos(earX, cy - 30 * sz - s * 5 * sz), + k.color(k.Color.fromHex('#cccccc')), k.opacity(0.5), k.z(36), + ]) + puff.onUpdate(() => { + puff.pos.y -= 15 * sz * k.dt() + puff.opacity -= 0.6 * k.dt() + if (puff.opacity <= 0 && puff.exists()) puff.destroy() + }) + grotesqueObjects.push(puff) + } + } else if (expression === 1) { + // TONGUE OUT — big pink tongue lolling, drool drops + const tongueX = cx + dir * 2 * sz + const tongueY = cy - 14 * sz + const tongue = k.add([ + k.rect(5 * sz, 10 * sz, { radius: 3 * sz }), k.pos(tongueX, tongueY), + k.color(k.Color.fromHex('#ff6688')), k.opacity(0.8), k.z(34), + ]) + tongue.onUpdate(() => { + tongue.pos.x = tongueX + Math.sin(k.time() * 6) * sz + tongue.pos.y = tongueY + Math.sin(k.time() * 4) * 0.5 * sz + }) + grotesqueObjects.push(tongue) + // Drool + const drool = k.add([ + k.circle(1.2 * sz), k.pos(tongueX + 2 * sz, tongueY + 10 * sz), + k.color(k.Color.fromHex('#88ccff')), k.opacity(0.6), k.z(35), + ]) + drool.onUpdate(() => { + drool.pos.y += 25 * sz * k.dt() + drool.opacity -= 0.4 * k.dt() + if (drool.opacity <= 0 && drool.exists()) drool.destroy() + }) + grotesqueObjects.push(drool) + } else if (expression === 2) { + // CROSS-EYED — both pupils drift toward center + pupil.onUpdate(() => { + pupil.pos.x = eyeX - dir * 3 * sz + Math.sin(k.time() * 3) * 0.5 * sz + pupil.pos.y = eyeY + 2 * sz + }) + pupil2.onUpdate(() => { + pupil2.pos.x = eye2X + dir * 3 * sz + Math.sin(k.time() * 3 + 1) * 0.5 * sz + pupil2.pos.y = eye2Y + 2 * sz + }) + } else if (expression === 3) { + // CRYING — tear streams + quivering lip + for (let side = 0; side < 2; side++) { + const tearBaseX = side === 0 ? eyeX : eye2X + const tearBaseY = (side === 0 ? eyeY : eye2Y) + 5 * sz + for (let t = 0; t < 3; t++) { + const tear = k.add([ + k.circle(1.2 * sz), k.pos(tearBaseX, tearBaseY), + k.color(k.Color.fromHex('#4488ff')), k.opacity(0.7), k.z(36), + ]) + const startDelay = t * 0.3 + let elapsed = -startDelay + tear.onUpdate(() => { + elapsed += k.dt() + if (elapsed < 0) return + tear.pos.y = tearBaseY + elapsed * 40 * sz + tear.opacity = 0.7 - elapsed * 0.8 + if (tear.opacity <= 0 && tear.exists()) tear.destroy() + }) + grotesqueObjects.push(tear) + } + } + // Quivering lower lip + const lip = k.add([ + k.rect(10 * sz, 2 * sz, { radius: sz }), k.pos(cx - 5 * sz, cy - 16 * sz), + k.color(k.Color.fromHex('#cc4466')), k.opacity(0.6), k.z(34), + ]) + lip.onUpdate(() => { lip.pos.y = cy - 16 * sz + Math.sin(k.time() * 20) * 0.5 * sz }) + grotesqueObjects.push(lip) + } else if (expression === 4) { + // SCARED FACE — wide eyes (pupils tiny), mouth agape + // Shrink pupils + pupil.onUpdate(() => { + pupil.pos.x = eyeX + Math.sin(k.time() * 20) * 2 * sz + pupil.pos.y = eyeY + Math.cos(k.time() * 18) * 1.5 * sz + }) + // Giant open mouth + const mouth = k.add([ + k.circle(6 * sz), k.pos(cx, cy - 16 * sz), + k.color(k.Color.fromHex('#110000')), k.opacity(0.7), k.z(33), + k.scale(1), + ]) + mouth.onUpdate(() => { + const pulse = 1 + Math.sin(k.time() * 10) * 0.15 + mouth.scale.x = pulse; mouth.scale.y = pulse * 0.7 + }) + grotesqueObjects.push(mouth) + } else { + // SMUG GRIN — half-lidded eyes, wide smirk + // Eyelids (half cover the eyes) + const lidL = k.add([ + k.rect(16 * sz, 5 * sz), k.pos(eyeX - 8 * sz, eyeY - 6 * sz), + k.color(k.Color.fromHex('#886644')), k.opacity(0.5), k.z(35), + ]) + const lidR = k.add([ + k.rect(12 * sz, 4 * sz), k.pos(eye2X - 6 * sz, eye2Y - 5 * sz), + k.color(k.Color.fromHex('#886644')), k.opacity(0.5), k.z(35), + ]) + // Wide smirk + const smirk = k.add([ + k.rect(14 * sz, 2 * sz, { radius: sz }), k.pos(cx - 3 * sz * dir, cy - 19 * sz), + k.color(k.Color.fromHex('#cc3344')), k.opacity(0.7), k.z(34), + k.rotate(dir > 0 ? 10 : -10), + ]) + grotesqueObjects.push(lidL, lidR, smirk) + } + } + + function destroyGrotesqueDetails() { + for (const obj of grotesqueObjects) { + if (obj.exists()) obj.destroy() + } + grotesqueObjects = [] + } + + // Arena-specific background decoration + function drawArenaDecor() { + const a = arena + const acc = theme.accent + + // Parallax stars/particles in the sky + for (let i = 0; i < 30; i++) { + const star = k.add([ + k.rect(1 + Math.random() * 2, 1 + Math.random() * 2), + k.pos(Math.random() * W, Math.random() * (GROUND_Y - 20)), + k.color(k.Color.fromHex(acc)), + k.opacity(0.1 + Math.random() * 0.2), + k.z(1), + ]) + // Twinkle + const speed = 0.5 + Math.random() * 1.5 + const baseOp = star.opacity + star.onUpdate(() => { + star.opacity = baseOp + Math.sin(k.time() * speed + i) * 0.1 + }) + } + + if (a === 'datacenter' || a === 'localhost') { + // Blinking server rack lights + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 8; col++) { + const lx = 30 + col * 95 + const ly = 40 + row * 60 + // Rack body + k.add([k.rect(70, 50), k.pos(lx, ly), k.color(k.Color.fromHex('#0a0a15')), k.opacity(0.5), k.z(1)]) + // Blinking LED + const led = k.add([ + k.rect(4, 4), k.pos(lx + 5 + col * 3, ly + 10 + row * 8), + k.color(k.Color.fromHex(Math.random() > 0.5 ? '#00ff41' : '#ff2d2d')), + k.opacity(0.6), k.z(2), + ]) + led.onUpdate(() => { led.opacity = Math.random() > 0.95 ? 0.1 : 0.6 }) + } + } + } else if (a === 'gpu_graveyard') { + // Floating circuit board fragments + for (let i = 0; i < 12; i++) { + const cx = Math.random() * W + const cy = 20 + Math.random() * (GROUND_Y - 60) + const chip = k.add([ + k.rect(15 + Math.random() * 20, 10 + Math.random() * 15), + k.pos(cx, cy), + k.color(k.Color.fromHex('#1a2a1a')), + k.opacity(0.3), + k.z(1), + k.rotate(Math.random() * 360), + ]) + // Trace lines + k.add([k.rect(Math.random() * 30, 1), k.pos(cx, cy + 5), k.color(k.Color.fromHex('#76b900')), k.opacity(0.15), k.z(1)]) + const baseY = cy + chip.onUpdate(() => { chip.pos.y = baseY + Math.sin(k.time() * 0.3 + i) * 5 }) + } + } else if (a === 'prompt_dungeon') { + // Glowing runes on the walls + for (let i = 0; i < 8; i++) { + const rx = 40 + i * (W / 8) + const ry = 30 + Math.random() * 80 + const rune = k.add([ + k.circle(8 + Math.random() * 6), + k.pos(rx, ry), + k.color(k.Color.fromHex('#b83dff')), + k.opacity(0.15), + k.z(1), + ]) + rune.onUpdate(() => { rune.opacity = 0.1 + Math.sin(k.time() * 0.8 + i * 0.7) * 0.1 }) + } + // Fog at ground level + for (let i = 0; i < 6; i++) { + const fog = k.add([ + k.circle(40 + Math.random() * 30), + k.pos(Math.random() * W, GROUND_Y - 10), + k.color(k.Color.fromHex('#1f1a2a')), + k.opacity(0.3), + k.z(3), + ]) + const baseX = fog.pos.x + fog.onUpdate(() => { fog.pos.x = baseX + Math.sin(k.time() * 0.2 + i) * 20 }) + } + } else if (a === 'the_cloud') { + // Floating cloud shapes + for (let i = 0; i < 5; i++) { + const cx = Math.random() * W + const cy = 30 + Math.random() * 80 + for (let j = 0; j < 3; j++) { + const cloud = k.add([ + k.circle(20 + Math.random() * 15), + k.pos(cx + j * 18, cy + (Math.random() - 0.5) * 10), + k.color(k.Color.fromHex('#1a1f2a')), + k.opacity(0.4), + k.z(1), + ]) + const bx = cloud.pos.x + cloud.onUpdate(() => { cloud.pos.x = bx + Math.sin(k.time() * 0.15 + i) * 10 }) + } + } + } else if (a === 'the_singularity') { + // Swirling vortex in the background + for (let i = 0; i < 20; i++) { + const angle = (i / 20) * Math.PI * 2 + const dist = 40 + i * 8 + const sx = W / 2 + Math.cos(angle) * dist + const sy = GROUND_Y * 0.4 + Math.sin(angle) * dist * 0.5 + const dot = k.add([ + k.circle(2 + Math.random() * 3), + k.pos(sx, sy), + k.color(k.Color.fromHex('#ff00ff')), + k.opacity(0.2), + k.z(1), + ]) + dot.onUpdate(() => { + const a2 = angle + k.time() * 0.5 + const d2 = dist + Math.sin(k.time() + i) * 10 + dot.pos.x = W / 2 + Math.cos(a2) * d2 + dot.pos.y = GROUND_Y * 0.4 + Math.sin(a2) * d2 * 0.5 + }) + } + } else if (a === 'silicon_valley_dojo') { + // Bamboo/pillars on sides + for (const side of [0.05, 0.1, 0.88, 0.93]) { + const px = W * side + k.add([k.rect(6, GROUND_Y - 20), k.pos(px, 20), k.color(k.Color.fromHex('#1a3a1a')), k.opacity(0.4), k.z(1)]) + // Leaves + for (let j = 0; j < 3; j++) { + k.add([ + k.circle(8), + k.pos(px + (Math.random() - 0.5) * 20, 30 + j * 40), + k.color(k.Color.fromHex('#00ff41')), + k.opacity(0.15), + k.z(1), + ]) + } + } + } else if (a === 'hacker_news' || a === 'stackoverflow_ruins') { + // Floating text-like blocks (simulating code/posts) + for (let i = 0; i < 10; i++) { + const bw = 30 + Math.random() * 50 + k.add([ + k.rect(bw, 4 + Math.random() * 3), + k.pos(20 + Math.random() * (W - 80), 20 + Math.random() * (GROUND_Y - 60)), + k.color(k.Color.fromHex(acc)), + k.opacity(0.06 + Math.random() * 0.06), + k.z(1), + ]) + } + } else if (a === 'beach') { + // Ocean waves at horizon, sun, palm trees + const sun = k.add([ + k.circle(25), k.pos(W * 0.8, 40), + k.color(k.Color.fromHex('#ffcc00')), k.opacity(0.5), k.z(1), + ]) + sun.onUpdate(() => { sun.opacity = 0.4 + Math.sin(k.time() * 0.5) * 0.1 }) + // Waves + for (let i = 0; i < 6; i++) { + const wave = k.add([ + k.rect(W * 0.4, 3), k.pos(Math.random() * W, GROUND_Y - 15 - i * 6), + k.color(k.Color.fromHex('#2266aa')), k.opacity(0.2), k.z(2), + ]) + const baseX = wave.pos.x + wave.onUpdate(() => { wave.pos.x = baseX + Math.sin(k.time() * 0.8 + i) * 20 }) + } + // Palm tree silhouettes + for (const px of [W * 0.08, W * 0.92]) { + k.add([k.rect(6, 80), k.pos(px, GROUND_Y - 80), k.color(k.Color.fromHex('#2a1a00')), k.opacity(0.4), k.z(1)]) + for (let l = 0; l < 4; l++) { + k.add([ + k.rect(30, 4), k.pos(px - 15, GROUND_Y - 82 - l * 3), + k.color(k.Color.fromHex('#1a5500')), k.opacity(0.3), k.z(1), + k.rotate(-30 + l * 20), + ]) + } + } + } else if (a === 'desert') { + // Sand dunes, cacti, heat shimmer + for (let i = 0; i < 3; i++) { + const dx = W * (0.2 + i * 0.3) + k.add([k.circle(60 + i * 20), k.pos(dx, GROUND_Y + 10), k.color(k.Color.fromHex('#886630')), k.opacity(0.3), k.z(1)]) + } + // Cacti + for (const cx of [W * 0.12, W * 0.55, W * 0.88]) { + k.add([k.rect(8, 40), k.pos(cx, GROUND_Y - 40), k.color(k.Color.fromHex('#226622')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(15, 6), k.pos(cx - 8, GROUND_Y - 55), k.color(k.Color.fromHex('#226622')), k.opacity(0.35), k.z(1)]) + } + // Heat shimmer + const shimmer = k.add([k.rect(W, 2), k.pos(0, GROUND_Y - 5), k.color(k.Color.fromHex('#ff8800')), k.opacity(0.08), k.z(2)]) + shimmer.onUpdate(() => { shimmer.opacity = 0.05 + Math.sin(k.time() * 3) * 0.04 }) + } else if (a === 'forest') { + // Trees, undergrowth, fireflies + for (let i = 0; i < 8; i++) { + const tx = 20 + i * (W / 8) + const th = 50 + Math.random() * 40 + k.add([k.rect(8, th), k.pos(tx, GROUND_Y - th), k.color(k.Color.fromHex('#1a2a10')), k.opacity(0.4), k.z(1)]) + k.add([k.circle(20 + Math.random() * 15), k.pos(tx, GROUND_Y - th - 10), k.color(k.Color.fromHex('#1a3a10')), k.opacity(0.3), k.z(1)]) + } + // Fireflies + for (let i = 0; i < 8; i++) { + const ff = k.add([ + k.circle(2), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 50)), + k.color(k.Color.fromHex('#aaff44')), k.opacity(0), k.z(2), + ]) + ff.onUpdate(() => { + ff.opacity = Math.max(0, Math.sin(k.time() * 2 + i * 1.5) * 0.4) + ff.pos.x += Math.sin(k.time() + i) * 10 * k.dt() + ff.pos.y += Math.cos(k.time() * 0.8 + i) * 8 * k.dt() + }) + } + } else if (a === 'jungle') { + // Dense vines, hanging leaves, misty + for (let i = 0; i < 6; i++) { + const vx = 30 + i * (W / 6) + const vineLen = 40 + Math.random() * 60 + k.add([k.rect(2, vineLen), k.pos(vx, 0), k.color(k.Color.fromHex('#1a4a10')), k.opacity(0.3), k.z(1)]) + // Leaf at bottom + k.add([k.circle(6), k.pos(vx, vineLen), k.color(k.Color.fromHex('#22aa22')), k.opacity(0.25), k.z(1)]) + } + // Jungle mist + for (let i = 0; i < 4; i++) { + const mist = k.add([ + k.circle(50 + Math.random() * 30), k.pos(Math.random() * W, GROUND_Y - 20), + k.color(k.Color.fromHex('#1a3818')), k.opacity(0.2), k.z(3), + ]) + const bx = mist.pos.x + mist.onUpdate(() => { mist.pos.x = bx + Math.sin(k.time() * 0.15 + i) * 15 }) + } + } else if (a === 'outer_space') { + // Deep star field, nebula, floating asteroids + for (let i = 0; i < 60; i++) { + const star = k.add([ + k.circle(0.5 + Math.random() * 1.5), + k.pos(Math.random() * W, Math.random() * H), + k.color(k.Color.fromHex(Math.random() > 0.8 ? '#aaaaff' : '#ffffff')), + k.opacity(0.2 + Math.random() * 0.4), k.z(1), + ]) + star.onUpdate(() => { star.opacity = 0.15 + Math.sin(k.time() * (1 + Math.random()) + i) * 0.15 }) + } + // Nebula glow + for (let i = 0; i < 3; i++) { + k.add([ + k.circle(60 + Math.random() * 40), + k.pos(W * (0.2 + i * 0.3), H * 0.3 + Math.random() * 50), + k.color(k.Color.fromHex(['#4400aa', '#aa0066', '#0044aa'][i])), + k.opacity(0.08), k.z(1), + ]) + } + // Asteroids + for (let i = 0; i < 4; i++) { + const ast = k.add([ + k.circle(5 + Math.random() * 8), + k.pos(Math.random() * W, 20 + Math.random() * (GROUND_Y - 40)), + k.color(k.Color.fromHex('#444444')), k.opacity(0.3), k.z(1), + ]) + const baseX = ast.pos.x, baseY = ast.pos.y + ast.onUpdate(() => { + ast.pos.x = baseX + Math.sin(k.time() * 0.2 + i * 2) * 15 + ast.pos.y = baseY + Math.cos(k.time() * 0.15 + i) * 10 + }) + } + } + + // === SPECTATORS === + // Small crowd figures behind the fighters, animated with idle bobbing and reactions + const spectatorCount = 8 + Math.floor(Math.random() * 6) + const spectatorColors = ['#cc4444', '#44cc44', '#4444cc', '#cccc44', '#cc44cc', '#44cccc', '#ff8844', '#8844ff'] + for (let i = 0; i < spectatorCount; i++) { + // Position along the back, between the sides of the arena + const sx = 30 + (i / (spectatorCount - 1)) * (W - 60) + const sy = GROUND_Y - 5 + (Math.random() > 0.5 ? -8 : 0) // slight row variation + const bodyColor = spectatorColors[i % spectatorColors.length] + const bodyH = 8 + Math.random() * 4 + // Body + const body = k.add([ + k.rect(5, bodyH), k.pos(sx, sy - bodyH), + k.color(k.Color.fromHex(bodyColor)), k.opacity(0.3), k.z(5), + ]) + // Head + const head = k.add([ + k.circle(3), k.pos(sx + 2.5, sy - bodyH - 3), + k.color(k.Color.fromHex('#ddbb88')), k.opacity(0.3), k.z(5), + ]) + // Idle bobbing + const baseY = body.pos.y + const headBaseY = head.pos.y + const bobSpeed = 1.5 + Math.random() * 2 + const bobAmt = 1 + Math.random() * 2 + body.onUpdate(() => { + body.pos.y = baseY + Math.sin(k.time() * bobSpeed + i * 0.7) * bobAmt + }) + head.onUpdate(() => { + head.pos.y = headBaseY + Math.sin(k.time() * bobSpeed + i * 0.7) * bobAmt + }) + } + } k.scene('fight', () => { - // Ground + // Ground with gradient effect (multiple layers) k.add([k.rect(W, H * 0.25), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.ground))]) - k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.5)]) + k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.6)]) + // Ground gradient lines + for (let i = 1; i <= 8; i++) { + k.add([k.rect(W, 1), k.pos(0, GROUND_Y + i * 8), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.08 - i * 0.008)]) + } + // Perspective grid + for (let i = 0; i < 32; i++) { + const x = i * (W / 32) + // Lines converge toward horizon for depth + k.add([k.rect(1, H * 0.25), k.pos(x, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.04)]) + } - for (let i = 1; i <= 5; i++) { - k.add([k.rect(W, 1), k.pos(0, GROUND_Y + i * 12), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.06)]) - } - for (let i = 0; i < 24; i++) { - k.add([k.rect(1, H * 0.25), k.pos(i * (W / 24), GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.04)]) + // Arena-specific decorations + drawArenaDecor() + + // Umpire chair (center-back, behind fighters) + const CHAIR_X = W / 2 + const CHAIR_SEAT_Y = GROUND_Y * 0.38 + k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X - 22, CHAIR_SEAT_Y - 8), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) + k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X + 18, CHAIR_SEAT_Y - 8), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) + for (let i = 0; i < 6; i++) { + const rungY = CHAIR_SEAT_Y + 15 + i * ((GROUND_Y - CHAIR_SEAT_Y - 15) / 6) + k.add([k.rect(44, 3), k.pos(CHAIR_X - 22, rungY), k.color(k.Color.fromHex('#443322')), k.opacity(0.6), k.z(3)]) } + k.add([k.rect(54, 5), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(k.Color.fromHex('#665544')), k.opacity(0.8), k.z(3)]) + k.add([k.rect(54, 1), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.3), k.z(3)]) + k.add([k.rect(48, 4), k.pos(CHAIR_X - 24, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#665544')), k.opacity(0.8), k.z(3)]) + k.add([k.rect(3, 12), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) + k.add([k.rect(3, 12), k.pos(CHAIR_X + 24, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) + k.add([ + k.sprite('judge', { anim: 'idle' }), + k.pos(CHAIR_X, CHAIR_SEAT_Y), + k.anchor('bot'), + k.scale(1.3), + k.z(4), + k.opacity(0.9), + 'judge', + ]) const scaleA = 1.8 + botA.tier * 0.4 - k.add([k.sprite('botA', { anim: 'idle' }), k.pos(W * 0.28, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), 'fighterA']) + k.add([k.sprite('botA', { anim: 'idle' }), k.pos(HOME_A, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), 'fighterA']) const scaleB = 1.8 + botB.tier * 0.4 - k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W * 0.72, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), 'fighterB']) - - k.add([k.text('', { size: 42, font: 'monospace' }), k.pos(W / 2, H * 0.3), k.anchor('center'), k.color(k.Color.fromHex('#ffffff')), k.opacity(0), k.z(100), 'announcement']) - k.add([k.text('', { size: 32, font: 'monospace' }), k.pos(0, 0), k.anchor('center'), k.color(k.Color.fromHex('#ff2d2d')), k.opacity(0), k.z(90), 'hitText']) - k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.28, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboA']) - k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.72, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboB']) + k.add([k.sprite('botB', { anim: 'idle' }), k.pos(HOME_B, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), 'fighterB']) config.onReady?.() }) @@ -126,80 +1091,2796 @@ export function createFightScene(config: FightSceneConfig) { let comboA = 0 let comboB = 0 + // === CHOREOGRAPHY FUNCTIONS === + // Each returns a Promise and handles all movement + animation + + async function dashPunch(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 50 + // Fast sprint to contact + await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('attack') + sfxPunch() + await k.wait(0.12) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 12 : 5) + spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 12 : 5, '#ffcc00') + if (isCritical) { screenFlash('#ffffff'); sfxCritical() } + // Push defender + const push = dir * (isCritical ? 100 : 35) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + // Return + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + async function aerialSlam(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const origAY = atk.pos.y + const contactX = origDX - dir * 20 + // Jump way up + await k.tween(atk.pos.y, origAY - 220, 0.18, (v) => { atk.pos.y = v }, k.easings.easeOutQuad) + atk.play('kick') + sfxKick() + // Arc down onto opponent + await Promise.all([ + k.tween(atk.pos.x, contactX, 0.22, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(atk.pos.y, origAY, 0.22, (v) => { atk.pos.y = v }, k.easings.easeInQuad), + ]) + // SLAM + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 20 : 10) + spawnSparks(def.pos.x, def.pos.y - 20, 15, theme.accent) + spawnShockwave(def.pos.x, GROUND_Y, theme.accent) + if (isCritical) { screenFlash('#ff2d7b'); sfxExplosion() } else { sfxPunch() } + const push = dir * (isCritical ? 120 : 50) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(atk.pos.y, origAY, 0.15, (v) => { atk.pos.y = v }, k.easings.easeOutBounce), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + async function flyingKick(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const origAY = atk.pos.y + const contactX = origDX - dir * 30 + // Small jump + fly horizontally + atk.play('kick') + sfxKick() + await Promise.all([ + k.tween(atk.pos.x, contactX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(atk.pos.y, origAY - 80, 0.1, (v) => { atk.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(atk.pos.y, origAY, 0.1, (v) => { atk.pos.y = v }, k.easings.easeInQuad) + ), + ]) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 14 : 6) + spawnSparks(def.pos.x, def.pos.y - 40, 8, '#ff6600') + if (isCritical) { sfxCritical(); screenFlash('#ff6600') } else { sfxPunch() } + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + // Bounce back + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + async function dashThrough(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const behindX = origDX + dir * 80 + atk.play('attack') + sfxSpecial() + // Blazing fast dash through opponent + await k.tween(atk.pos.x, behindX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeInQuad) + // Hit happens mid-pass + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 15 : 7) + spawnSparks(origDX, def.pos.y - 30, 10, '#00f0ff') + if (isCritical) screenFlash('#00f0ff') + // Dramatic pause behind opponent + await k.wait(0.3) + // Zip back to start + atk.play('idle') + await k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.1) + } + + async function uppercut(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 40 + const origDY = def.pos.y + // Run to contact + await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('special') + sfxSpecial() + await k.wait(0.1) + // Launch defender into the air! + def.play('knockback') + k.shake(isCritical ? 18 : 8) + spawnSparks(def.pos.x, def.pos.y - 30, 12, '#ffe14d') + if (isCritical) { screenFlash('#ffe14d'); sfxExplosion() } + const launchHeight = isCritical ? 250 : 160 + await k.tween(def.pos.y, origDY - launchHeight, 0.25, (v) => { def.pos.y = v }, k.easings.easeOutQuad) + await k.wait(0.15) + // Defender crashes back down + await k.tween(def.pos.y, origDY, 0.3, (v) => { def.pos.y = v }, k.easings.easeInQuad) + spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d') + k.shake(5) + sfxPunch() + await k.wait(0.2) + // Return + await k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad) + } + + async function multiHit(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 45 + // Sprint to contact + await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + const hitCount = isCritical ? 5 : 3 + for (let i = 0; i < hitCount; i++) { + atk.play(i % 2 === 0 ? 'attack' : 'kick') + sfxPunch() + await k.wait(0.08) + def.play('hit') + k.shake(3 + i) + spawnSparks(def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 - Math.random() * 40, 4, '#ff2d7b') + // Jitter defender + def.pos.x += dir * 8 + await k.wait(0.08) + } + if (isCritical) { screenFlash('#ff2d7b'); sfxCritical() } + // Final big push + def.play('knockback') + k.shake(12) + const push = dir * (isCritical ? 130 : 60) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + async function projectile(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + await k.wait(0.25) + // Fire projectile + const projY = atk.pos.y - 40 + const color = isCritical ? '#ff2d7b' : theme.accent + const size = isCritical ? 12 : 8 + await spawnProjectile(atk.pos.x + dir * 30, projY, def.pos.x, def.pos.y - 40, color, size) + // Impact + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 15 : 6) + spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 20 : 8, color) + if (isCritical) { screenFlash(color); sfxExplosion() } + const push = dir * (isCritical ? 80 : 30) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function jetpackDive(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const origAY = atk.pos.y + sfxJetpack() + // Jetpack blast off - fly way up off screen + const exhaustInterval = spawnExhaust(atk.pos.x, atk.pos.y, 0.8) + await k.tween(atk.pos.y, -50, 0.3, (v) => { + atk.pos.y = v + // Update exhaust position tracking would need the closure to capture, but exhaust spawns from initial pos + }, k.easings.easeInQuad) + clearInterval(exhaustInterval) + // Reposition above opponent + atk.pos.x = origDX + await k.wait(0.15) + // Dive bomb! + atk.play('kick') + sfxJetpack() + const exhaust2 = spawnExhaust(atk.pos.x, atk.pos.y, 0.3) + await k.tween(atk.pos.y, origAY, 0.2, (v) => { atk.pos.y = v }, k.easings.easeInQuad) + clearInterval(exhaust2) + // IMPACT + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 25 : 12) + spawnSparks(def.pos.x, def.pos.y - 20, 20, '#ff6600') + spawnShockwave(def.pos.x, GROUND_Y, '#ff6600') + screenFlash(isCritical ? '#ff2d2d' : '#ff6600', 0.15) + sfxExplosion() + // Push defender far + const push = dir * (isCritical ? 150 : 80) + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + // Return + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.4, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + async function gunBurst(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Attacker pulls out a "gun" (just plays special anim) + atk.play('special') + await k.wait(0.2) + const bulletCount = isCritical ? 8 : 4 + const gunX = atk.pos.x + dir * 25 + const gunY = atk.pos.y - 40 + // Fire bullets in rapid succession + for (let i = 0; i < bulletCount; i++) { + sfxGunshot() + const targetY = def.pos.y - 20 - Math.random() * 50 + spawnBullet(gunX, gunY, def.pos.x, targetY) + // Small recoil + atk.pos.x -= dir * 3 + await k.wait(0.06) + atk.pos.x += dir * 3 + sfxBulletHit() + // Flash defender + def.opacity = 0.4 + await k.wait(0.02) + def.opacity = 1 + } + // Show bullet holes on defender side + spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 5 : 3) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 15 : 8) + if (isCritical) { screenFlash('#ffee00'); sfxExplosion() } + const push = dir * (isCritical ? 100 : 40) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function groundPound(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const origAY = atk.pos.y + // Jump super high off screen + sfxJetpack() + await k.tween(atk.pos.y, -100, 0.25, (v) => { atk.pos.y = v }, k.easings.easeOutQuad) + // Move to center of stage + const slamX = (origAX + origDX) / 2 + atk.pos.x = slamX + await k.wait(0.2) + // SLAM DOWN + atk.play('special') + sfxSpecial() + await k.tween(atk.pos.y, origAY, 0.12, (v) => { atk.pos.y = v }, k.easings.easeInQuad) + // Massive shockwave + k.shake(isCritical ? 30 : 18) + screenFlash(isCritical ? '#ff2d2d' : '#ffe14d', 0.2) + sfxExplosion() + spawnShockwave(slamX, GROUND_Y, theme.accent) + spawnSparks(slamX, GROUND_Y - 10, 25, theme.accent) + // Defender gets launched + const origDY = def.pos.y + def.play('knockback') + const push = dir * (isCritical ? 130 : 70) + const launchH = isCritical ? 180 : 100 + await Promise.all([ + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.y, origDY - launchH, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(def.pos.y, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad) + ), + ]) + k.shake(5) + spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d') + await k.wait(0.3) + // Return + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.4, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + async function teleportStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Fade out + sfxSpecial() + spawnSparks(atk.pos.x, atk.pos.y - 30, 8, '#b83dff') + await k.tween(1, 0, 0.15, (v) => { atk.opacity = v }) + // Teleport behind opponent + atk.pos.x = origDX + dir * 60 + await k.wait(0.1) + // Fade in behind + spawnSparks(atk.pos.x, atk.pos.y - 30, 8, '#b83dff') + await k.tween(0, 1, 0.1, (v) => { atk.opacity = v }) + // Strike from behind! + atk.play('attack') + sfxPunch() + await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 15 : 8) + spawnSparks(def.pos.x, def.pos.y - 30, 12, '#b83dff') + if (isCritical) { screenFlash('#b83dff'); sfxCritical() } + // Push defender forward (reversed since we're behind) + const push = -dir * (isCritical ? 80 : 40) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + // Teleport back home + spawnSparks(atk.pos.x, atk.pos.y - 30, 6, '#b83dff') + await k.tween(1, 0, 0.1, (v) => { atk.opacity = v }) + atk.pos.x = origAX + await k.tween(0, 1, 0.1, (v) => { atk.opacity = v }) + spawnSparks(origAX, atk.pos.y - 30, 6, '#b83dff') + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function fullScreenDash(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Back up to wall + const wallX = dir === 1 ? 20 : W - 20 + await k.tween(atk.pos.x, wallX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.1) + // FULL SPEED DASH across entire screen! + atk.play('kick') + sfxSpecial() + const targetX = dir === 1 ? W - 20 : 20 + // Speed lines + for (let i = 0; i < 8; i++) { + const lineY = Math.random() * H * 0.7 + const line = k.add([ + k.rect(W, 2), + k.pos(0, lineY), + k.color(k.Color.fromHex(theme.accent)), + k.opacity(0.3), + k.z(12), + ]) + setTimeout(() => { if (line.exists()) line.destroy() }, 300) + } + await k.tween(atk.pos.x, targetX, 0.18, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad) + // Hit happens when passing through defender + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 22 : 10) + spawnSparks(origDX, def.pos.y - 30, 15, '#00f0ff') + screenFlash(isCritical ? '#ffffff' : '#00f0ff', 0.12) + if (isCritical) sfxExplosion() + else sfxKick() + const push = dir * (isCritical ? 100 : 50) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + // Return from far side + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // === THEMED CHOREOGRAPHIES (challenge-specific props) === + + // Helper: spawn a flying prop (rect or circle) from A to B + function spawnProp( + fromX: number, fromY: number, toX: number, toY: number, + w: number, h: number, color: string, speed: number = 600, isCircle: boolean = false, + ): Promise { + return new Promise(resolve => { + const prop = k.add([ + isCircle ? k.circle(w / 2) : k.rect(w, h), + k.pos(fromX, fromY), + k.color(k.Color.fromHex(color)), + k.opacity(1), + k.z(16), + k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI), + ]) + const dx = toX - fromX + const dy = toY - fromY + const dist = Math.sqrt(dx * dx + dy * dy) + const dur = dist / speed + k.tween(0, 1, dur, (t) => { + prop.pos.x = fromX + dx * t + prop.pos.y = fromY + dy * t + }, k.easings.linear).then(() => { + prop.destroy() + resolve() + }) + }) + } + + // Helper: spawn multiple flying objects in a stream + async function spawnBarrage( + fromX: number, fromY: number, toX: number, toY: number, + count: number, color: string, size: number, spread: number, delay: number, isCircle: boolean = false, + ) { + for (let i = 0; i < count; i++) { + const offY = (Math.random() - 0.5) * spread + const offX = (Math.random() - 0.5) * spread * 0.3 + spawnProp(fromX + offX, fromY + offY, toX + offX, toY + offY, size, size, color, 500 + Math.random() * 300, isCircle) + await k.wait(delay) + } + } + + // CODE GOLF: swing a giant golf club, golf balls fly at opponent + async function golfClubSmash(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 60 + await k.tween(atk.pos.x, contactX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + // Spawn golf club (long thin rect) + const club = k.add([ + k.rect(8, 60), + k.pos(atk.pos.x + dir * 20, atk.pos.y - 60), + k.color(k.Color.fromHex('#888888')), + k.anchor('bot'), + k.z(18), + k.rotate(dir === 1 ? -45 : 45), + ]) + // Club head (wider rect at end) + const clubHead = k.add([ + k.rect(18, 12), + k.pos(club.pos.x + dir * 15, club.pos.y - 55), + k.color(k.Color.fromHex('#cccccc')), + k.z(18), + ]) + atk.play('attack') + sfxSpecial() + // Swing the club + await k.tween(dir === 1 ? -45 : 45, dir === 1 ? 90 : -90, 0.15, (v) => { club.angle = v }, k.easings.easeInQuad) + sfxBonk() + // Club hits - launch golf balls! + club.destroy() + clubHead.destroy() + const ballCount = isCritical ? 6 : 3 + for (let i = 0; i < ballCount; i++) { + const ballY = def.pos.y - 20 - Math.random() * 40 + const ball = k.add([ + k.circle(5), + k.pos(atk.pos.x + dir * 30, atk.pos.y - 40), + k.color(k.Color.fromHex('#ffffff')), + k.z(16), + ]) + sfxPunch() + const targetX = def.pos.x + (Math.random() - 0.5) * 30 + k.tween(0, 1, 0.12, (t) => { + ball.pos.x = (atk.pos.x + dir * 30) + (targetX - atk.pos.x - dir * 30) * t + ball.pos.y = (atk.pos.y - 40) + (ballY - atk.pos.y + 40) * t - Math.sin(t * Math.PI) * 60 + }).then(() => { + ball.destroy() + spawnSparks(targetX, ballY, 4, '#ffffff') + }) + await k.wait(0.06) + } + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 18 : 8) + if (isCritical) screenFlash('#39ff14') + spawnSparks(def.pos.x, def.pos.y - 30, 10, '#39ff14') + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // ROAST BATTLE: fire breath — stream of fire from attacker's face + async function fireBreath(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 80 + await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('special') + sfxSpecial() + // Fire stream! + const fireX = atk.pos.x + dir * 25 + const fireY = atk.pos.y - 45 + const fireCount = isCritical ? 40 : 20 + for (let i = 0; i < fireCount; i++) { + const p = k.add([ + k.circle(4 + Math.random() * 6), + k.pos(fireX, fireY + (Math.random() - 0.5) * 15), + k.color(k.Color.fromHex(Math.random() > 0.3 ? '#ff6600' : Math.random() > 0.5 ? '#ffcc00' : '#ff2d2d')), + k.opacity(0.9), + k.z(16), + ]) + const speed = 400 + Math.random() * 400 + const vy = (Math.random() - 0.5) * 100 + p.onUpdate(() => { + p.pos.x += dir * speed * k.dt() + p.pos.y += vy * k.dt() + p.opacity -= 2 * k.dt() + if (p.opacity <= 0 || Math.abs(p.pos.x - fireX) > Math.abs(def.pos.x - fireX) + 30) p.destroy() + }) + if (i % 5 === 0) { + def.opacity = 0.5 + setTimeout(() => { if (def.exists()) def.opacity = 1 }, 50) + } + await k.wait(0.02) + } + await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 20 : 8) + spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ff6600') + if (isCritical) { screenFlash('#ff6600'); sfxExplosion() } + // Defender on fire briefly + const fireTimer = setInterval(() => { + if (!def.exists()) { clearInterval(fireTimer); return } + k.add([ + k.circle(3), + k.pos(def.pos.x + (Math.random() - 0.5) * 25, def.pos.y - Math.random() * 60), + k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), + k.opacity(0.7), + k.z(11), + ]).onUpdate(function(this: any) { this.pos.y -= 80 * k.dt(); this.opacity -= 2 * k.dt(); if (this.opacity <= 0) this.destroy() }) + }, 40) + const push = dir * (isCritical ? 110 : 50) + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.6) + clearInterval(fireTimer) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // RIDDLE: question mark barrage rains from the sky + async function riddleBarrage(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + sfxZap() + await k.wait(0.2) + // Rain question marks from above + const qCount = isCritical ? 12 : 6 + for (let i = 0; i < qCount; i++) { + const qx = def.pos.x + (Math.random() - 0.5) * 100 + const qy = -20 + const q = k.add([ + k.circle(8), + k.pos(qx, qy), + k.color(k.Color.fromHex('#b83dff')), + k.opacity(0.9), + k.z(16), + ]) + // Inner dot to make it look like "?" + const dot = k.add([ + k.circle(3), + k.pos(qx, qy + 12), + k.color(k.Color.fromHex('#ffffff')), + k.opacity(0.9), + k.z(17), + ]) + k.tween(qy, def.pos.y - 20, 0.2 + Math.random() * 0.1, (v) => { q.pos.y = v; dot.pos.y = v + 12 }, k.easings.easeInQuad).then(() => { + q.destroy(); dot.destroy() + spawnSparks(qx, def.pos.y - 20, 3, '#b83dff') + sfxBonk() + k.shake(2) + }) + await k.wait(0.06) + } + await k.wait(0.15) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 15 : 7) + if (isCritical) { screenFlash('#b83dff'); sfxExplosion() } + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // HALLUCINATION CHECK: clone confusion — multiple fake attackers appear + async function cloneStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + sfxZap() + // Spawn 3-5 "ghost" clones that zip around + const cloneCount = isCritical ? 5 : 3 + const clones: any[] = [] + for (let i = 0; i < cloneCount; i++) { + const clone = k.add([ + k.rect(20, 35), + k.pos(atk.pos.x, atk.pos.y - 25), + k.color(k.Color.fromHex(theme.accent)), + k.opacity(0.3), + k.z(9), + ]) + clones.push(clone) + // Each clone zips to a random position + const rx = origDX + (Math.random() - 0.5) * 200 + const ry = GROUND_Y - 6 - Math.random() * 80 + k.tween(0, 1, 0.2, (t) => { + clone.pos.x = atk.pos.x + (rx - atk.pos.x) * t + clone.pos.y = (atk.pos.y - 25) + (ry - atk.pos.y + 25) * t + }) + } + await k.wait(0.25) + // Defender is confused — screen warps + screenFlash('#b83dff', 0.1) + await k.wait(0.15) + // Real attacker teleports and strikes + spawnSparks(atk.pos.x, atk.pos.y - 30, 6, theme.accent) + await k.tween(1, 0, 0.08, (v) => { atk.opacity = v }) + atk.pos.x = origDX - dir * 40 + await k.tween(0, 1, 0.08, (v) => { atk.opacity = v }) + atk.play('attack') + sfxPunch() + await k.wait(0.1) + // Destroy clones + clones.forEach(c => { if (c.exists()) { spawnSparks(c.pos.x, c.pos.y, 3, theme.accent); c.destroy() } }) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 16 : 7) + if (isCritical) { screenFlash('#ffffff'); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 30, 10, theme.accent) + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // TOKEN ECONOMY: coin shower — coins rain down on opponent + async function coinShower(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + sfxBoing() + await k.wait(0.15) + // Coins rain from above + const coinCount = isCritical ? 20 : 10 + for (let i = 0; i < coinCount; i++) { + const cx = def.pos.x + (Math.random() - 0.5) * 80 + const coin = k.add([ + k.circle(5), + k.pos(cx, -10), + k.color(k.Color.fromHex(Math.random() > 0.3 ? '#ffd700' : '#ffb000')), + k.opacity(1), + k.z(16), + ]) + const targetY = def.pos.y - 10 - Math.random() * 50 + k.tween(coin.pos.y, targetY, 0.15 + Math.random() * 0.1, (v) => { coin.pos.y = v }, k.easings.easeInQuad).then(() => { + coin.destroy() + spawnSparks(cx, targetY, 2, '#ffd700') + sfxBulletHit() + }) + await k.wait(0.03) + } + await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 14 : 6) + if (isCritical) { screenFlash('#ffd700'); sfxExplosion() } + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // CREATIVE WRITING: giant pen stab + ink splatter + async function penStab(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Spawn a giant pen/quill + const penX = atk.pos.x + dir * 15 + const penY = atk.pos.y - 70 + const pen = k.add([ + k.rect(6, 80), + k.pos(penX, penY), + k.color(k.Color.fromHex('#4488ff')), + k.anchor('bot'), + k.z(18), + k.rotate(dir === 1 ? -30 : 30), + ]) + // Pen tip + const tip = k.add([ + k.rect(4, 12), + k.pos(penX + dir * 5, penY - 68), + k.color(k.Color.fromHex('#333333')), + k.z(18), + ]) + atk.play('special') + sfxSpecial() + await k.wait(0.15) + // Thrust pen forward at opponent + const targetX = origDX - dir * 20 + await Promise.all([ + k.tween(pen.pos.x, targetX, 0.12, (v) => { pen.pos.x = v; tip.pos.x = v + dir * 5 }, k.easings.easeInQuad), + k.tween(pen.angle, dir === 1 ? 80 : -80, 0.12, (v) => { pen.angle = v }, k.easings.easeInQuad), + k.tween(atk.pos.x, targetX - dir * 30, 0.12, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + ]) + sfxPunch() + pen.destroy() + tip.destroy() + // INK SPLATTER! + const inkColors = ['#1a1a3a', '#2a2a5a', '#0a0a2a', '#4488ff'] + for (let i = 0; i < (isCritical ? 20 : 10); i++) { + const splat = k.add([ + k.circle(4 + Math.random() * 10), + k.pos(def.pos.x + (Math.random() - 0.5) * 60, def.pos.y - Math.random() * 70), + k.color(k.Color.fromHex(inkColors[Math.floor(Math.random() * inkColors.length)])), + k.opacity(0.8), + k.z(9), + ]) + setTimeout(() => { + k.tween(0.8, 0, 1.5, (v) => { if (splat.exists()) splat.opacity = v }).then(() => { if (splat.exists()) splat.destroy() }) + }, 800) + } + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 16 : 7) + if (isCritical) screenFlash('#4488ff') + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // MATH BLITZ: number projectiles fly at opponent + async function mathAttack(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + await k.wait(0.15) + // Fire number "projectiles" (colored circles representing digits) + const numCount = isCritical ? 10 : 5 + const colors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#39ff14', '#b83dff'] + for (let i = 0; i < numCount; i++) { + const size = 6 + Math.random() * 8 + const fromY = atk.pos.y - 30 - Math.random() * 30 + const toY = def.pos.y - 10 - Math.random() * 50 + const num = k.add([ + k.circle(size), + k.pos(atk.pos.x + dir * 25, fromY), + k.color(k.Color.fromHex(colors[i % colors.length])), + k.opacity(1), + k.z(16), + ]) + const dur = 0.1 + Math.random() * 0.05 + sfxGunshot() + k.tween(0, 1, dur, (t) => { + num.pos.x = (atk.pos.x + dir * 25) + (def.pos.x - atk.pos.x - dir * 25) * t + num.pos.y = fromY + (toY - fromY) * t + }).then(() => { + num.destroy() + spawnSparks(def.pos.x + (Math.random() - 0.5) * 20, toY, 3, colors[i % colors.length]) + }) + await k.wait(0.04) + } + await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 15 : 7) + if (isCritical) { screenFlash('#ffe14d'); sfxExplosion() } + const push = dir * (isCritical ? 95 : 40) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // TRAP CARD: card shuriken + trap springs + async function trapCardAttack(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + await k.wait(0.1) + // Throw spinning cards + const cardCount = isCritical ? 5 : 3 + for (let i = 0; i < cardCount; i++) { + const card = k.add([ + k.rect(12, 18), + k.pos(atk.pos.x + dir * 20, atk.pos.y - 40 + (Math.random() - 0.5) * 20), + k.color(k.Color.fromHex(i === 0 ? '#ff2d7b' : '#ffffff')), + k.anchor('center'), + k.z(16), + k.rotate(0), + ]) + const fromX = card.pos.x + const fromY = card.pos.y + const toX = def.pos.x + const toY = def.pos.y - 30 + (Math.random() - 0.5) * 30 + sfxGunshot() + k.tween(0, 1, 0.12, (t) => { + card.pos.x = fromX + (toX - fromX) * t + card.pos.y = fromY + (toY - fromY) * t + card.angle = t * 720 // Spin + }).then(() => { + card.destroy() + spawnSparks(toX, toY, 5, '#ff2d7b') + sfxBulletHit() + }) + await k.wait(0.08) + } + await k.wait(0.1) + // TRAP SPRINGS! A bear trap appears under defender + const trap = k.add([ + k.rect(30, 8), + k.pos(def.pos.x - 15, GROUND_Y - 8), + k.color(k.Color.fromHex('#888888')), + k.z(8), + ]) + const jaw1 = k.add([ + k.rect(30, 4), + k.pos(def.pos.x - 15, GROUND_Y - 16), + k.color(k.Color.fromHex('#aaaaaa')), + k.z(8), + ]) + sfxBonk() + k.shake(3) + await k.wait(0.05) + // Snap! Jaws close + k.tween(jaw1.pos.y, GROUND_Y - 10, 0.05, (v) => { jaw1.pos.y = v }) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 18 : 8) + if (isCritical) { screenFlash('#ff2d7b'); sfxExplosion() } + spawnSparks(def.pos.x, GROUND_Y - 10, 8, '#ff2d7b') + await k.wait(0.3) + trap.destroy() + jaw1.destroy() + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // SPEED BLITZ: afterimage dash — leaves ghost copies behind + async function afterimageDash(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + sfxSpecial() + const contactX = origDX - dir * 40 + // Leave afterimages as we dash + const ghostCount = isCritical ? 6 : 4 + const startX = atk.pos.x + for (let i = 0; i < ghostCount; i++) { + const ghost = k.add([ + k.rect(20, 35), + k.pos(atk.pos.x, atk.pos.y - 25), + k.color(k.Color.fromHex(theme.accent)), + k.opacity(0.4), + k.z(9), + ]) + setTimeout(() => { k.tween(0.4, 0, 0.3, (v) => { if (ghost.exists()) ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() }) }, 50) + // Move attacker a step forward + const stepX = startX + ((contactX - startX) / ghostCount) * (i + 1) + await k.tween(atk.pos.x, stepX, 0.03, (v) => { atk.pos.x = v }) + } + atk.play('attack') + sfxPunch() + await k.wait(0.08) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 16 : 7) + // Speed lines + for (let i = 0; i < 5; i++) { + const lineY = GROUND_Y - 20 - Math.random() * 100 + const line = k.add([ + k.rect(W * 0.4, 2), + k.pos(dir === 1 ? origAX : origDX, lineY), + k.color(k.Color.fromHex(theme.accent)), + k.opacity(0.5), + k.z(12), + ]) + k.tween(0.5, 0, 0.2, (v) => { if (line.exists()) line.opacity = v }).then(() => { if (line.exists()) line.destroy() }) + } + if (isCritical) { screenFlash('#ffffff'); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 30, 12, theme.accent) + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // INVISIBLE SPEED: so fast you can barely see — just blur lines and impact + async function lightningRush(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + sfxSpecial() + // Attacker vanishes + atk.opacity = 0 + // Blur lines everywhere + for (let i = 0; i < 12; i++) { + const lineY = GROUND_Y - 10 - Math.random() * 120 + const line = k.add([ + k.rect(W, 2 + Math.random() * 2), + k.pos(0, lineY), + k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ffffff' : theme.accent)), + k.opacity(0.6), + k.z(25), + ]) + k.tween(0.6, 0, 0.15, (v) => { if (line.exists()) line.opacity = v }).then(() => { if (line.exists()) line.destroy() }) + } + // Multiple instant impacts across the screen (so fast, hits happen everywhere) + const hitCount = isCritical ? 8 : 5 + for (let i = 0; i < hitCount; i++) { + const hx = origDX + (Math.random() - 0.5) * 100 + const hy = GROUND_Y - 20 - Math.random() * 80 + spawnSparks(hx, hy, 5, '#ffffff') + sfxPunch() + k.shake(3) + // Brief flash of attacker at hit position + atk.pos.x = hx - dir * 30 + atk.opacity = 0.3 + await k.wait(0.02) + atk.opacity = 0 + await k.wait(0.01) + } + // Final massive hit + screenFlash('#ffffff', 0.15) + atk.pos.x = origDX - dir * 40 + atk.opacity = 1 + atk.play('attack') + sfxCritical() + await k.wait(0.05) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 25 : 12) + spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffffff') + if (isCritical) sfxExplosion() + const push = dir * (isCritical ? 140 : 70) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // ZOOM RUSH: fly toward camera (scale huge) then slam back into opponent + async function zoomRush(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const origAY = atk.pos.y + const origScaleX = atk.scale.x + const origScaleY = atk.scale.y + const origDefScaleX = def.scale.x + const origDefScaleY = def.scale.y + sfxZoomWhoosh() + // Randomize zoom level for variety (3x to 7x) + const zoomScale = isCritical ? 5 + Math.random() * 2 : 3 + Math.random() * 2 + const zoomDur = 0.2 + Math.random() * 0.1 + // Attacker rushes toward camera — scale up dramatically + // Defender shrinks to background (perspective effect) + await Promise.all([ + k.tween(Math.abs(origScaleX), zoomScale, zoomDur, (v) => { + atk.scale.x = origScaleX > 0 ? v : -v + atk.scale.y = v + }, k.easings.easeInQuad), + k.tween(Math.abs(origDefScaleX), Math.abs(origDefScaleX) * 0.4, zoomDur, (v) => { + def.scale.x = origDefScaleX > 0 ? v : -v + def.scale.y = v + }, k.easings.easeInQuad), + k.tween(atk.pos.x, W / 2, zoomDur, (v) => { atk.pos.x = v }, k.easings.easeOutQuad), + k.tween(atk.pos.y, H * 0.6, zoomDur, (v) => { atk.pos.y = v }, k.easings.easeOutQuad), + k.tween(1, 0.3, zoomDur, (v) => { atk.opacity = Math.max(0.3, v) }), + ]) + // Brief dramatic pause at huge size — screen darkens + grotesque close-up + screenFlash('#000000', 0.15) + if (zoomScale >= 4) spawnGrotesqueDetails(atk, zoomScale * 0.4) + k.shake(5) + await k.wait(0.1 + Math.random() * 0.08) + destroyGrotesqueDetails() + // Zoom back and SLAM into opponent (defender grows back) + sfxZoomWhoosh() + sfxSpecial() + await Promise.all([ + k.tween(atk.scale.y, Math.abs(origScaleY), 0.15, (v) => { + atk.scale.x = origScaleX > 0 ? v : -v + atk.scale.y = v + }, k.easings.easeInQuad), + k.tween(Math.abs(def.scale.y), Math.abs(origDefScaleY), 0.15, (v) => { + def.scale.x = origDefScaleX > 0 ? v : -v + def.scale.y = v + }, k.easings.easeOutQuad), + k.tween(atk.pos.x, origDX - dir * 40, 0.15, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(atk.pos.y, origAY, 0.15, (v) => { atk.pos.y = v }, k.easings.easeInQuad), + k.tween(0.3, 1, 0.15, (v) => { atk.opacity = v }), + ]) + // IMPACT — rapid punches on contact + atk.play('attack') + const punchCount = isCritical ? 8 : 5 + for (let i = 0; i < punchCount; i++) { + sfxRapidPunch() + def.play(i % 2 === 0 ? 'hit' : 'knockback') + spawnSparks(def.pos.x + (Math.random() - 0.5) * 40, def.pos.y - 10 - Math.random() * 50, 4, i % 2 === 0 ? '#ff2d7b' : '#ffcc00') + k.shake(2 + i) + def.pos.x += dir * 4 + atk.play(i % 2 === 0 ? 'attack' : 'kick') + await k.wait(0.04) + } + // Final haymaker + sfxCritical() + k.shake(isCritical ? 25 : 15) + screenFlash(isCritical ? '#ffffff' : '#ff2d7b', 0.2) + spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffffff') + spawnShockwave(def.pos.x, GROUND_Y, '#ff2d7b') + if (isCritical) sfxExplosion() + const push = dir * (isCritical ? 150 : 80) + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + // Return — restore both scales + atk.scale.x = origScaleX + atk.scale.y = origScaleY + def.scale.x = origDefScaleX + def.scale.y = origDefScaleY + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // RAPID FLURRY: DBZ-style machine gun punches — so fast it's a blur + async function rapidFlurry(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 40 + // Sprint to contact + sfxSpecial() + await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + // UNLEASH — rapid alternating punches + const hitCount = isCritical ? 14 : 8 + const colors = ['#ff2d7b', '#ffcc00', '#00f0ff', '#ff6600', '#ffffff'] + // Speed lines during flurry + for (let i = 0; i < 6; i++) { + const lineY = GROUND_Y - 10 - Math.random() * 100 + const line = k.add([ + k.rect(W * 0.5, 2), + k.pos(dir === 1 ? origAX - 50 : origDX + 50, lineY), + k.color(k.Color.fromHex(colors[i % colors.length])), + k.opacity(0.4), + k.z(12), + ]) + k.tween(0.4, 0, 0.5, (v) => { if (line.exists()) line.opacity = v }).then(() => { if (line.exists()) line.destroy() }) + } + for (let i = 0; i < hitCount; i++) { + const anim = ['attack', 'kick', 'attack', 'special'][i % 4] + atk.play(anim) + sfxRapidPunch() + // Sparks spray in alternating directions + const sparkX = def.pos.x + (i % 2 === 0 ? -1 : 1) * (10 + Math.random() * 20) + const sparkY = def.pos.y - 10 - Math.random() * 50 + spawnSparks(sparkX, sparkY, 3, colors[i % colors.length]) + def.play('hit') + k.shake(2) + // Jitter defender rapidly side to side + def.pos.x += (i % 2 === 0 ? dir : -dir) * 6 + def.pos.y += (i % 2 === 0 ? -3 : 3) + await k.wait(0.035) + } + // Pause — "Is it over?" + await k.wait(0.08) + // ONE MORE — the big finish + atk.play('special') + sfxCritical() + sfxPunch() + await k.wait(0.06) + def.play('knockback') + k.shake(isCritical ? 22 : 12) + screenFlash(isCritical ? '#ffffff' : '#ffcc00', 0.15) + spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 12, '#ffcc00') + spawnShockwave(def.pos.x, GROUND_Y, '#ffcc00') + if (isCritical) sfxExplosion() + const push = dir * (isCritical ? 140 : 70) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + // Reset defender Y + k.tween(def.pos.y, GROUND_Y - 6, 0.2, (v) => { def.pos.y = v }) + await k.wait(0.35) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // SWORD SLASH: giant energy sword swing with arc trail + async function swordSlash(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 50 + await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('special') + sfxSpecial() + // Draw sword arc (series of rects forming a crescent) + const arcParts: any[] = [] + for (let i = 0; i < 8; i++) { + const angle = (i / 8) * Math.PI - Math.PI / 4 + const ax = contactX + dir * 20 + Math.cos(angle) * 60 + const ay = atk.pos.y - 40 + Math.sin(angle) * 60 + const part = k.add([ + k.rect(25, 3), k.pos(ax, ay), k.color(k.Color.fromHex(isCritical ? '#00f0ff' : '#aaddff')), + k.opacity(0.9), k.z(18), k.rotate(angle * 180 / Math.PI + 90), + ]) + arcParts.push(part) + } + await k.wait(0.08) + // Slash hits + arcParts.forEach(p => { k.tween(0.9, 0, 0.2, (v) => { if (p.exists()) p.opacity = v }).then(() => { if (p.exists()) p.destroy() }) }) + sfxKick() + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 18 : 8) + spawnSparks(def.pos.x, def.pos.y - 30, 15, '#aaddff') + if (isCritical) { screenFlash('#00f0ff'); sfxCritical() } + const push = dir * (isCritical ? 110 : 50) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // HAMMER SMASH: pull out giant hammer, overhead slam with ground crack + async function hammerSmash(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 40 + await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + // Giant hammer + const handle = k.add([k.rect(6, 70), k.pos(atk.pos.x + dir * 15, atk.pos.y - 90), k.color(k.Color.fromHex('#8B4513')), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -60 : 60)]) + const head = k.add([k.rect(30, 20), k.pos(handle.pos.x + dir * 10, handle.pos.y - 60), k.color(k.Color.fromHex('#888888')), k.z(18)]) + atk.play('special') + sfxSpecial() + // Swing hammer down + await k.tween(dir === 1 ? -60 : 60, dir === 1 ? 70 : -70, 0.15, (v) => { handle.angle = v }, k.easings.easeInQuad) + handle.destroy(); head.destroy() + sfxExplosion() + // Ground crack effect + for (let i = 0; i < 6; i++) { + const crack = k.add([k.rect(2, 15 + Math.random() * 20), k.pos(def.pos.x + (i - 3) * 12, GROUND_Y - 5), k.color(k.Color.fromHex('#ff6600')), k.opacity(0.8), k.z(4)]) + setTimeout(() => { k.tween(0.8, 0, 0.6, (v) => { if (crack.exists()) crack.opacity = v }).then(() => { if (crack.exists()) crack.destroy() }) }, 400) + } + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 25 : 14) + spawnShockwave(def.pos.x, GROUND_Y, '#ff6600') + spawnSparks(def.pos.x, def.pos.y - 20, 18, '#ff6600') + if (isCritical) screenFlash('#ff6600') + const push = dir * (isCritical ? 120 : 55) + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // LASER BEAM: charge up then fire continuous beam across screen + async function laserBeam(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + // Charge up (growing ball of energy) + const chargeX = atk.pos.x + dir * 25 + const chargeY = atk.pos.y - 40 + const charge = k.add([k.circle(3), k.pos(chargeX, chargeY), k.color(k.Color.fromHex('#ff2d2d')), k.opacity(0.9), k.z(18)]) + await k.tween(3, isCritical ? 18 : 12, 0.3, (v) => { charge.radius = v }, k.easings.easeOutQuad) + sfxZap() + // FIRE BEAM (long rect that stretches across) + charge.destroy() + const beamLen = Math.abs(def.pos.x - chargeX) + 30 + const beam = k.add([k.rect(beamLen, isCritical ? 12 : 7), k.pos(chargeX, chargeY), k.color(k.Color.fromHex(isCritical ? '#ff2d2d' : '#ff6600')), k.opacity(0.9), k.z(18)]) + // Core beam (brighter, thinner) + const core = k.add([k.rect(beamLen, isCritical ? 5 : 3), k.pos(chargeX, chargeY + 2), k.color(k.Color.fromHex('#ffffff')), k.opacity(0.8), k.z(19)]) + k.shake(isCritical ? 12 : 6) + screenFlash(isCritical ? '#ff2d2d' : '#ff6600', 0.15) + def.play(isCritical ? 'knockback' : 'hit') + sfxCritical() + spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ff2d2d') + await k.wait(0.25) + // Beam fades + k.tween(0.9, 0, 0.15, (v) => { beam.opacity = v; core.opacity = v * 0.8 }).then(() => { beam.destroy(); core.destroy() }) + const push = dir * (isCritical ? 130 : 60) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // ROCKET LAUNCHER: fire a big slow rocket that explodes on impact + async function rocketLauncher(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + await k.wait(0.15) + // Rocket body + const rocketX = atk.pos.x + dir * 30 + const rocketY = atk.pos.y - 35 + const rocket = k.add([k.rect(20, 8), k.pos(rocketX, rocketY), k.color(k.Color.fromHex('#888888')), k.z(17), k.rotate(dir === 1 ? 0 : 180)]) + const nose = k.add([k.rect(6, 6), k.pos(rocketX + dir * 12, rocketY + 1), k.color(k.Color.fromHex('#ff2d2d')), k.z(17)]) + sfxJetpack() + // Trail exhaust as rocket flies + const exhaustInt = setInterval(() => { + const p = k.add([k.circle(3 + Math.random() * 4), k.pos(rocket.pos.x - dir * 12, rocket.pos.y + (Math.random() - 0.5) * 8), k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(16)]) + p.onUpdate(() => { p.pos.x -= dir * 100 * k.dt(); p.opacity -= 3 * k.dt(); if (p.opacity <= 0) p.destroy() }) + }, 30) + // Fly rocket to target + const dur = 0.3 + await k.tween(0, 1, dur, (t) => { rocket.pos.x = rocketX + (def.pos.x - rocketX) * t; nose.pos.x = rocket.pos.x + dir * 12; rocket.pos.y = rocketY + Math.sin(t * Math.PI * 3) * 8; nose.pos.y = rocket.pos.y + 1 }, k.easings.linear) + clearInterval(exhaustInt) + rocket.destroy(); nose.destroy() + // EXPLOSION! + sfxExplosion() + k.shake(isCritical ? 30 : 18) + screenFlash('#ff6600', 0.2) + // Explosion rings + for (let i = 0; i < 3; i++) { + setTimeout(() => spawnShockwave(def.pos.x, def.pos.y - 20, ['#ff6600', '#ff2d2d', '#ffcc00'][i]), i * 60) + } + spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ff6600') + def.play(isCritical ? 'knockback' : 'hit') + const push = dir * (isCritical ? 160 : 80) + const origDY = def.pos.y + await Promise.all([ + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.y, origDY - (isCritical ? 120 : 60), 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(def.pos.y, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad) + ), + ]) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // BOMB THROW: lob an arcing bomb that bounces once then detonates + async function bombThrow(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('attack') + sfxSpecial() + const bombX = atk.pos.x + dir * 20 + const bombY = atk.pos.y - 50 + const bomb = k.add([k.circle(8), k.pos(bombX, bombY), k.color(k.Color.fromHex('#333333')), k.z(17)]) + const fuse = k.add([k.rect(2, 8), k.pos(bombX, bombY - 8), k.color(k.Color.fromHex('#ff6600')), k.z(17)]) + // Arc to opponent + const midX = (bombX + def.pos.x) / 2 + await k.tween(0, 1, 0.35, (t) => { + bomb.pos.x = bombX + (def.pos.x - bombX) * t + bomb.pos.y = bombY + (GROUND_Y - 10 - bombY) * t - Math.sin(t * Math.PI) * 120 + fuse.pos.x = bomb.pos.x; fuse.pos.y = bomb.pos.y - 8 + }, k.easings.linear) + fuse.destroy() + // Bounce + sfxBonk() + await k.tween(bomb.pos.y, bomb.pos.y - 30, 0.08, (v) => { bomb.pos.y = v }, k.easings.easeOutQuad) + await k.tween(bomb.pos.y, GROUND_Y - 10, 0.08, (v) => { bomb.pos.y = v }, k.easings.easeInQuad) + // BOOM + bomb.destroy() + sfxExplosion() + k.shake(isCritical ? 28 : 15) + screenFlash(isCritical ? '#ff2d2d' : '#ff6600', 0.2) + spawnShockwave(def.pos.x, GROUND_Y, '#ff6600') + spawnSparks(def.pos.x, def.pos.y - 20, 20, '#ffcc00') + def.play(isCritical ? 'knockback' : 'hit') + const push = dir * (isCritical ? 120 : 55) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // MINIGUN SPRAY: sustained automatic fire with shell casings + async function minigunSpray(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + await k.wait(0.1) + // Spin-up sound + sfxSpecial() + const bulletCount = isCritical ? 16 : 10 + const gunX = atk.pos.x + dir * 25 + const gunY = atk.pos.y - 38 + for (let i = 0; i < bulletCount; i++) { + sfxGunshot() + // Bullet + const spread = (Math.random() - 0.5) * 50 + spawnBullet(gunX, gunY, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread) + // Shell casing ejection (tiny gold rect flying up) + const casing = k.add([k.rect(3, 2), k.pos(gunX - dir * 5, gunY - 5), k.color(k.Color.fromHex('#ffd700')), k.opacity(0.8), k.z(16)]) + const casVx = -dir * (100 + Math.random() * 100) + const casVy = -200 - Math.random() * 100 + casing.onUpdate(() => { casing.pos.x += casVx * k.dt(); casing.pos.y += casVy * k.dt() + 500 * k.dt() * k.dt(); casing.opacity -= 1.5 * k.dt(); if (casing.opacity <= 0) casing.destroy() }) + // Recoil shake + atk.pos.x -= dir * 2 + await k.wait(0.01) + atk.pos.x += dir * 2 + // Flash defender + if (i % 2 === 0) { def.opacity = 0.3; await k.wait(0.01); def.opacity = 1 } + sfxBulletHit() + await k.wait(0.04) + } + spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 8 : 5) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 18 : 10) + if (isCritical) { screenFlash('#ffee00'); sfxExplosion() } + const push = dir * (isCritical ? 120 : 55) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // SNIPER SHOT: long pause, laser sight, then one devastating hit + async function sniperShot(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Back up to wall + const wallX = dir === 1 ? 30 : W - 30 + await k.tween(atk.pos.x, wallX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('special') + // Laser sight line + const laserY = atk.pos.y - 38 + const laser = k.add([k.rect(Math.abs(def.pos.x - wallX), 1), k.pos(Math.min(wallX, def.pos.x), laserY), k.color(k.Color.fromHex('#ff0000')), k.opacity(0.5), k.z(15)]) + // Wobble the laser for "aiming" + for (let i = 0; i < 8; i++) { + laser.pos.y = laserY + (Math.random() - 0.5) * 15 + await k.wait(0.06) + } + laser.pos.y = def.pos.y - 35 + await k.wait(0.15) + // FIRE + laser.destroy() + sfxGunshot() + sfxCritical() + screenFlash('#ffffff', 0.1) + // Tracer (bright line from attacker to defender) + const tracer = k.add([k.rect(Math.abs(def.pos.x - wallX), 3), k.pos(Math.min(wallX, def.pos.x), def.pos.y - 35), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(18)]) + k.tween(0.9, 0, 0.1, (v) => { tracer.opacity = v }).then(() => tracer.destroy()) + // Impact + def.play('knockback') + k.shake(isCritical ? 22 : 12) + spawnSparks(def.pos.x, def.pos.y - 35, 18, '#ffee00') + if (isCritical) { sfxExplosion(); screenFlash('#ff2d2d', 0.15) } + const push = dir * (isCritical ? 130 : 65) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // WHIP CRACK: ranged whip attack with crack sound + async function whipCrack(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('attack') + sfxSpecial() + await k.wait(0.1) + // Whip extends from attacker to defender (series of small segments) + const segments: any[] = [] + const fromX = atk.pos.x + dir * 20 + const fromY = atk.pos.y - 40 + const toX = def.pos.x + const toY = def.pos.y - 30 + const segCount = 12 + for (let i = 0; i < segCount; i++) { + const t = i / segCount + const sx = fromX + (toX - fromX) * t + const sy = fromY + (toY - fromY) * t + Math.sin(t * Math.PI * 3) * 20 + const seg = k.add([k.rect(6, 3), k.pos(sx, sy), k.color(k.Color.fromHex('#8B4513')), k.opacity(0), k.z(17)]) + segments.push(seg) + } + // Animate whip extending (each segment appears in sequence) + for (let i = 0; i < segments.length; i++) { + segments[i].opacity = 0.9 + await k.wait(0.01) + } + // CRACK! + sfxPunch() + sfxBonk() + spawnSparks(toX, toY, 10, '#ffcc00') + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 14 : 7) + if (isCritical) screenFlash('#ffcc00') + // Whip retracts + for (let i = segments.length - 1; i >= 0; i--) { + if (segments[i].exists()) segments[i].destroy() + await k.wait(0.008) + } + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // KATANA COMBO: triple slash with cross patterns + async function katanaCombo(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 45 + sfxSpecial() + await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + const slashCount = isCritical ? 5 : 3 + for (let i = 0; i < slashCount; i++) { + atk.play(i % 2 === 0 ? 'attack' : 'kick') + sfxPunch() + // Slash trail (diagonal line) + const angle = [45, -45, 0, 135, -135][i % 5] + const slash = k.add([k.rect(50, 3), k.pos(def.pos.x, def.pos.y - 30), k.color(k.Color.fromHex('#aaddff')), k.opacity(0.9), k.z(18), k.rotate(angle), k.anchor('center')]) + k.tween(0.9, 0, 0.15, (v) => { if (slash.exists()) slash.opacity = v }).then(() => { if (slash.exists()) slash.destroy() }) + def.play('hit') + k.shake(4 + i * 2) + spawnSparks(def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 - Math.random() * 30, 5, '#aaddff') + await k.wait(0.1) + } + // Final slash — big X mark + if (isCritical) { + const s1 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(k.Color.fromHex('#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(45), k.anchor('center')]) + const s2 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(k.Color.fromHex('#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(-45), k.anchor('center')]) + sfxCritical() + screenFlash('#00f0ff') + k.shake(20) + await k.wait(0.2) + k.tween(0.9, 0, 0.3, (v) => { if (s1.exists()) s1.opacity = v; if (s2.exists()) s2.opacity = v }).then(() => { if (s1.exists()) s1.destroy(); if (s2.exists()) s2.destroy() }) + } + def.play('knockback') + const push = dir * (isCritical ? 110 : 50) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // CHAINSAW: charge with buzzing chainsaw, multi-hit grind + async function chainsawRev(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 35 + atk.play('special') + // Chainsaw buzzing (visual: vibrating rect) + const saw = k.add([k.rect(40, 8), k.pos(atk.pos.x + dir * 25, atk.pos.y - 35), k.color(k.Color.fromHex('#888888')), k.z(18)]) + const blade = k.add([k.rect(35, 3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 32), k.color(k.Color.fromHex('#ffcc00')), k.z(19)]) + sfxSpecial() + // Rev up (shake the saw) + for (let i = 0; i < 8; i++) { + saw.pos.y += (Math.random() - 0.5) * 4 + blade.pos.y = saw.pos.y + 3 + await k.wait(0.03) + } + // Charge forward + await Promise.all([ + k.tween(atk.pos.x, contactX, 0.15, (v) => { atk.pos.x = v; saw.pos.x = v + dir * 25; blade.pos.x = v + dir * 30 }, k.easings.easeInQuad), + ]) + // GRIND — multi-hit with sparks + const hits = isCritical ? 6 : 4 + for (let i = 0; i < hits; i++) { + sfxPunch() + spawnSparks(def.pos.x - dir * 10, def.pos.y - 20 - Math.random() * 30, 5, '#ffcc00') + def.play('hit') + k.shake(3) + saw.pos.y += (Math.random() - 0.5) * 6 + blade.pos.y = saw.pos.y + 3 + await k.wait(0.05) + } + saw.destroy(); blade.destroy() + if (isCritical) { sfxCritical(); screenFlash('#ff6600') } + def.play('knockback') + k.shake(isCritical ? 18 : 10) + spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ff6600') + const push = dir * (isCritical ? 110 : 50) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // MOTORBIKE CHARGE: ride a motorbike across the screen and slam into defender + async function motorbikeCharge(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Back up to edge + const startX = dir === 1 ? -30 : W + 30 + atk.opacity = 0 + atk.pos.x = startX + // Build the bike: body + wheels + const bikeY = GROUND_Y - 15 + const bike = k.add([k.rect(50, 18), k.pos(startX, bikeY), k.color(k.Color.fromHex(isCritical ? '#ff2d2d' : '#4488ff')), k.z(16)]) + const wheelF = k.add([k.circle(8), k.pos(startX + dir * 18, bikeY + 12), k.color(k.Color.fromHex('#333333')), k.z(16)]) + const wheelR = k.add([k.circle(8), k.pos(startX - dir * 16, bikeY + 12), k.color(k.Color.fromHex('#333333')), k.z(16)]) + sfxJetpack() + // Tire marks + exhaust as it zooms across + const exh = setInterval(() => { + const p = k.add([k.circle(3), k.pos(bike.pos.x - dir * 25, bikeY + 5), k.color(k.Color.fromHex('#888888')), k.opacity(0.5), k.z(4)]) + p.onUpdate(() => { p.opacity -= 2 * k.dt(); if (p.opacity <= 0) p.destroy() }) + }, 30) + // ZOOM across screen + const targetX = origDX + dir * 30 + await k.tween(startX, targetX, 0.3, (v) => { + bike.pos.x = v; wheelF.pos.x = v + dir * 18; wheelR.pos.x = v - dir * 16 + }, k.easings.easeInQuad) + clearInterval(exh) + // IMPACT + sfxExplosion() + sfxCritical() + bike.destroy(); wheelF.destroy(); wheelR.destroy() + k.shake(isCritical ? 25 : 14) + screenFlash(isCritical ? '#ff2d2d' : '#4488ff', 0.2) + spawnSparks(def.pos.x, def.pos.y - 20, 20, '#ff6600') + spawnShockwave(def.pos.x, GROUND_Y, '#ff6600') + def.play('knockback') + const push = dir * (isCritical ? 160 : 80) + const origDY = def.pos.y + await Promise.all([ + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.y, origDY - 100, 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(def.pos.y, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad) + ), + ]) + // Attacker reappears at home + atk.pos.x = origAX + atk.opacity = 1 + atk.play('idle') + await k.wait(0.2) + await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // CAR SMASH: drive a car from off-screen, defender gets sent flying + async function carSmash(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.opacity = 0 + const carY = GROUND_Y - 20 + const startX = dir === 1 ? -60 : W + 60 + // Car body + const car = k.add([k.rect(70, 25), k.pos(startX, carY), k.color(k.Color.fromHex(isCritical ? '#ffd700' : '#ff6600')), k.z(16)]) + // Roof + const roof = k.add([k.rect(35, 15), k.pos(startX + dir * 8, carY - 15), k.color(k.Color.fromHex(isCritical ? '#ccaa00' : '#cc5500')), k.z(16)]) + // Wheels + const w1 = k.add([k.circle(8), k.pos(startX + dir * 22, carY + 18), k.color(k.Color.fromHex('#222222')), k.z(16)]) + const w2 = k.add([k.circle(8), k.pos(startX - dir * 22, carY + 18), k.color(k.Color.fromHex('#222222')), k.z(16)]) + // Headlight + const hl = k.add([k.rect(4, 6), k.pos(startX + dir * 35, carY + 3), k.color(k.Color.fromHex('#ffee00')), k.z(17)]) + sfxJetpack() + sfxSpecial() + // DRIVE ACROSS + await k.tween(startX, def.pos.x, 0.35, (v) => { + car.pos.x = v; roof.pos.x = v + dir * 8; w1.pos.x = v + dir * 22; w2.pos.x = v - dir * 22; hl.pos.x = v + dir * 35 + }, k.easings.easeInQuad) + // HIT! + car.destroy(); roof.destroy(); w1.destroy(); w2.destroy(); hl.destroy() + sfxExplosion() + k.shake(isCritical ? 30 : 18) + screenFlash('#ff6600', 0.2) + spawnSparks(def.pos.x, def.pos.y - 20, 25, '#ffcc00') + spawnShockwave(def.pos.x, GROUND_Y, '#ff6600') + def.play('knockback') + const push = dir * (isCritical ? 180 : 90) + const origDY = def.pos.y + await Promise.all([ + k.tween(def.pos.x, origDX + push, 0.4, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.y, origDY - 150, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(def.pos.y, origDY, 0.25, (v) => { def.pos.y = v }, k.easings.easeInQuad) + ), + ]) + sfxBoing() + spawnShockwave(def.pos.x, GROUND_Y, '#ffcc00') + atk.pos.x = origAX + atk.opacity = 1 + atk.play('idle') + await k.wait(0.2) + await k.tween(def.pos.x, origDX, 0.4, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // BOAT CANNON: a boat slides across the ground and fires cannons + async function boatCannon(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + // Build boat + const boatX = atk.pos.x + const boatY = GROUND_Y - 10 + const hull = k.add([k.rect(60, 20), k.pos(boatX, boatY), k.color(k.Color.fromHex('#8B4513')), k.z(8)]) + const mast = k.add([k.rect(3, 40), k.pos(boatX + 10, boatY - 40), k.color(k.Color.fromHex('#8B4513')), k.z(8)]) + const sail = k.add([k.rect(25, 30), k.pos(boatX + 15, boatY - 45), k.color(k.Color.fromHex('#ffffff')), k.opacity(0.8), k.z(8)]) + // Slide boat forward + sfxJetpack() + const moveX = (origAX + origDX) / 2 + await k.tween(boatX, moveX, 0.3, (v) => { hull.pos.x = v; mast.pos.x = v + 10; sail.pos.x = v + 15 }, k.easings.easeOutQuad) + // Fire cannons! + const shotCount = isCritical ? 5 : 3 + for (let i = 0; i < shotCount; i++) { + sfxGunshot() + // Cannonball + const ballY = boatY - 5 + (Math.random() - 0.5) * 10 + await spawnProjectile(moveX + dir * 30, ballY, def.pos.x, def.pos.y - 20 + (Math.random() - 0.5) * 30, '#333333', 7) + sfxBulletHit() + def.play('hit') + k.shake(5) + spawnSparks(def.pos.x, def.pos.y - 20, 5, '#ff6600') + await k.wait(0.08) + } + // Final broadside + if (isCritical) { + sfxExplosion() + screenFlash('#ff6600') + k.shake(20) + spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ff6600') + } + def.play(isCritical ? 'knockback' : 'hit') + hull.destroy(); mast.destroy(); sail.destroy() + const push = dir * (isCritical ? 110 : 50) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // ANVIL DROP: classic cartoon — anvil falls from sky onto opponent + async function anvilDrop(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + await k.wait(0.15) + // Shadow on ground growing + const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(k.Color.fromHex('#000000')), k.opacity(0.3), k.z(4)]) + k.tween(5, 25, 0.3, (v) => { shadow.radius = v }) + // Anvil falls from sky + const anvil = k.add([k.rect(35, 25), k.pos(def.pos.x - 17, -30), k.color(k.Color.fromHex('#555555')), k.opacity(0.9), k.z(18)]) + const base = k.add([k.rect(45, 8), k.pos(def.pos.x - 22, -8), k.color(k.Color.fromHex('#444444')), k.opacity(0.9), k.z(18)]) + sfxSlideDown() + await k.tween(-30, def.pos.y - 50, 0.25, (v) => { anvil.pos.y = v; base.pos.y = v + 22 }, k.easings.easeInQuad) + // BONK! + sfxBonk() + sfxExplosion() + k.shake(isCritical ? 25 : 15) + screenFlash(isCritical ? '#ffffff' : '#888888', 0.15) + spawnSparks(def.pos.x, def.pos.y - 30, 15, '#888888') + spawnShockwave(def.pos.x, GROUND_Y, '#888888') + shadow.destroy() + def.play(isCritical ? 'knockback' : 'hit') + // Anvil bounces + await k.tween(anvil.pos.y, anvil.pos.y - 40, 0.1, (v) => { anvil.pos.y = v; base.pos.y = v + 22 }, k.easings.easeOutQuad) + await k.tween(anvil.pos.y, anvil.pos.y + 40, 0.1, (v) => { anvil.pos.y = v; base.pos.y = v + 22 }, k.easings.easeInQuad) + sfxBonk() + await k.wait(0.15) + k.tween(0.9, 0, 0.3, (v) => { anvil.opacity = v; base.opacity = v }).then(() => { anvil.destroy(); base.destroy() }) + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // POCKET CANNON: reach into pocket, pull out comically oversized gun, fire + async function pocketCannon(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // "Reach into pocket" — arm down motion + atk.play('special') + await k.wait(0.12) + + // Gun appears — comically oversized + const gunLen = isCritical ? 90 : 65 + const gunH = isCritical ? 22 : 16 + const gx = atk.pos.x + dir * 20 + const gy = atk.pos.y - 25 + // Barrel + const barrel = k.add([ + k.rect(gunLen, gunH), k.pos(gx, gy), + k.color(k.Color.fromHex('#333333')), k.opacity(1), k.z(16), k.scale(1), + ]) + // Handle + const handle = k.add([ + k.rect(12, 20), k.pos(gx - dir * 5, gy + gunH / 2), + k.color(k.Color.fromHex('#555555')), k.opacity(1), k.z(15), k.scale(1), + ]) + // Scope on top + const scope = k.add([ + k.circle(5), k.pos(gx + dir * gunLen * 0.6, gy - 6), + k.color(k.Color.fromHex('#880000')), k.opacity(0.9), k.z(17), k.scale(1), + ]) + + // Gun grows in from small — comedy "pulling from tiny pocket" effect + barrel.scale = k.vec2(0.1, 0.1) + handle.scale = k.vec2(0.1, 0.1) + scope.scale = k.vec2(0.1, 0.1) + await Promise.all([ + k.tween(0.1, 1, 0.15, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack), + k.tween(0.1, 1, 0.15, (v) => { handle.scale = k.vec2(v, v) }, k.easings.easeOutBack), + k.tween(0.1, 1, 0.15, (v) => { scope.scale = k.vec2(v, v) }, k.easings.easeOutBack), + ]) + sfxBoing() + k.shake(3) + await k.wait(0.08) + + // AIM — brief wobble + const wobbleTime = 0.12 + await k.tween(0, wobbleTime, wobbleTime, (t) => { + const w = Math.sin(t * 40) * 3 + barrel.pos.y = gy + w + handle.pos.y = gy + gunH / 2 + w + scope.pos.y = gy - 6 + w + }) + + // FIRE! massive shot + const shotCount = isCritical ? 4 : 2 + for (let s = 0; s < shotCount; s++) { + sfxGunshot() + sfxExplosion() + k.shake(isCritical ? 15 : 10) + // Muzzle flash + const flash = k.add([ + k.circle(12 + Math.random() * 8), + k.pos(gx + dir * gunLen, gy), + k.color(k.Color.fromHex('#ffee00')), + k.opacity(0.9), k.z(18), + ]) + setTimeout(() => { if (flash.exists()) flash.destroy() }, 60) + // Recoil + const recoilX = atk.pos.x - dir * 15 + k.tween(atk.pos.x, recoilX, 0.04, (v) => { + atk.pos.x = v + barrel.pos.x = v + dir * 20 + handle.pos.x = v + dir * 20 - dir * 5 + scope.pos.x = v + dir * 20 + dir * gunLen * 0.6 + }).then(() => k.tween(recoilX, origAX, 0.06, (v) => { + atk.pos.x = v + barrel.pos.x = v + dir * 20 + handle.pos.x = v + dir * 20 - dir * 5 + scope.pos.x = v + dir * 20 + dir * gunLen * 0.6 + })) + // Massive projectile + await spawnProjectile(gx + dir * gunLen, gy, def.pos.x, def.pos.y - 20 + (Math.random() - 0.5) * 20, '#ffcc00', 10) + sfxBulletHit() + def.play(s === shotCount - 1 ? 'knockback' : 'hit') + spawnSparks(def.pos.x, def.pos.y - 20, 10, '#ff6600') + spawnShockwave(def.pos.x, GROUND_Y, '#ff6600') + def.pos.x += dir * (isCritical ? 20 : 12) + if (s < shotCount - 1) await k.wait(0.06) + } + + // Gun disappears — shrinks back into pocket + await Promise.all([ + k.tween(1, 0, 0.12, (v) => { barrel.scale = k.vec2(v, v) }), + k.tween(1, 0, 0.12, (v) => { handle.scale = k.vec2(v, v) }), + k.tween(1, 0, 0.12, (v) => { scope.scale = k.vec2(v, v) }), + ]) + barrel.destroy(); handle.destroy(); scope.destroy() + sfxBoing() + + if (isCritical) screenFlash('#ff6600', 0.15) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + // GRAPPLE FLURRY: both bots close in and trade sustained blows without splitting apart + async function grappleFlurry(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Both rush to center — meet in the middle + const meetX = (origAX + origDX) / 2 + const atkMeetX = meetX - dir * 25 + const defMeetX = meetX + dir * 25 + sfxSpecial() + await Promise.all([ + k.tween(atk.pos.x, atkMeetX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.x, defMeetX, 0.12, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + ]) + + // Sustained trading blows — attacker dominates but defender fights back + const hitCount = isCritical ? 10 : 7 + for (let i = 0; i < hitCount; i++) { + const atkHits = i % 3 !== 2 // attacker hits 2/3 of the time + if (atkHits) { + atk.play(i % 2 === 0 ? 'attack' : 'kick') + sfxPunch() + await k.wait(0.03) + def.play('hit') + spawnSparks(def.pos.x - dir * 5, def.pos.y - 25 - Math.random() * 20, 3, i % 2 === 0 ? '#ff2d7b' : '#ffcc00') + k.shake(2 + Math.floor(i / 2)) + // Push defender slightly but attacker follows + def.pos.x += dir * 3 + atk.pos.x += dir * 2 + } else { + // Defender strikes back! + def.play('attack') + sfxKick() + await k.wait(0.03) + atk.play('hit') + spawnSparks(atk.pos.x + dir * 5, atk.pos.y - 25 - Math.random() * 20, 3, '#00f0ff') + k.shake(2) + atk.pos.x -= dir * 3 + def.pos.x -= dir * 2 + } + await k.wait(0.05) + } + + // Attacker finishes with a shove + atk.play('special') + sfxCritical() + k.shake(isCritical ? 20 : 12) + spawnSparks(def.pos.x, def.pos.y - 30, 12, '#ff2d7b') + if (isCritical) { + screenFlash('#ff2d7b', 0.15) + spawnShockwave(def.pos.x, GROUND_Y, '#ff2d7b') + } + def.play('knockback') + const pushDist = dir * (isCritical ? 120 : 60) + k.tween(def.pos.x, origDX + pushDist * 0.5, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.25) + + // Both return + atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.25, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // BODY SLAM: wrestling-style grab, lift, and slam + async function bodySlam(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Rush in close + sfxSpecial() + const contactX = def.pos.x - dir * 30 + await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + + // Grab — both freeze + atk.play('special') + def.play('hit') + sfxClash() + k.shake(5) + await k.wait(0.1) + + // Lift opponent overhead + const liftY = atk.pos.y - 80 + sfxBoing() + await k.tween(def.pos.y, liftY, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad) + await k.tween(def.pos.x, atk.pos.x + dir * 5, 0.1, (v) => { def.pos.x = v }) + // Spin overhead + const defOrigY = GROUND_Y + for (let i = 0; i < (isCritical ? 3 : 2); i++) { + await k.tween(def.pos.x, atk.pos.x - dir * 20, 0.06, (v) => { def.pos.x = v }) + await k.tween(def.pos.x, atk.pos.x + dir * 20, 0.06, (v) => { def.pos.x = v }) + } + + // SLAM DOWN + sfxExplosion() + def.play('knockback') + const slamX = atk.pos.x + dir * 50 + await Promise.all([ + k.tween(def.pos.y, defOrigY, 0.08, (v) => { def.pos.y = v }, k.easings.easeInQuad), + k.tween(def.pos.x, slamX, 0.08, (v) => { def.pos.x = v }), + ]) + sfxCritical() + k.shake(isCritical ? 25 : 16) + screenFlash(isCritical ? '#ffffff' : '#ff6600', 0.15) + spawnSparks(slamX, defOrigY - 10, 18, '#ff6600') + spawnShockwave(slamX, GROUND_Y, '#ff6600') + spawnBulletHoles(slamX, GROUND_Y, 3) + await k.wait(0.2) + + // Return + atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // PINBALL COMBO: bounce the opponent between walls/edges like a pinball + async function pinballCombo(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Initial launch punch + atk.play('attack') + sfxPunch() + const contactX = def.pos.x - dir * 35 + await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + def.play('hit') + k.shake(8) + spawnSparks(def.pos.x, def.pos.y - 30, 8, '#ff2d7b') + + // Bounce the opponent back and forth across the screen + const bounces = isCritical ? 5 : 3 + const leftWall = 30 + const rightWall = W - 30 + let defX = def.pos.x + for (let i = 0; i < bounces; i++) { + const toWall = i % 2 === (dir > 0 ? 0 : 1) ? rightWall : leftWall + sfxZoomWhoosh() + await k.tween(defX, toWall, 0.06, (v) => { def.pos.x = v }, k.easings.easeInQuad) + defX = toWall + // Wall hit + sfxBonk() + k.shake(6 + i * 2) + spawnSparks(toWall, def.pos.y - 20, 6, ['#ff2d7b', '#ffcc00', '#00f0ff'][i % 3]) + screenFlash(['#ff2d7b', '#ffcc00', '#00f0ff'][i % 3], 0.05) + def.play(i % 2 === 0 ? 'hit' : 'knockback') + await k.wait(0.04) + } + + // Final slam to ground + sfxCritical() + sfxExplosion() + k.shake(isCritical ? 22 : 14) + screenFlash('#ffffff', 0.12) + spawnSparks(def.pos.x, def.pos.y - 20, 15, '#ffe14d') + spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d') + def.play('knockback') + await k.wait(0.2) + + // Return + atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // SUPLEX: grab from behind, lift, and drive headfirst into ground + async function suplex(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Dash behind defender + sfxSpecial() + const behindX = def.pos.x + dir * 30 + await k.tween(atk.pos.x, behindX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + // Flip attacker to face back + atk.scale.x = -atk.scale.x + atk.play('special') + sfxClash() + k.shake(4) + await k.wait(0.06) + + // Grab and arc overhead + const arcCx = (atk.pos.x + def.pos.x) / 2 + const arcTop = atk.pos.y - 120 + // Both rise in an arc + const steps = 8 + for (let i = 0; i <= steps; i++) { + const t = i / steps + const angle = Math.PI * t + def.pos.x = arcCx + Math.cos(angle) * 40 + def.pos.y = GROUND_Y - Math.sin(angle) * 120 + atk.pos.x = def.pos.x + dir * 20 + atk.pos.y = def.pos.y + 10 + await k.wait(0.02) + } + + // IMPACT — headfirst into ground + sfxExplosion() + sfxCritical() + k.shake(isCritical ? 28 : 18) + screenFlash(isCritical ? '#ffffff' : '#ff2d2d', 0.2) + spawnSparks(def.pos.x, GROUND_Y - 10, 20, '#ff2d2d') + spawnShockwave(def.pos.x, GROUND_Y, '#ff2d2d') + def.play('ko') + await k.wait(0.15) + def.play(isCritical ? 'knockback' : 'hit') + + // Flip attacker back + atk.scale.x = -atk.scale.x + atk.play('idle') + def.pos.y = GROUND_Y + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // ============================================================ + // CHOREOGRAPHY FACTORIES — generate moves from templates + // ============================================================ + type ChoreoFn = typeof dashPunch + + // FACTORY 1: Throw object(s) in an arc at the opponent + function makeThrow(color: string, size: number, count: number = 1, arc: number = 60, circle: boolean = false, trail?: string): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial(); await k.wait(0.12) + const total = count + (isCritical ? 2 : 0) + for (let i = 0; i < total; i++) { + const fx = atk.pos.x + dir * 25, fy = atk.pos.y - 35 + const tx = def.pos.x + (Math.random() - 0.5) * 30, ty = def.pos.y - 25 + (Math.random() - 0.5) * 20 + const p = k.add([circle ? k.circle(size / 2) : k.rect(size, size), k.pos(fx, fy), k.color(k.Color.fromHex(color)), k.opacity(1), k.z(16), k.rotate(Math.random() * 360)]) + k.tween(0, 1, 0.18, (t) => { p.pos.x = fx + (tx - fx) * t; p.pos.y = fy + (ty - fy) * t - Math.sin(t * Math.PI) * arc; p.angle += 720 * k.dt() }).then(() => { p.destroy(); spawnSparks(tx, ty, 3, trail || color) }) + if (i < total - 1) await k.wait(0.05) + } + await k.wait(0.12); sfxBonk() + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 14 : 6) + if (isCritical) { screenFlash(color); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 14 : 7, color) + const push = dir * (isCritical ? 85 : 38) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + + // FACTORY 2: Swing weapon — run up, swing rect, hit + function makeSwing(weaponW: number, weaponH: number, weaponColor: string, headColor?: string, headW?: number, headH?: number): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + const cx = origDX - dir * 55 + await k.tween(atk.pos.x, cx, 0.14, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + const wx = atk.pos.x + dir * 18, wy = atk.pos.y - 55 + const shaft = k.add([k.rect(weaponW, weaponH), k.pos(wx, wy), k.color(k.Color.fromHex(weaponColor)), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -40 : 40)]) + let head: any = null + if (headColor) { head = k.add([k.rect(headW || 18, headH || 14), k.pos(wx + dir * 10, wy - weaponH + 5), k.color(k.Color.fromHex(headColor)), k.z(18)]) } + atk.play('attack'); sfxSpecial() + await k.tween(dir === 1 ? -40 : 40, dir === 1 ? 85 : -85, 0.13, (v) => { shaft.angle = v }, k.easings.easeInQuad) + sfxBonk(); shaft.destroy(); if (head) head.destroy() + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 18 : 9) + if (isCritical) { screenFlash(weaponColor); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 14 : 7, headColor || weaponColor) + const push = dir * (isCritical ? 100 : 48) + k.tween(def.pos.x, origDX + push, 0.22, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + } + + // FACTORY 3: Drop something from the sky onto opponent + function makeDrop(w: number, h: number, color: string, bounceColor?: string, circle: boolean = false): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial(); await k.wait(0.12) + const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(k.Color.fromHex('#000000')), k.opacity(0.3), k.z(4)]) + k.tween(5, w * 0.7, 0.25, (v) => { shadow.radius = v }) + const obj = k.add([circle ? k.circle(w / 2) : k.rect(w, h), k.pos(def.pos.x - (circle ? 0 : w / 2), -h - 10), k.color(k.Color.fromHex(color)), k.opacity(0.9), k.z(18)]) + sfxSlideDown() + await k.tween(-h - 10, def.pos.y - 45, 0.22, (v) => { obj.pos.y = v }, k.easings.easeInQuad) + sfxBonk(); sfxExplosion() + k.shake(isCritical ? 22 : 13); screenFlash(isCritical ? '#ffffff' : (bounceColor || color), 0.15) + spawnSparks(def.pos.x, def.pos.y - 30, 14, bounceColor || color) + spawnShockwave(def.pos.x, GROUND_Y, bounceColor || color) + shadow.destroy(); def.play(isCritical ? 'knockback' : 'hit') + await k.tween(obj.pos.y, obj.pos.y - 35, 0.08, (v) => { obj.pos.y = v }, k.easings.easeOutQuad) + await k.tween(obj.pos.y, obj.pos.y + 35, 0.08, (v) => { obj.pos.y = v }, k.easings.easeInQuad) + sfxBonk(); await k.wait(0.1) + k.tween(0.9, 0, 0.25, (v) => { obj.opacity = v }).then(() => obj.destroy()) + const push = dir * (isCritical ? 95 : 42) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.25); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + + // FACTORY 4: Vehicle drives across screen (like motorbikeCharge/carSmash) + function makeVehicle(bodyW: number, bodyH: number, bodyColor: string, wheelR: number = 8, parts?: { w: number, h: number, color: string, ox: number, oy: number }[]): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + const startX = dir === 1 ? -bodyW - 20 : W + bodyW + 20 + atk.opacity = 0; atk.pos.x = startX + const vY = GROUND_Y - bodyH / 2 - wheelR + const body = k.add([k.rect(bodyW, bodyH), k.pos(startX, vY), k.color(k.Color.fromHex(isCritical ? '#ff2d2d' : bodyColor)), k.z(16)]) + const w1 = k.add([k.circle(wheelR), k.pos(startX + dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(k.Color.fromHex('#222222')), k.z(16)]) + const w2 = k.add([k.circle(wheelR), k.pos(startX - dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(k.Color.fromHex('#222222')), k.z(16)]) + const extras: any[] = [] + if (parts) for (const p of parts) { + extras.push(k.add([k.rect(p.w, p.h), k.pos(startX + p.ox * dir, vY + p.oy), k.color(k.Color.fromHex(p.color)), k.z(17)])) + } + sfxJetpack() + const exh = setInterval(() => { + const ep = k.add([k.circle(3), k.pos(body.pos.x - dir * bodyW / 2, vY + 5), k.color(k.Color.fromHex('#888888')), k.opacity(0.5), k.z(4)]) + ep.onUpdate(() => { ep.opacity -= 2 * k.dt(); if (ep.opacity <= 0) ep.destroy() }) + }, 30) + const tgt = origDX + dir * 20 + await k.tween(startX, tgt, 0.32, (v) => { + body.pos.x = v; w1.pos.x = v + dir * (bodyW / 3); w2.pos.x = v - dir * (bodyW / 3) + extras.forEach((e, i) => { if (parts) e.pos.x = v + parts[i].ox * dir }) + }, k.easings.easeInQuad) + clearInterval(exh) + sfxExplosion(); sfxCritical() + body.destroy(); w1.destroy(); w2.destroy(); extras.forEach(e => e.destroy()) + k.shake(isCritical ? 25 : 14); screenFlash(isCritical ? '#ff2d2d' : bodyColor, 0.18) + spawnSparks(def.pos.x, def.pos.y - 20, 18, '#ff6600'); spawnShockwave(def.pos.x, GROUND_Y, '#ff6600') + def.play('knockback') + const push = dir * (isCritical ? 150 : 70) + const origDY = def.pos.y + await Promise.all([ + k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.y, origDY - 90, 0.14, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(def.pos.y, origDY, 0.18, (v) => { def.pos.y = v }, k.easings.easeInQuad)) + ]) + atk.pos.x = origAX; atk.opacity = 1; atk.play('idle') + await k.wait(0.15) + await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + + // FACTORY 5: Beam/ray attack — attacker fires continuous beam + function makeBeam(color: string, width: number = 6, pulses: number = 1): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial() + // Charge up + const chargeOrb = k.add([k.circle(3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 35), k.color(k.Color.fromHex(color)), k.opacity(0.8), k.z(18)]) + await k.tween(3, 12 + (isCritical ? 5 : 0), 0.2, (v) => { chargeOrb.radius = v }, k.easings.easeOutQuad) + chargeOrb.destroy() + // Fire beam + const total = pulses + (isCritical ? 1 : 0) + for (let p = 0; p < total; p++) { + sfxZap() + const bx = atk.pos.x + dir * 30, by = atk.pos.y - 35 + const beamLen = Math.abs(def.pos.x - atk.pos.x) + 30 + const beam = k.add([k.rect(beamLen, width + (isCritical ? 4 : 0)), k.pos(bx, by - width / 2), k.color(k.Color.fromHex(color)), k.opacity(0.9), k.z(17)]) + const glow = k.add([k.rect(beamLen, width * 2.5), k.pos(bx, by - width * 1.25), k.color(k.Color.fromHex(color)), k.opacity(0.25), k.z(16)]) + if (dir === -1) { beam.pos.x = bx - beamLen; glow.pos.x = bx - beamLen } + await k.wait(0.08) + def.play('hit'); k.shake(isCritical ? 12 : 5) + spawnSparks(def.pos.x, def.pos.y - 30, 8, color) + await k.wait(0.08) + k.tween(0.9, 0, 0.12, (v) => { beam.opacity = v; glow.opacity = v * 0.3 }).then(() => { beam.destroy(); glow.destroy() }) + if (p < total - 1) await k.wait(0.06) + } + if (isCritical) { screenFlash(color); sfxCritical() } + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 7) + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + + // FACTORY 6: Spin/flip — attacker does acrobatic motion then strikes + function makeSpin(spinType: 'horizontal' | 'vertical' | 'corkscrew', height: number = 120, hits: number = 1): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + const origAY = atk.pos.y + const cx = origDX - dir * 35 + atk.play('kick'); sfxKick() + if (spinType === 'vertical') { + // Frontflip / backflip — jump up, arc to opponent + await Promise.all([ + k.tween(atk.pos.x, cx, 0.25, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad), + k.tween(origAY, origAY - height, 0.12, (v) => { atk.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(origAY - height, origAY, 0.13, (v) => { atk.pos.y = v }, k.easings.easeInQuad)), + k.tween(0, 360 * (isCritical ? 2 : 1), 0.25, (v) => { atk.angle = v }).then(() => { atk.angle = 0 }), + ]) + } else if (spinType === 'corkscrew') { + // Corkscrew — spiral path + await k.tween(0, 1, 0.28, (t) => { + atk.pos.x = origAX + (cx - origAX) * t + atk.pos.y = origAY - Math.sin(t * Math.PI * 3) * height * 0.4 + atk.angle = t * 720 + }, k.easings.easeInOutQuad) + atk.angle = 0 + } else { + // Horizontal tornado spin + await Promise.all([ + k.tween(atk.pos.x, cx, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(0, 720 * (isCritical ? 1.5 : 1), 0.2, (v) => { atk.angle = v }).then(() => { atk.angle = 0 }), + ]) + } + // Hit(s) + const hc = hits + (isCritical ? 1 : 0) + for (let i = 0; i < hc; i++) { + sfxPunch(); def.play('hit'); k.shake(4 + i * 2) + spawnSparks(def.pos.x + (Math.random() - 0.5) * 20, def.pos.y - 25, 5, theme.accent) + def.pos.x += dir * 10 + if (i < hc - 1) await k.wait(0.06) + } + if (isCritical) { screenFlash(theme.accent); sfxCritical() } + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8) + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + } + + // FACTORY 7: Summon/swarm — creatures fly from behind attacker toward defender + function makeSwarm(color: string, count: number, size: number, speed: number = 400, zigzag: boolean = false): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial(); await k.wait(0.15) + const total = count + (isCritical ? 3 : 0) + const swarmThings: any[] = [] + for (let i = 0; i < total; i++) { + const sx = atk.pos.x - dir * 20 + (Math.random() - 0.5) * 30 + const sy = atk.pos.y - 40 + (Math.random() - 0.5) * 50 + const p = k.add([k.circle(size / 2 + Math.random() * 2), k.pos(sx, sy), k.color(k.Color.fromHex(color)), k.opacity(0.9), k.z(16)]) + const tx = def.pos.x + (Math.random() - 0.5) * 25, ty = def.pos.y - 30 + (Math.random() - 0.5) * 30 + swarmThings.push(p) + k.tween(0, 1, (Math.abs(tx - sx) / speed) + Math.random() * 0.1, (t) => { + p.pos.x = sx + (tx - sx) * t + p.pos.y = sy + (ty - sy) * t + (zigzag ? Math.sin(t * Math.PI * 6) * 15 : 0) + }).then(() => { p.destroy(); spawnSparks(tx, ty, 2, color) }) + await k.wait(0.03) + } + await k.wait(0.2) + sfxBonk(); def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8) + if (isCritical) { screenFlash(color); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 25, 10, color) + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + swarmThings.forEach(s => { if (s.exists()) s.destroy() }) + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + + // FACTORY 8: Explosion — thing appears near opponent and explodes + function makeExplosion(objW: number, objH: number, objColor: string, fuseTime: number = 0.3, blastColor: string = '#ff6600', circle: boolean = false): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial() + // Throw the thing + const fromX = atk.pos.x + dir * 25, fromY = atk.pos.y - 35 + const toX = def.pos.x, toY = def.pos.y - 20 + const obj = k.add([circle ? k.circle(objW / 2) : k.rect(objW, objH), k.pos(fromX, fromY), k.color(k.Color.fromHex(objColor)), k.opacity(1), k.z(16), k.rotate(0)]) + await k.tween(0, 1, 0.2, (t) => { + obj.pos.x = fromX + (toX - fromX) * t + obj.pos.y = fromY + (toY - fromY) * t - Math.sin(t * Math.PI) * 80 + obj.angle = t * 360 + }) + obj.angle = 0 + // Fuse / flash + for (let i = 0; i < 3; i++) { + obj.opacity = 0.4; await k.wait(fuseTime / 6); obj.opacity = 1; await k.wait(fuseTime / 6) + } + // BOOM + obj.destroy(); sfxExplosion() + const blastR = isCritical ? 45 : 28 + const blast = k.add([k.circle(blastR), k.pos(toX, toY), k.color(k.Color.fromHex(blastColor)), k.opacity(0.8), k.z(19)]) + k.tween(blastR, blastR * 2.5, 0.15, (v) => { blast.radius = v }) + k.tween(0.8, 0, 0.2, (v) => { blast.opacity = v }).then(() => blast.destroy()) + k.shake(isCritical ? 25 : 14); screenFlash(blastColor, 0.15) + spawnSparks(toX, toY, isCritical ? 25 : 12, blastColor); spawnShockwave(toX, GROUND_Y, blastColor) + def.play(isCritical ? 'knockback' : 'hit') + if (isCritical) sfxCritical() + const push = dir * (isCritical ? 120 : 55) + const origDY = def.pos.y + await Promise.all([ + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(origDY, origDY - 60, 0.12, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(origDY - 60, origDY, 0.15, (v) => { def.pos.y = v }, k.easings.easeInQuad)) + ]) + await k.wait(0.15); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + + // FACTORY 9: Grab/wrestling — run up, grab, slam + function makeGrab(slamType: 'up' | 'down' | 'spin' | 'toss', height: number = 150): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + const cx = origDX - dir * 30 + await k.tween(atk.pos.x, cx, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('attack'); sfxPunch(); await k.wait(0.08) + // Grab + def.play('hit'); sfxSpecial() + const origDY = def.pos.y + if (slamType === 'up') { + // Lift and slam down + await k.tween(origDY, origDY - height, 0.2, (v) => { def.pos.y = v; atk.pos.y = v + 20 }, k.easings.easeOutQuad) + await k.wait(0.1) + await k.tween(origDY - height, origDY, 0.15, (v) => { def.pos.y = v; atk.pos.y = GROUND_Y }, k.easings.easeInQuad) + sfxExplosion(); k.shake(isCritical ? 22 : 12); spawnShockwave(def.pos.x, GROUND_Y, theme.accent) + } else if (slamType === 'down') { + // Piledriver — both go up, come down head first + await Promise.all([ + k.tween(origDY, origDY - height, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad), + k.tween(atk.pos.y, atk.pos.y - height, 0.2, (v) => { atk.pos.y = v }, k.easings.easeOutQuad), + ]) + await k.wait(0.1) + def.angle = 180 + await Promise.all([ + k.tween(origDY - height, origDY, 0.13, (v) => { def.pos.y = v }, k.easings.easeInQuad), + k.tween(atk.pos.y, GROUND_Y, 0.13, (v) => { atk.pos.y = v }, k.easings.easeInQuad), + ]) + def.angle = 0; sfxExplosion(); k.shake(isCritical ? 28 : 16); spawnShockwave(def.pos.x, GROUND_Y, '#ff2d2d') + } else if (slamType === 'spin') { + // Spin throw + await k.tween(0, 1, 0.35, (t) => { + def.pos.y = origDY - Math.sin(t * Math.PI) * height * 0.5 + def.angle = t * 720 + def.pos.x += dir * 2 + }) + def.angle = 0; sfxBonk(); k.shake(isCritical ? 20 : 10) + } else { + // Toss across screen + const tossX = origDX + dir * (isCritical ? 200 : 120) + await Promise.all([ + k.tween(def.pos.x, tossX, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(origDY, origDY - height, 0.12, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(origDY - height, origDY, 0.13, (v) => { def.pos.y = v }, k.easings.easeInQuad)), + ]) + sfxBonk(); k.shake(isCritical ? 18 : 9); spawnShockwave(def.pos.x, GROUND_Y, theme.accent) + } + if (isCritical) { screenFlash(theme.accent); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 18 : 8, theme.accent) + def.play(isCritical ? 'knockback' : 'hit') + await k.wait(0.3); atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(atk.pos.y, GROUND_Y, 0.15, (v) => { atk.pos.y = v }, k.easings.easeOutQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + } + + // ============================================================ + // GENERATED CHOREOGRAPHIES — 258 new moves from factories + // ============================================================ + + // --- FOOD THROWS (30) --- + const pizzaSlam = makeThrow('#ff8800', 16, 2, 70, false, '#ffcc00') + const bananaFling = makeThrow('#ffe135', 12, 3, 55, false, '#ffff00') + const pieSmash = makeThrow('#f5deb3', 18, 1, 80, true, '#ffffff') + const hotdogWhip = makeThrow('#cc6633', 14, 2, 45, false, '#ff6644') + const watermelonBomb = makeThrow('#33aa33', 20, 1, 90, true, '#ff4466') + const sushiBarrage = makeThrow('#ffffff', 10, 5, 40, false, '#ff6666') + const burgerToss = makeThrow('#cc8833', 16, 2, 65, false, '#ffaa00') + const iceCreamFling = makeThrow('#ffccdd', 12, 3, 50, true, '#ff88bb') + const tacoStorm = makeThrow('#ffcc44', 11, 4, 55, false, '#ff8800') + const donutBarrage = makeThrow('#ff88cc', 14, 3, 60, true, '#ffaadd') + const popcornBlast = makeThrow('#ffffcc', 6, 12, 35, true, '#ffee88') + const cookieFling = makeThrow('#cc9944', 12, 3, 50, true, '#aa7722') + const eggBombard = makeThrow('#ffffdd', 10, 4, 65, true, '#ffee44') + const baguetteStrike = makeSwing(6, 55, '#dda855', '#cc9944', 10, 8) + const coffeeSplash = makeThrow('#443322', 10, 3, 45, true, '#886644') + const ramenWhip = makeThrow('#ffee88', 8, 6, 35, false, '#ffffff') + const nachoVolley = makeThrow('#ffcc22', 8, 8, 40, false, '#ff8800') + const pretzelFling = makeThrow('#aa7733', 14, 2, 60, false, '#cc9955') + const meatballStorm = makeThrow('#883322', 12, 5, 55, true, '#aa4433') + const popsicleJab = makeSwing(5, 45, '#ff66aa', '#ffffff', 12, 10) + const candyBarrage = makeThrow('#ff44ff', 8, 7, 40, true, '#ffaaff') + const waffleSlam = makeThrow('#dda844', 16, 2, 70, false, '#ffcc66') + const pancakeFrisbee = makeThrow('#eebb55', 14, 3, 30, true, '#ffdd88') + const drumstickSmack = makeSwing(7, 40, '#cc8844', '#aa6633', 14, 12) + const cornCobCannon = makeThrow('#ffee44', 10, 4, 50, false, '#44aa22') + const pickleJavelin = makeThrow('#669933', 8, 3, 45, false, '#88bb44') + const cabbagePunt = makeThrow('#44aa33', 18, 1, 85, true, '#66cc44') + const breadLoafBash = makeSwing(10, 35, '#dda855') + const cheeseWheel = makeThrow('#ffcc00', 20, 1, 80, true, '#ffee44') + const fishSlap = makeSwing(8, 40, '#8899aa', '#667788', 15, 8) + + // --- WEAPON SWINGS (20) --- + const baseballBat = makeSwing(6, 55, '#aa7744', '#996633', 16, 10) + const fryingPan = makeSwing(5, 40, '#333333', '#444444', 22, 4) + const rollingPin = makeSwing(8, 45, '#dda855') + const umbrellaWhack = makeSwing(5, 55, '#222222', '#ff4444', 30, 3) + const mopSwipe = makeSwing(4, 60, '#aa9977', '#dddddd', 14, 12) + const broomStrike = makeSwing(4, 58, '#886633', '#558833', 12, 10) + const wrenchSmash = makeSwing(6, 38, '#888888', '#aaaaaa', 18, 8) + const crowbarSwing = makeSwing(5, 50, '#555555', '#444444', 8, 16) + const plungerSlam = makeSwing(5, 48, '#aa8844', '#ff4444', 12, 12) + const tennisRacket = makeSwing(3, 42, '#666666', '#44aa44', 20, 14) + const hockeyStick = makeSwing(4, 55, '#663300', '#222222', 12, 8) + const poolCue = makeSwing(3, 65, '#aa8855') + const guitarSmash = makeSwing(8, 50, '#884422', '#ffcc00', 18, 8) + const shovelBash = makeSwing(5, 52, '#666666', '#888888', 16, 12) + const pickaxeStrike = makeSwing(4, 50, '#886644', '#888888', 14, 8) + const oarSwing = makeSwing(5, 58, '#bb9966') + const flagpoleSmash = makeSwing(3, 65, '#888888', '#ff0000', 18, 12) + const caneWhack = makeSwing(4, 55, '#222222', '#ddaa00', 6, 6) + const rulerSlap = makeSwing(3, 50, '#ddcc88') + const newspaperRoll = makeSwing(6, 42, '#ccccbb') + + // --- PROJECTILE STREAMS (20) --- + const tennisBallVolley = makeThrow('#ccff00', 8, 5, 40, true, '#aadd00') + const baseballPitch = makeThrow('#ffffff', 8, 4, 50, true, '#dddddd') + const bowlingBallRoll = makeThrow('#222222', 18, 1, 20, true, '#444444') + const rubberDuckFlood = makeThrow('#ffdd00', 10, 6, 45, true, '#ffee44') + const shoeBarrage = makeThrow('#553322', 12, 4, 55, false, '#775544') + const bookStorm = makeThrow('#884422', 14, 4, 60, false, '#aa6644') + const phoneFling = makeThrow('#333333', 10, 3, 50, false, '#4488ff') + const keyboardSmash = makeThrow('#dddddd', 16, 2, 65, false, '#aaaaaa') + const diceBarrage = makeThrow('#ffffff', 8, 6, 45, false, '#ff0000') + const marbleShower = makeThrow('#44aaff', 5, 10, 30, true, '#88ccff') + const snowballFight = makeThrow('#ffffff', 12, 4, 55, true, '#ddeeff') + const paintCanBlast = makeThrow('#ff4488', 14, 3, 60, true, '#ff88aa') + const tomatoBarrage = makeThrow('#ff2222', 10, 5, 50, true, '#ff4444') + const waterBalloonRain = makeThrow('#4488ff', 14, 3, 70, true, '#88bbff') + const pillowFight = makeThrow('#ffffff', 16, 3, 45, false, '#eeeeff') + const vinylRecordFling = makeThrow('#111111', 14, 3, 35, true, '#222222') + const frisbeeToss = makeThrow('#ff6600', 14, 2, 30, true, '#ffaa44') + const beachBallBonk = makeThrow('#ff4488', 20, 1, 80, true, '#ffaacc') + const soccerKick = makeThrow('#ffffff', 14, 2, 75, true, '#111111') + const basketballDunk = makeThrow('#ff8800', 16, 1, 90, true, '#ff6600') + + // --- DROP ATTACKS (15) --- + const pianoDrop = makeDrop(55, 40, '#222222', '#ffcc00') + const safeDrop = makeDrop(35, 35, '#555555', '#888888') + const fridgeDrop = makeDrop(30, 45, '#dddddd', '#88ccff') + const couchDrop = makeDrop(55, 25, '#886644', '#aa8866') + const tvDrop = makeDrop(35, 30, '#333333', '#4488ff') + const toiletDrop = makeDrop(25, 30, '#ffffff', '#88bbff') + const bathtubDrop = makeDrop(50, 25, '#dddddd', '#88ccff') + const washerDrop = makeDrop(32, 35, '#cccccc', '#4488ff') + const vendingDrop = makeDrop(30, 45, '#ff4444', '#ffcc00') + const chandelierDrop = makeDrop(40, 30, '#ffcc00', '#ffffff') + const boulderDrop = makeDrop(35, 35, '#888888', '#666666', true) + const satelliteDrop = makeDrop(28, 35, '#888888', '#4488ff') + const ufoDrop = makeDrop(45, 15, '#88ff88', '#44ff44', false) + const giantShoeDrop = makeDrop(40, 25, '#553322', '#886644') + const giantPhoneDrop = makeDrop(22, 40, '#333333', '#4488ff') + + // --- VEHICLES (10) --- + const shoppingCart = makeVehicle(45, 30, '#888888', 7, [{ w: 40, h: 3, color: '#aaaaaa', ox: 0, oy: -15 }]) + const forkliftCharge = makeVehicle(50, 30, '#ffaa00', 8, [{ w: 8, h: 25, color: '#ffcc44', ox: 28, oy: -20 }]) + const tankRoll = makeVehicle(65, 28, '#556633', 10, [{ w: 40, h: 6, color: '#445522', ox: 25, oy: -14 }]) + const helicopterStrike = makeVehicle(50, 20, '#446688', 0, [{ w: 55, h: 3, color: '#557799', ox: 0, oy: -12 }]) + const airplaneSwoop = makeVehicle(60, 15, '#dddddd', 0, [{ w: 50, h: 4, color: '#cccccc', ox: 0, oy: -3 }]) + const rocketRide = makeVehicle(30, 50, '#ff4444', 0, [{ w: 20, h: 8, color: '#ffffff', ox: 0, oy: -28 }]) + const unicycleRun = makeVehicle(12, 18, '#ff8800', 10) + const zamboniCrush = makeVehicle(60, 30, '#ffffff', 9, [{ w: 55, h: 5, color: '#88ccff', ox: 0, oy: 15 }]) + const tractorPlow = makeVehicle(55, 32, '#44aa22', 10, [{ w: 30, h: 20, color: '#888888', ox: 30, oy: -8 }]) + const golfCartDrive = makeVehicle(40, 25, '#ffffff', 6, [{ w: 15, h: 18, color: '#44aa44', ox: -8, oy: -16 }]) + + // --- SPIN ATTACKS (10) --- + const tornadoSpin = makeSpin('horizontal', 80, 3) + const backflipKick = makeSpin('vertical', 140, 1) + const corkscrewDive = makeSpin('corkscrew', 100, 2) + const helicopterArms = makeSpin('horizontal', 50, 4) + const breakdanceSweep = makeSpin('horizontal', 30, 3) + const cartwheelStrike = makeSpin('vertical', 90, 1) + const frontflipSlam = makeSpin('vertical', 160, 2) + const drillSpin = makeSpin('corkscrew', 60, 3) + const cycloneKick = makeSpin('horizontal', 100, 2) + const pirouetteStrike = makeSpin('horizontal', 40, 5) + + // --- FLIP ATTACKS (10) --- + const doubleBackflip = makeSpin('vertical', 180, 2) + const wallBounce = makeSpin('vertical', 200, 1) + const springboardLaunch = makeSpin('vertical', 220, 1) + const barrelRoll = makeSpin('corkscrew', 80, 1) + const trampolineBounce = makeSpin('vertical', 250, 2) + const poleVault = makeSpin('vertical', 230, 1) + const skateTrick = makeSpin('corkscrew', 120, 1) + const surfSlam = makeSpin('horizontal', 60, 2) + const rolloutBall = makeSpin('horizontal', 20, 4) + const aerialTwist = makeSpin('corkscrew', 150, 2) + + // --- BEAM ATTACKS (15) --- + const kamehameha = makeBeam('#44bbff', 10, 1) + const freezeRay = makeBeam('#88eeff', 8, 2) + const heatVision = makeBeam('#ff2200', 4, 3) + const plasmaBeam = makeBeam('#ff44ff', 8, 1) + const rainbowBeam = makeBeam('#ff8800', 12, 1) + const shadowBeam = makeBeam('#440066', 10, 1) + const sonicWave = makeBeam('#88ff88', 14, 2) + const gravityBeam = makeBeam('#6600aa', 6, 2) + const mindBlast = makeBeam('#ff88ff', 8, 1) + const pixelBeam = makeBeam('#44ff44', 6, 3) + const glitchBeam = makeBeam('#ff00ff', 5, 4) + const bassDropBeam = makeBeam('#8844ff', 16, 1) + const wifiBlast = makeBeam('#4488ff', 8, 2) + const dataStream = makeBeam('#00ff88', 4, 5) + const chainLightning = makeBeam('#ffff00', 5, 3) + + // --- ANIMAL SUMMONS (15) --- + const sharkBite = makeSwarm('#4488aa', 1, 20, 600) + const bearSwipe = makeSwarm('#885533', 1, 22, 500) + const eagleDive = makeSwarm('#886644', 3, 12, 700, true) + const snakeLunge = makeSwarm('#44aa22', 2, 10, 550, true) + const bullCharge = makeSwarm('#883322', 1, 24, 450) + const gorillaSlam = makeSwarm('#555544', 1, 25, 400) + const scorpionSting = makeSwarm('#aa8822', 2, 8, 600, true) + const crabPinch = makeSwarm('#ff4422', 4, 10, 350) + const batSwarm = makeSwarm('#333333', 12, 5, 500, true) + const wolfPack = makeSwarm('#777766', 5, 12, 450) + const spiderWeb = makeSwarm('#dddddd', 8, 4, 300, true) + const beeSwarm = makeSwarm('#ffcc00', 10, 4, 550, true) + const catScratch = makeSwarm('#ff8844', 6, 6, 600, true) + const dogPile = makeSwarm('#aa8855', 4, 14, 400) + const dolphinFlip = makeSwarm('#6688cc', 3, 14, 500, true) + + // --- EXPLOSION ATTACKS (15) --- + const dynamiteBlast = makeExplosion(8, 20, '#ff4444', 0.35, '#ff6600') + const c4Detonation = makeExplosion(14, 8, '#556633', 0.25, '#ff4400') + const fireworksBurst = makeExplosion(10, 10, '#ff0088', 0.2, '#ff44ff', true) + const nukeStrike = makeExplosion(12, 12, '#ffcc00', 0.4, '#ff2200', true) + const volcanoErupt = makeExplosion(20, 15, '#ff4400', 0.3, '#ff6600') + const grenadeBlast = makeExplosion(10, 12, '#556633', 0.3, '#ff6600', true) + const partyPopper = makeExplosion(8, 16, '#ff44ff', 0.15, '#ffcc00') + const pinataSmash = makeExplosion(20, 22, '#ff88cc', 0.2, '#ffaa44') + const balloonPop = makeExplosion(18, 18, '#ff4488', 0.1, '#ff88aa', true) + const glitterBomb = makeExplosion(12, 12, '#ffcc88', 0.15, '#ff88ff', true) + const smokeBomb = makeExplosion(14, 14, '#888888', 0.1, '#666666', true) + const flashBang = makeExplosion(8, 8, '#ffffff', 0.1, '#ffffff', true) + const cherryBomb = makeExplosion(8, 8, '#ff2222', 0.2, '#ff4444', true) + const confettiCannon = makeExplosion(16, 10, '#ffcc00', 0.1, '#ff88ff') + const stinkBomb = makeExplosion(12, 12, '#88aa33', 0.15, '#aacc44', true) + + // --- SILLY/MEME (20) --- + const rubberChicken = makeSwing(8, 40, '#ffcc00', '#ff8844', 12, 12) + const airHornBlast = makeBeam('#ffcc00', 18, 1) + const vuvuzelaBlast = makeBeam('#ff4444', 16, 2) + const selfieStrike: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => { + // Pose, flash, then punch + atk.play('idle'); await k.wait(0.2) + screenFlash('#ffffff', 0.08); sfxSpecial(); await k.wait(0.15) + const cx = origDX - dir * 40 + await k.tween(atk.pos.x, cx, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('attack'); sfxPunch(); await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 14 : 6) + if (isCritical) { screenFlash('#ffffff'); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 25, 8, '#ffcc00') + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + const dabAttack = makeSpin('horizontal', 30, 1) + const flossAttack = makeSpin('horizontal', 20, 3) + const yeetThrow = makeGrab('toss', 180) + const tPoseAssert = makeBeam('#ffffff', 20, 1) + const emojiBarrage = makeThrow('#ffcc00', 12, 6, 50, true, '#ff8800') + const memeBeam = makeBeam('#ff00ff', 10, 2) + const ratioAttack = makeThrow('#4488ff', 10, 5, 40, true, '#2266dd') + const capThrow = makeThrow('#ff0000', 14, 1, 30, true, '#0000ff') + const fingerGuns: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial(); await k.wait(0.15) + const shots = isCritical ? 6 : 3 + for (let i = 0; i < shots; i++) { + sfxGunshot() + await spawnBullet(atk.pos.x + dir * 25, atk.pos.y - 35, def.pos.x, def.pos.y - 25 + (Math.random() - 0.5) * 30) + sfxBulletHit(); def.play('hit'); k.shake(3) + spawnSparks(def.pos.x, def.pos.y - 25, 3, '#ffcc00'); await k.wait(0.06) + } + if (isCritical) { screenFlash('#ffcc00'); sfxCritical() } + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 12 : 6) + const push = dir * (isCritical ? 80 : 35) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + const micDrop = makeDrop(12, 16, '#333333', '#ffcc00') + const dramaticEntrance = makeSpin('corkscrew', 200, 1) + const clapback = makeSwing(10, 5, '#ffaa88') + const touchGrass = makeSwarm('#44aa22', 15, 4, 300, true) + const noScope = makeBeam('#ff4400', 3, 1) + + // --- GRAPPLE/WRESTLING (15) --- + const piledriver = makeGrab('down', 180) + const powerbomb = makeGrab('up', 200) + const ddt = makeGrab('down', 120) + const germanSuplex = makeGrab('up', 160) + const chokeslam = makeGrab('up', 220) + const tombstone = makeGrab('down', 200) + const stunner = makeGrab('down', 80) + const rko = makeGrab('down', 140) + const spear = makeGrab('toss', 80) + const clothesline: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => { + const cx = origDX - dir * 20 + atk.play('attack'); sfxSpecial() + await k.tween(atk.pos.x, cx, 0.12, (v) => { atk.pos.x = v }, k.easings.easeInQuad) + sfxPunch(); def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 18 : 9); spawnSparks(def.pos.x, def.pos.y - 30, 10, '#ff4444') + if (isCritical) { screenFlash('#ff4444'); sfxCritical() } + // Defender spins backwards + const origDY = def.pos.y + await Promise.all([ + k.tween(def.pos.x, origDX + dir * (isCritical ? 120 : 60), 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(0, 360, 0.25, (v) => { def.angle = v }).then(() => { def.angle = 0 }), + k.tween(origDY, origDY - 50, 0.12, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(origDY - 50, origDY, 0.13, (v) => { def.pos.y = v }, k.easings.easeInQuad)), + ]) + spawnShockwave(def.pos.x, GROUND_Y, '#ff4444') + await k.wait(0.2); atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + const atomicDrop = makeGrab('up', 140) + const hurricanrana = makeGrab('spin', 120) + const moonsault = makeSpin('vertical', 200, 2) + const elbowDrop = makeDrop(10, 8, '#ffaa88', '#ff4444') + const frogSplash = makeSpin('vertical', 180, 1) + + // --- ENERGY/MAGIC (20) --- + const fireball = makeThrow('#ff4400', 14, 2, 30, true, '#ff8800') + const iceLance = makeThrow('#88eeff', 10, 3, 35, false, '#44ccff') + const thunderStrike = makeBeam('#ffff00', 6, 2) + const darkVoid = makeBeam('#440066', 12, 1) + const solarFlare: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial(); await k.wait(0.1) + // Blinding flash + screenFlash('#ffffff', 0.25) + await k.wait(0.2) + // Then punch while blinded + const cx = origDX - dir * 40 + await k.tween(atk.pos.x, cx, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('attack'); sfxPunch(); await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 18 : 8) + if (isCritical) { screenFlash('#ffff00'); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 25, 12, '#ffff00') + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + const spiritBomb = makeDrop(30, 30, '#44bbff', '#88ddff', true) + const windSlash = makeBeam('#aaffaa', 3, 4) + const earthquakeStrike: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial(); await k.wait(0.1) + atk.play('attack') + // Ground pound + const origAY = atk.pos.y + await k.tween(origAY, origAY + 15, 0.08, (v) => { atk.pos.y = v }, k.easings.easeInQuad) + sfxExplosion(); k.shake(isCritical ? 30 : 18) + spawnShockwave(atk.pos.x, GROUND_Y, '#aa8844') + // Screen shakes violently, ground cracks spread toward opponent + for (let i = 0; i < (isCritical ? 4 : 2); i++) { + const cx2 = atk.pos.x + dir * (80 + i * 60) + spawnSparks(cx2, GROUND_Y - 5, 5, '#aa8844') + await k.wait(0.05) + } + def.play(isCritical ? 'knockback' : 'hit'); k.shake(10) + if (isCritical) { screenFlash('#aa8844'); sfxCritical() } + spawnSparks(def.pos.x, GROUND_Y - 5, 10, '#886644') + const origDY = def.pos.y + await k.tween(origDY, origDY - 80, 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad) + await k.tween(origDY - 80, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad) + spawnShockwave(def.pos.x, GROUND_Y, '#886644') + atk.pos.y = GROUND_Y + await k.wait(0.2); atk.play('idle') + } + const portalPunch: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial() + // Open portal near attacker + const p1 = k.add([k.circle(20), k.pos(atk.pos.x + dir * 40, atk.pos.y - 30), k.color(k.Color.fromHex('#8844ff')), k.opacity(0.7), k.z(15)]) + // Open portal near defender + const p2 = k.add([k.circle(20), k.pos(def.pos.x - dir * 30, def.pos.y - 30), k.color(k.Color.fromHex('#ff44ff')), k.opacity(0.7), k.z(15)]) + await k.wait(0.2) + atk.play('attack'); sfxPunch() + // Fist appears from portal 2 + await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8) + spawnSparks(def.pos.x, def.pos.y - 25, 10, '#ff44ff') + if (isCritical) { screenFlash('#8844ff'); sfxCritical() } + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.15) + k.tween(0.7, 0, 0.2, (v) => { p1.opacity = v; p2.opacity = v }).then(() => { p1.destroy(); p2.destroy() }) + await k.wait(0.2); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + const gravityWell = makeSwarm('#6600aa', 1, 25, 300) + const plasmaOrb = makeThrow('#ff44ff', 16, 1, 40, true, '#ff88ff') + const voidRift = makeBeam('#220044', 14, 1) + const crystalShards = makeThrow('#88ffff', 8, 6, 45, false, '#44ddff') + const meteorStrike = makeDrop(25, 25, '#ff4400', '#ff8800', true) + const arcaneBarrage = makeThrow('#8844ff', 10, 5, 50, true, '#aa66ff') + const holySmite = makeBeam('#ffee88', 10, 1) + const hexCurse = makeSwarm('#44ff44', 6, 6, 400, true) + const bloodMoon = makeBeam('#ff2222', 12, 1) + const shadowClone = makeSwarm('#333333', 3, 16, 500) + + // --- TECH/MODERN (15) --- + const droneStrike = makeSwarm('#888888', 3, 8, 600) + const satelliteLaser = makeBeam('#ff0000', 4, 1) + const hackerAttack = makeThrow('#00ff44', 8, 8, 30, false, '#44ff88') + const bugSwarm = makeSwarm('#ff4444', 10, 4, 500, true) + const stackOverflow = makeDrop(35, 35, '#ff8800', '#f48024') + const segfault = makeExplosion(20, 20, '#ff0000', 0.1, '#880000') + const blueScreen = makeBeam('#0000ff', 20, 1) + const fourOhFour = makeExplosion(15, 15, '#888888', 0.2, '#ff4444') + const ctrlAltDelete = makeSwing(8, 12, '#333333', '#ffffff', 20, 12) + const bitcoinCrash = makeDrop(20, 20, '#ff8800', '#ffcc00', true) + const nftRugPull = makeGrab('toss', 200) + const aiUprising = makeSwarm('#ff0044', 8, 8, 500, true) + const captchaTrap = makeExplosion(25, 20, '#4488ff', 0.3, '#2266cc') + const popupSpam = makeThrow('#ffffff', 14, 8, 30, false, '#ff4444') + const malwareInject = makeBeam('#00ff00', 5, 3) + + // --- MUSIC (8) --- + const bassDrop = makeExplosion(10, 10, '#8844ff', 0.2, '#6622cc', true) + const guitarSolo = makeBeam('#ff8800', 8, 3) + const drumSolo = makeThrow('#aa8844', 10, 6, 45, true, '#886633') + const dubstepCannon = makeBeam('#ff00ff', 14, 2) + const airGuitar = makeBeam('#ffcc00', 6, 3) + const karaokeAttack = makeBeam('#ff44aa', 10, 2) + const beatboxBlast = makeSwarm('#4488ff', 8, 6, 400, true) + const vinylScratch = makeSwing(14, 2, '#111111', '#ff4444', 14, 14) + + // --- SPORTS (10) --- + const homeRunSwing = makeSwing(5, 55, '#886633', '#ffffff', 10, 10) + const slapShot = makeSwing(4, 55, '#333333', '#444444', 8, 8) + const servingAce = makeThrow('#ccff00', 10, 1, 80, true, '#aadd00') + const fieldGoalKick = makeSpin('vertical', 100, 1) + const bodyCheck = makeGrab('toss', 60) + const dropKick = makeSpin('vertical', 120, 1) + const elbowStrike = makeGrab('down', 60) + const kneeStrike = makeGrab('up', 50) + const headbutt = makeGrab('down', 30) + const shoulderCharge = makeGrab('toss', 40) + + // --- NATURE/ELEMENTAL (10) --- + const treeSmash = makeDrop(20, 60, '#44aa22', '#228811') + const icebergDrop = makeDrop(40, 35, '#88eeff', '#44ccff') + const lavaSplash = makeExplosion(15, 15, '#ff4400', 0.2, '#ff8800', true) + const tornadoFling = makeGrab('spin', 160) + const tsunamiWave = makeBeam('#2266cc', 20, 1) + const avalanche = makeSwarm('#ffffff', 12, 10, 400) + const vineWhip = makeSwing(3, 60, '#44aa22', '#33aa11', 6, 12) + const sandstorm = makeSwarm('#ccaa66', 15, 4, 350, true) + const thornBarrage = makeThrow('#44aa22', 6, 8, 35, false, '#228811') + const pollenCloud = makeSwarm('#ffee44', 12, 3, 250, true) + + // --- SPACE/SCI-FI (10) --- + const blackHole = makeSwarm('#220044', 1, 30, 200) + const photonTorpedo = makeThrow('#44bbff', 12, 3, 40, true, '#88ddff') + const tractor_beam = makeBeam('#44ff88', 10, 1) + const warpDrive = makeSpin('corkscrew', 180, 1) + const alienAbduction = makeGrab('up', 250) + const laserSword = makeSwing(4, 55, '#44bbff', '#88ddff', 4, 50) + const ionCannon = makeBeam('#88ff44', 8, 2) + const plasmaSword = makeSwing(4, 55, '#ff44ff', '#ff88ff', 4, 50) + const cosmicRay = makeBeam('#ffffff', 3, 5) + const asteroidBelt = makeThrow('#888888', 14, 5, 60, true, '#666666') + + // --- MEDIEVAL/FANTASY (10) --- + const dragonBreath = makeBeam('#ff4400', 12, 2) + const maceSwing = makeSwing(5, 48, '#888888', '#666666', 16, 16) + const crossbowBolt = makeThrow('#886633', 6, 3, 25, false, '#aa8855') + const shieldBash = makeGrab('toss', 50) + const flailSwing = makeSwing(3, 55, '#888888', '#666666', 14, 14) + const battleAxe = makeSwing(6, 50, '#886633', '#888888', 20, 12) + const holyWater = makeThrow('#88ccff', 10, 3, 55, true, '#aaeeff') + const potionThrow = makeThrow('#44ff44', 10, 2, 60, true, '#88ff88') + const scrollBlast = makeBeam('#ffcc88', 8, 1) + const enchantedArrow = makeThrow('#ff88ff', 6, 3, 30, false, '#ffaaff') + + const choreographyMap: Record = { + dashPunch, aerialSlam, flyingKick, dashThrough, uppercut, + multiHit, projectile, jetpackDive, gunBurst, groundPound, + teleportStrike, fullScreenDash, lightningRush, zoomRush, rapidFlurry, + // Weapons + swordSlash, hammerSmash, laserBeam, rocketLauncher, bombThrow, + minigunSpray, sniperShot, whipCrack, katanaCombo, chainsawRev, + // Vehicles & objects + motorbikeCharge, carSmash, boatCannon, anvilDrop, + // Close-combat / grapple + pocketCannon, grappleFlurry, bodySlam, pinballCombo, suplex, + // Themed + golfClubSmash, fireBreath, riddleBarrage, cloneStrike, + coinShower, penStab, mathAttack, trapCardAttack, afterimageDash, + // --- NEW: Food throws --- + pizzaSlam, bananaFling, pieSmash, hotdogWhip, watermelonBomb, + sushiBarrage, burgerToss, iceCreamFling, tacoStorm, donutBarrage, + popcornBlast, cookieFling, eggBombard, baguetteStrike, coffeeSplash, + ramenWhip, nachoVolley, pretzelFling, meatballStorm, popsicleJab, + candyBarrage, waffleSlam, pancakeFrisbee, drumstickSmack, cornCobCannon, + pickleJavelin, cabbagePunt, breadLoafBash, cheeseWheel, fishSlap, + // --- NEW: Weapon swings --- + baseballBat, fryingPan, rollingPin, umbrellaWhack, mopSwipe, + broomStrike, wrenchSmash, crowbarSwing, plungerSlam, tennisRacket, + hockeyStick, poolCue, guitarSmash, shovelBash, pickaxeStrike, + oarSwing, flagpoleSmash, caneWhack, rulerSlap, newspaperRoll, + // --- NEW: Projectile streams --- + tennisBallVolley, baseballPitch, bowlingBallRoll, rubberDuckFlood, + shoeBarrage, bookStorm, phoneFling, keyboardSmash, diceBarrage, + marbleShower, snowballFight, paintCanBlast, tomatoBarrage, + waterBalloonRain, pillowFight, vinylRecordFling, frisbeeToss, + beachBallBonk, soccerKick, basketballDunk, + // --- NEW: Drop attacks --- + pianoDrop, safeDrop, fridgeDrop, couchDrop, tvDrop, toiletDrop, + bathtubDrop, washerDrop, vendingDrop, chandelierDrop, boulderDrop, + satelliteDrop, ufoDrop, giantShoeDrop, giantPhoneDrop, + // --- NEW: Vehicles --- + shoppingCart, forkliftCharge, tankRoll, helicopterStrike, + airplaneSwoop, rocketRide, unicycleRun, zamboniCrush, tractorPlow, golfCartDrive, + // --- NEW: Spin attacks --- + tornadoSpin, backflipKick, corkscrewDive, helicopterArms, breakdanceSweep, + cartwheelStrike, frontflipSlam, drillSpin, cycloneKick, pirouetteStrike, + // --- NEW: Flip attacks --- + doubleBackflip, wallBounce, springboardLaunch, barrelRoll, + trampolineBounce, poleVault, skateTrick, surfSlam, rolloutBall, aerialTwist, + // --- NEW: Beam attacks --- + kamehameha, freezeRay, heatVision, plasmaBeam, rainbowBeam, + shadowBeam, sonicWave, gravityBeam, mindBlast, pixelBeam, + glitchBeam, bassDropBeam, wifiBlast, dataStream, chainLightning, + // --- NEW: Animal summons --- + sharkBite, bearSwipe, eagleDive, snakeLunge, bullCharge, + gorillaSlam, scorpionSting, crabPinch, batSwarm, wolfPack, + spiderWeb, beeSwarm, catScratch, dogPile, dolphinFlip, + // --- NEW: Explosion attacks --- + dynamiteBlast, c4Detonation, fireworksBurst, nukeStrike, volcanoErupt, + grenadeBlast, partyPopper, pinataSmash, balloonPop, glitterBomb, + smokeBomb, flashBang, cherryBomb, confettiCannon, stinkBomb, + // --- NEW: Silly/Meme --- + rubberChicken, airHornBlast, vuvuzelaBlast, selfieStrike, + dabAttack, flossAttack, yeetThrow, tPoseAssert, emojiBarrage, + memeBeam, ratioAttack, capThrow, fingerGuns, micDrop, + dramaticEntrance, clapback, touchGrass, noScope, + // --- NEW: Grapple/Wrestling --- + piledriver, powerbomb, ddt, germanSuplex, chokeslam, + tombstone, stunner, rko, spear, clothesline, + atomicDrop, hurricanrana, moonsault, elbowDrop, frogSplash, + // --- NEW: Energy/Magic --- + fireball, iceLance, thunderStrike, darkVoid, solarFlare, + spiritBomb, windSlash, earthquakeStrike, portalPunch, + gravityWell, plasmaOrb, voidRift, crystalShards, meteorStrike, + arcaneBarrage, holySmite, hexCurse, bloodMoon, shadowClone, + // --- NEW: Tech/Modern --- + droneStrike, satelliteLaser, hackerAttack, bugSwarm, + stackOverflow, segfault, blueScreen, fourOhFour, ctrlAltDelete, + bitcoinCrash, nftRugPull, aiUprising, captchaTrap, popupSpam, malwareInject, + // --- NEW: Music --- + bassDrop, guitarSolo, drumSolo, dubstepCannon, airGuitar, + karaokeAttack, beatboxBlast, vinylScratch, + // --- NEW: Sports --- + homeRunSwing, slapShot, servingAce, fieldGoalKick, bodyCheck, + dropKick, elbowStrike, kneeStrike, headbutt, shoulderCharge, + // --- NEW: Nature/Elemental --- + treeSmash, icebergDrop, lavaSplash, tornadoFling, tsunamiWave, + avalanche, vineWhip, sandstorm, thornBarrage, pollenCloud, + // --- NEW: Space/Sci-fi --- + blackHole, photonTorpedo, tractor_beam, warpDrive, alienAbduction, + laserSword, ionCannon, plasmaSword, cosmicRay, asteroidBelt, + // --- NEW: Medieval/Fantasy --- + dragonBreath, maceSwing, crossbowBolt, shieldBash, flailSwing, + battleAxe, holyWater, potionThrow, scrollBlast, enchantedArrow, + } + return { k, - async showAnnouncement(text: string, color: string = '#ffffff', duration: number = 1200) { - const ann = k.get('announcement')[0] - if (!ann) return - ann.text = text - ann.color = k.Color.fromHex(color) - ann.opacity = 1 - ann.scaleTo(0.5) - await k.tween(ann.scale.x, 1, 0.2, (v) => ann.scaleTo(v), k.easings.easeOutBack) - await k.wait(duration / 1000) - await k.tween(1, 0, 0.3, (v) => { ann.opacity = v }) - }, + async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {}, - async playAttack(side: 'a' | 'b', attackAnim: string, defenderAnim: string, isCritical: boolean) { + async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) { const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0] if (!attacker || !defender) return - const origAX = attacker.pos.x - const origDX = defender.pos.x + const origAX = side === 'a' ? HOME_A : HOME_B + const origDX = side === 'a' ? HOME_B : HOME_A const direction = side === 'a' ? 1 : -1 - const lunge = attackAnim === 'special' ? 20 : 40 + (isCritical ? 20 : 0) - // Lunge forward - await k.tween(attacker.pos.x, attacker.pos.x + direction * lunge, 0.15, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad) + const fn = choreographyMap[choreographyName] || dashPunch + await fn(attacker, defender, direction, origAX, origDX, isCritical) - attacker.play(attackAnim as any) - await k.wait(attackAnim === 'special' ? 0.35 : 0.2) - - defender.play(defenderAnim as any) - - // Hit text - const hitFx = k.get('hitText')[0] - if (hitFx) { - const words = isCritical - ? ['CRITICAL!', 'DEVASTATING!', 'BRUTAL!', 'OBLITERATED!'] - : attackAnim === 'kick' ? ['KICK!', 'ROUNDHOUSE!', 'SWEPT!'] - : attackAnim === 'special' ? ['SPECIAL!', 'HADOUKEN!', 'ZAPPED!'] - : ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!'] - hitFx.text = words[Math.floor(Math.random() * words.length)] - hitFx.pos.x = defender.pos.x + (side === 'a' ? -20 : 20) - hitFx.pos.y = defender.pos.y - 90 - hitFx.opacity = 1 - hitFx.color = isCritical ? k.Color.fromHex('#ffe14d') : attackAnim === 'special' ? k.Color.fromHex('#00f0ff') : k.Color.fromHex('#ff2d2d') - k.tween(hitFx.pos.y, hitFx.pos.y - 50, 0.8, (v) => { hitFx.pos.y = v }) - k.tween(1, 0, 1, (v) => { hitFx.opacity = v }) - } - - // Screen shake - k.shake(isCritical ? 15 : attackAnim === 'special' ? 8 : 5) - - // Knockback — push defender back - if (defenderAnim === 'knockback') { - const pushDist = direction * -60 - await k.tween(defender.pos.x, defender.pos.x + pushDist, 0.3, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) - await k.wait(0.3) - // Return defender - await k.tween(defender.pos.x, origDX, 0.4, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad) - } else { - // Flash defender - await k.wait(0.1) - defender.opacity = 0.3; await k.wait(0.05) - defender.opacity = 1; await k.wait(0.05) - defender.opacity = 0.3; await k.wait(0.05) - defender.opacity = 1 - await k.wait(0.2) - } - - // Return attacker - await k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) - - await k.wait(0.2) + // Ensure positions are correct and sprites are idle + attacker.pos.x = origAX + defender.pos.x = origDX + attacker.opacity = 1 + defender.opacity = 1 + await k.wait(0.1) attacker.play('idle') defender.play('idle') }, @@ -207,67 +3888,513 @@ export function createFightScene(config: FightSceneConfig) { async playRound(event: RoundEvent) { const aWon = event.winnerId === event.botAId const bWon = event.winnerId === event.botBId - const isCritical = Math.abs(event.botAScore - event.botBScore) > 4 - const atkAnim = pickAttackAnim(event.challengeType, isCritical) - const defAnim = pickDefenderAnim(isCritical) + const margin = Math.abs(event.botAScore - event.botBScore) + const intensity = Math.min(1, margin / 8) // 0.0 to 1.0 continuous + const isCritical = margin > 4 + // Super-speed scales with intensity + const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0)) + // Always 2-4 exchanges for variety — intensity controls winner dominance, not count + const exchangeCount = 2 + Math.floor(Math.random() * 3) + // Hyperdetail scales with intensity + const hyperDetail = Math.random() < (0.05 + intensity * 0.45) + const fA = k.get('fighterA')[0] + const fB = k.get('fighterB')[0] + const savedScaleAX = fA?.scale.x + const savedScaleAY = fA?.scale.y + const savedScaleBX = fB?.scale.x + const savedScaleBY = fB?.scale.y + + // Visual chaos: schizo cut before round (20% chance) + if (Math.random() < 0.2 && fA && fB) { + await schizoCut() + } + + if (hyperDetail && fA && fB) { + // Zoom both bots up 1.8x and shift toward center for close-up feel + const zoomFactor = 1.6 + Math.random() * 0.4 + await Promise.all([ + k.tween(Math.abs(fA.scale.x), Math.abs(fA.scale.x) * zoomFactor, 0.3, (v) => { + fA.scale.x = savedScaleAX! > 0 ? v : -v; fA.scale.y = v + }, k.easings.easeOutQuad), + k.tween(Math.abs(fB.scale.x), Math.abs(fB.scale.x) * zoomFactor, 0.3, (v) => { + fB.scale.x = savedScaleBX! > 0 ? v : -v; fB.scale.y = v + }, k.easings.easeOutQuad), + k.tween(fA.pos.x, HOME_A + (W / 2 - HOME_A) * 0.25, 0.3, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, HOME_B - (HOME_B - W / 2) * 0.25, 0.3, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + // Ren & Stimpy grotesque close-up overlays + spawnGrotesqueDetails(fA, zoomFactor * 0.6) + spawnGrotesqueDetails(fB, zoomFactor * 0.6) + // VHS tracking during hyperdetail + vhsTracking(0.5) + } + + // Super-speed: persistent speed lines during round + let speedLines: any[] = [] + if (superSpeed) { + for (let i = 0; i < 10; i++) { + const lineY = GROUND_Y - 5 - Math.random() * 130 + const line = k.add([ + k.rect(W, 1 + Math.random()), k.pos(0, lineY), + k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ffffff' : theme.accent)), + k.opacity(0.15 + Math.random() * 0.1), k.z(25), + ]) + line.onUpdate(() => { line.opacity = 0.1 + Math.sin(k.time() * 8 + i * 2) * 0.08 }) + speedLines.push(line) + } + } + + for (let ex = 0; ex < exchangeCount; ex++) { + const isLastExchange = ex === exchangeCount - 1 + // In earlier exchanges, sometimes the loser attacks back + let attackerSide: 'a' | 'b' + let exchangeCritical = false + + if (isLastExchange) { + // Final exchange: winner lands the decisive blow + attackerSide = aWon ? 'a' : bWon ? 'b' : (Math.random() > 0.5 ? 'a' : 'b') + exchangeCritical = isCritical + } else if (aWon || bWon) { + // Earlier exchanges: mix of both sides attacking + const winnerSide = aWon ? 'a' : 'b' + const loserSide = aWon ? 'b' : 'a' + // Loser hits back less at high intensity (more one-sided domination) + attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide + exchangeCritical = false + // Occasionally play a silly sound on non-decisive hits + if (Math.random() < 0.15) sfxRandomSilly() + } else { + // Draw: alternate + attackerSide = ex % 2 === 0 ? 'a' : 'b' + } + + const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round) + + if (exchangeCritical && isLastExchange) { + fanfareCritical() + // RGB glitch + hyperspeed lines on critical final blow + glitchRGB(0.3) + const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] + if (defPos) hyperSpeedLines(defPos.pos.x, defPos.pos.y - 30, 0.4) + } + + // Random scanline glitch during exchanges (25%) + if (Math.random() < 0.25) scanlineGlitch(0.2) + + // Play the exchange + if (!aWon && !bWon && isLastExchange) { + // Draw: clash in the middle + const fA = k.get('fighterA')[0] + const fB = k.get('fighterB')[0] + if (fA && fB) { + const midX = W / 2 + sfxClash() + await Promise.all([ + k.tween(fA.pos.x, midX - 30, 0.15, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, midX + 30, 0.15, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + fA.play('attack'); fB.play('attack') + await k.wait(0.1) + fA.play('hit'); fB.play('hit') + k.shake(8) + spawnSparks(midX, GROUND_Y - 50, 12, '#ffffff') + screenFlash('#ffffff', 0.08) + sfxBonk() + await k.wait(0.3) + await Promise.all([ + k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + fA.play('idle'); fB.play('idle') + } + } else { + // Occasionally the defender dodges (non-final exchanges only) + if (!isLastExchange && Math.random() < 0.15) { + await this.playDodge(attackerSide === 'a' ? 'b' : 'a') + sfxDodge() + sfxBoing() + } else { + await this.playAttack(attackerSide, choreo, exchangeCritical) + } + } + + // Brief pause between exchanges (much shorter in super-speed) + if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.3 + Math.random() * 0.3) + } + + // Clean up speed lines + speedLines.forEach(l => { if (l.exists()) l.destroy() }) + speedLines = [] + + // Clean up grotesque overlays before zooming out + destroyGrotesqueDetails() + + // Zoom back out from hyperdetail mode + if (hyperDetail && fA && fB && savedScaleAX != null && savedScaleBX != null) { + await Promise.all([ + k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => { + fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v + }, k.easings.easeInOutQuad), + k.tween(fB.scale.y, Math.abs(savedScaleBY!), 0.25, (v) => { + fB.scale.x = savedScaleBX > 0 ? v : -v; fB.scale.y = v + }, k.easings.easeInOutQuad), + k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeInOutQuad), + k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // Judge calls the round (non-blocking) + const judge = k.get('judge')[0] + if (judge) { + if (isCritical) { + judge.play('shocked') + await k.wait(0.25) + } + judge.play(aWon ? 'call_left' : bWon ? 'call_right' : 'idle') + k.wait(0.8).then(() => { if (judge.exists()) judge.play('idle') }) + } + + // Update combos if (aWon) { comboA++; comboB = 0 - await this.playAttack('a', atkAnim, defAnim, isCritical) - if (comboA >= 2) { - const ct = k.get('comboA')[0] - if (ct) { ct.text = `x${comboA} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) } - } + if (comboA >= 3) fanfareCombo(comboA) } else if (bWon) { comboB++; comboA = 0 - await this.playAttack('b', atkAnim, defAnim, isCritical) - if (comboB >= 2) { - const ct = k.get('comboB')[0] - if (ct) { ct.text = `x${comboB} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) } - } + if (comboB >= 3) fanfareCombo(comboB) } else { comboA = 0; comboB = 0 - // Draw — both take a hit - const fA = k.get('fighterA')[0] - const fB = k.get('fighterB')[0] - if (fA && fB) { - fA.play('hit'); fB.play('hit') - k.shake(3) - await k.wait(0.5) - fA.play('idle'); fB.play('idle') - } + } + + if (isCritical && (aWon || bWon)) { + fanfareDevastating() + // Dimensional shift on devastating rounds (60%) + if (Math.random() < 0.6) dimensionalShift(0.5) } }, - async playKO(winningSide: 'a' | 'b') { + async playTaunt(side: 'a' | 'b') { + const taunter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] + if (!taunter) return + const origY = taunter.pos.y + // Random taunt animation: hop, flex, or shake + const tauntType = Math.floor(Math.random() * 4) + if (tauntType === 0) { + // Victory hop + taunter.play('win') + await k.tween(taunter.pos.y, origY - 30, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeOutQuad) + await k.tween(taunter.pos.y, origY, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeInQuad) + sfxBoing() + } else if (tauntType === 1) { + // Flex / scale pulse + taunter.play('special') + const sx = taunter.scale.x + const sy = taunter.scale.y + await k.tween(1, 1.3, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeOutQuad) + await k.tween(1.3, 1, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeInQuad) + taunter.scale.x = sx + taunter.scale.y = sy + } else if (tauntType === 2) { + // Shake head side to side + const origX = taunter.pos.x + for (let i = 0; i < 3; i++) { + await k.tween(taunter.pos.x, origX + 8, 0.04, (v) => { taunter.pos.x = v }) + await k.tween(taunter.pos.x, origX - 8, 0.04, (v) => { taunter.pos.x = v }) + } + taunter.pos.x = origX + } else { + // Quick kick at the air + taunter.play('kick') + sfxDodge() + await k.wait(0.2) + } + await k.wait(0.15) + taunter.play('idle') + }, + + async playDodge(side: 'a' | 'b') { + const dodger = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] + if (!dodger) return + const origX = side === 'a' ? HOME_A : HOME_B + const origY = dodger.pos.y + const dir = side === 'a' ? -1 : 1 + // Quick hop backward + await Promise.all([ + k.tween(dodger.pos.x, origX + dir * 60, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeOutQuad), + k.tween(dodger.pos.y, origY - 60, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(dodger.pos.y, origY, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeInQuad) + ), + ]) + await k.wait(0.1) + await k.tween(dodger.pos.x, origX, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeInOutQuad) + }, + + async playKO(winningSide: 'a' | 'b', winnerName: string) { const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] if (!loser || !winner) return + const dir = winningSide === 'a' ? 1 : -1 + const loserOrigY = loser.pos.y + const contactX = loser.pos.x - dir * 45 + const finishStyle = Math.floor(Math.random() * 4) + + if (finishStyle === 0) { + // STYLE A: Classic rush-in combo + uppercut launch + sfxSpecial() + await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#ff2d2d', '#ffffff', '#ff2d7b'] + for (let i = 0; i < 6; i++) { + winner.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special') + sfxPunch() + await k.wait(0.04) + loser.play('hit') + k.shake(4 + i * 2) + spawnSparks(loser.pos.x + (Math.random() - 0.5) * 25, loser.pos.y - 15 - Math.random() * 40, 5, hitColors[i]) + loser.pos.x += dir * 5; loser.pos.y += (i % 2 === 0 ? -4 : 4) + await k.wait(0.04) + } + loser.pos.y = loserOrigY + winner.play('special'); sfxCritical(); await k.wait(0.06) + loser.play('knockback'); k.shake(20); screenFlash('#ff2d2d', 0.2) + spawnSparks(loser.pos.x, loser.pos.y - 30, 20, '#ff2d2d') + await Promise.all([ + k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, loserOrigY - 200, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, loserOrigY, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + ]) + } else if (finishStyle === 1) { + // STYLE B: Suplex finish — grab, spin overhead, slam headfirst + sfxSpecial() + const behindX = loser.pos.x + dir * 25 + await k.tween(winner.pos.x, behindX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + winner.scale.x = -winner.scale.x + winner.play('special'); sfxClash(); k.shake(5); await k.wait(0.06) + // Arc overhead + const arcCx = (winner.pos.x + loser.pos.x) / 2 + for (let i = 0; i <= 10; i++) { + const t = i / 10 + const angle = Math.PI * t + loser.pos.x = arcCx + Math.cos(angle) * 40 + loser.pos.y = GROUND_Y - Math.sin(angle) * 140 + winner.pos.x = loser.pos.x + dir * 20 + winner.pos.y = loser.pos.y + 10 + await k.wait(0.015) + } + sfxExplosion(); sfxCritical() + k.shake(25); screenFlash('#ffffff', 0.2) + spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#ff2d2d') + spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d') + loser.pos.y = loserOrigY + winner.scale.x = -winner.scale.x + } else if (finishStyle === 2) { + // STYLE C: Pinball wall-bounce finish + sfxSpecial() + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + winner.play('attack'); sfxPunch() + loser.play('hit'); k.shake(10) + spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ff2d7b') + // Bounce off walls + const wallColors = ['#ff2d7b', '#ffcc00', '#00f0ff', '#b83dff', '#39ff14'] + let lx = loser.pos.x + for (let i = 0; i < 5; i++) { + const toWall = i % 2 === (dir > 0 ? 0 : 1) ? W - 25 : 25 + sfxZoomWhoosh() + await k.tween(lx, toWall, 0.05, (v) => { loser.pos.x = v }, k.easings.easeInQuad) + lx = toWall + sfxBonk(); k.shake(8 + i * 2) + spawnSparks(toWall, loser.pos.y - 20, 8, wallColors[i]) + screenFlash(wallColors[i], 0.04) + loser.play(i % 2 === 0 ? 'hit' : 'knockback') + await k.wait(0.03) + } + sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.15) + spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d') + spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffe14d') + } else { + // STYLE D: Pocket cannon finish — pull out massive gun, obliterate + sfxSpecial() + winner.play('special'); await k.wait(0.08) + const gunLen = 80 + const gx = winner.pos.x + dir * 20 + const gy = winner.pos.y - 25 + const barrel = k.add([k.rect(gunLen, 20), k.pos(gx, gy), k.color(k.Color.fromHex('#333333')), k.opacity(1), k.z(16), k.scale(0.1)]) + await k.tween(0.1, 1, 0.12, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack) + sfxBoing() + // Fire 3 massive shots + for (let s = 0; s < 3; s++) { + sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3) + const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(18)]) + setTimeout(() => { if (flash.exists()) flash.destroy() }, 50) + await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12) + sfxBulletHit() + loser.play(s < 2 ? 'hit' : 'knockback') + spawnSparks(loser.pos.x, loser.pos.y - 20, 12, '#ff6600') + spawnShockwave(loser.pos.x, GROUND_Y, '#ff6600') + loser.pos.x += dir * 25 + await k.wait(0.05) + } + barrel.destroy() + screenFlash('#ff6600', 0.2); k.shake(20) + } + + // Judge stands up for KO call + const judgeKO = k.get('judge')[0] + if (judgeKO) { + judgeKO.play('shocked') + const judgeOrigY = judgeKO.pos.y + k.tween(judgeKO.pos.y, judgeOrigY - 25, 0.2, (v) => { judgeKO.pos.y = v }, k.easings.easeOutQuad) + k.wait(1.5).then(() => { + if (judgeKO.exists()) { + judgeKO.play(winningSide === 'a' ? 'call_left' : 'call_right') + k.tween(judgeKO.pos.y, judgeOrigY, 0.3, (v) => { judgeKO.pos.y = v }, k.easings.easeInOutQuad) + } + }) + } + + // === Common ending: KO + celebration === + sfxKO() + spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d') + spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff2d2d') + k.shake(18); sfxExplosion() + await k.wait(0.2) + loser.play('ko') + await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.25, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad) + await k.wait(0.2) + winner.play('win') + sfxWin(); sfxWinAnnounce(winnerName) + spawnSparks(winner.pos.x, winner.pos.y - 50, 15, '#ffe14d') + for (let i = 0; i < 4; i++) { + setTimeout(() => { + spawnSparks(winner.pos.x + (Math.random() - 0.5) * 60, winner.pos.y - 40 - Math.random() * 30, 8, '#ffe14d') + }, i * 200) + } + await k.wait(0.5) + }, + + async playPerfect(winningSide: 'a' | 'b', winnerName: string) { + const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] + const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] + if (!loser || !winner) return + + // Judge goes wild for a perfect + const judgePerfect = k.get('judge')[0] + if (judgePerfect) { + judgePerfect.play('shocked') + const jOrigY = judgePerfect.pos.y + k.tween(judgePerfect.pos.y, jOrigY - 35, 0.15, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad) + k.wait(0.5).then(async () => { + if (!judgePerfect.exists()) return + for (let b = 0; b < 4; b++) { + await k.tween(judgePerfect.pos.y, jOrigY - 45, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad) + await k.tween(judgePerfect.pos.y, jOrigY - 35, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeInQuad) + } + judgePerfect.play(winningSide === 'a' ? 'call_left' : 'call_right') + k.tween(judgePerfect.pos.y, jOrigY, 0.3, (v) => { judgePerfect.pos.y = v }, k.easings.easeInOutQuad) + }) + } + + sfxPerfect() + const dir = winningSide === 'a' ? 1 : -1 + const loserOrigY = loser.pos.y + const contactX = loser.pos.x - dir * 40 + + // === PHASE 1: Dramatic zoom rush into the loser === + const origScaleX = winner.scale.x + const origScaleY = winner.scale.y + sfxZoomWhoosh() + // Winner zooms at camera + await Promise.all([ + k.tween(Math.abs(origScaleX), 5, 0.2, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), + k.tween(winner.pos.x, W / 2, 0.2, (v) => { winner.pos.x = v }, k.easings.easeOutQuad), + k.tween(winner.pos.y, H * 0.6, 0.2, (v) => { winner.pos.y = v }, k.easings.easeOutQuad), + ]) + screenFlash('#000000', 0.1) + spawnGrotesqueDetails(winner, 2.5) + await k.wait(0.1) + destroyGrotesqueDetails() + // Zoom back and SLAM into loser + sfxZoomWhoosh() + await Promise.all([ + k.tween(winner.scale.y, Math.abs(origScaleY), 0.12, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), + k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeInQuad), + k.tween(winner.pos.y, loserOrigY, 0.12, (v) => { winner.pos.y = v }, k.easings.easeInQuad), + ]) + + // === PHASE 2: Devastating rapid combo at contact === + const colors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#ff6600', '#b83dff', '#39ff14', '#ffffff', '#ff2d2d'] + const hitCount = 10 + for (let i = 0; i < hitCount; i++) { + const anim = ['attack', 'kick', 'special', 'attack'][i % 4] + winner.play(anim) + sfxRapidPunch() + await k.wait(0.04) + loser.play(i < hitCount - 1 ? 'hit' : 'knockback') + k.shake(3 + i) + spawnSparks(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 10 - Math.random() * 50, 5, colors[i % colors.length]) + loser.pos.x += dir * 4 + loser.pos.y += (i % 2 === 0 ? -3 : 3) + await k.wait(0.04) + } + loser.pos.y = loserOrigY + + // === PHASE 3: Final massive hit — screen goes white === + winner.play('special') + sfxCritical() + sfxExplosion() + await k.wait(0.06) loser.play('knockback') + k.shake(35) + screenFlash('#ffe14d', 0.4) + spawnSparks(loser.pos.x, loser.pos.y - 30, 30, '#ffe14d') + spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d') + + // Launch loser way off with a spin + await Promise.all([ + k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, loserOrigY - 300, 0.25, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + ]) + spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d') + spawnSparks(loser.pos.x, GROUND_Y - 10, 30, '#ff2d2d') k.shake(20) - await k.wait(0.4) + sfxExplosion() + sfxBoing() + await k.wait(0.3) loser.play('ko') - await k.wait(0.6) - await this.showAnnouncement('K.O.!', '#ff2d2d', 2000) + + // Winner walks back and celebrates + winner.scale.x = origScaleX + winner.scale.y = origScaleY + await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad) winner.play('win') - await k.wait(0.5) + setTimeout(() => { + sfxWin() + sfxWinAnnounce(winnerName) + }, 300) + announce('Perfect!', 0.2, 0.5) + // Massive fireworks + for (let i = 0; i < 8; i++) { + setTimeout(() => { + spawnSparks( + Math.random() * W, + Math.random() * H * 0.5, + 18, + ['#ff2d7b', '#00f0ff', '#ffe14d', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i] + ) + if (i % 2 === 0) sfxRandomSilly() + }, i * 200) + } + await k.wait(0.7) }, - async playPerfect(winningSide: 'a' | 'b') { - const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] - const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] - if (!loser || !winner) return - - loser.play('knockback') - k.shake(25) - await k.wait(0.5) - loser.play('ko') - await this.showAnnouncement('PERFECT!', '#ffe14d', 2500) - winner.play('win') - }, + startMusic() { startMusic() }, + stopMusic() { stopMusic() }, destroy() { + stopMusic() k.quit() }, } diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts new file mode 100644 index 0000000..91e285b --- /dev/null +++ b/frontend/src/game/sounds.ts @@ -0,0 +1,1618 @@ +// Procedural 8-bit sound system + announcer voice using Web Audio + Speech Synthesis +let ctx: AudioContext | null = null +let musicGain: GainNode | null = null +let sfxGain: GainNode | null = null +let musicPlaying = false +let musicTimeout: number | null = null + +function getCtx(): AudioContext { + if (!ctx) { + ctx = new AudioContext() + musicGain = ctx.createGain() + musicGain.gain.value = 0.12 + musicGain.connect(ctx.destination) + sfxGain = ctx.createGain() + sfxGain.gain.value = 0.25 + sfxGain.connect(ctx.destination) + } + if (ctx.state === 'suspended') ctx.resume() + return ctx +} + +function tone(freq: number, type: OscillatorType, duration: number, dest: AudioNode, startTime?: number) { + const c = getCtx() + const osc = c.createOscillator() + const g = c.createGain() + osc.type = type + osc.frequency.value = freq + const t = startTime ?? c.currentTime + g.gain.setValueAtTime(0.3, t) + g.gain.exponentialRampToValueAtTime(0.001, t + duration) + osc.connect(g) + g.connect(dest) + osc.start(t) + osc.stop(t + duration) +} + +function noise(duration: number, dest: AudioNode, startTime?: number) { + const c = getCtx() + const bufferSize = Math.max(1, Math.floor(c.sampleRate * duration)) + 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 + const src = c.createBufferSource() + src.buffer = buffer + const g = c.createGain() + const t = startTime ?? c.currentTime + g.gain.setValueAtTime(0.4, t) + g.gain.exponentialRampToValueAtTime(0.001, t + duration) + const filter = c.createBiquadFilter() + filter.type = 'highpass' + filter.frequency.value = 800 + src.connect(filter) + filter.connect(g) + g.connect(dest) + src.start(t) + src.stop(t + duration) +} + +function sweep(startFreq: number, endFreq: number, type: OscillatorType, duration: number, dest: AudioNode) { + const c = getCtx() + const osc = c.createOscillator() + const g = c.createGain() + osc.type = type + osc.frequency.setValueAtTime(startFreq, c.currentTime) + osc.frequency.exponentialRampToValueAtTime(Math.max(1, endFreq), c.currentTime + duration) + g.gain.setValueAtTime(0.3, c.currentTime) + g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + duration) + osc.connect(g) + g.connect(dest) + osc.start() + osc.stop(c.currentTime + duration) +} + +// === ANNOUNCER VOICE SYSTEM (Speech Synthesis with multiple voice types) === + +interface VoiceProfile { + voice: SpeechSynthesisVoice | null + pitch: number + rate: number + volume: number +} + +let voicesLoaded = false +const voiceProfiles: Record = { + // Original 6 + announcer: { voice: null, pitch: 1.0, rate: 0.8, volume: 1.0 }, // Natural, authoritative announcer + hype: { voice: null, pitch: 1.1, rate: 1.4, volume: 1.0 }, // Fast excited commentator + deep: { voice: null, pitch: 0.7, rate: 0.7, volume: 1.0 }, // Lower but still clear + robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone + screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, // Frantic energy + smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, // Natural narrator + // 24 new profiles + whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper + boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming + chipmunk: { voice: null, pitch: 2.0, rate: 1.8, volume: 0.9 }, // Tiny squeaky fast + drill: { voice: null, pitch: 0.6, rate: 1.2, volume: 1.0 }, // Drill sergeant bark + surfer: { voice: null, pitch: 1.1, rate: 1.0, volume: 0.8 }, // Laid back dude + auctioneer: { voice: null, pitch: 1.0, rate: 2.0, volume: 1.0 }, // Lightning fast + preacher: { voice: null, pitch: 0.8, rate: 0.6, volume: 1.0 }, // Dramatic pause king + baby: { voice: null, pitch: 1.8, rate: 1.0, volume: 0.7 }, // High pitched cute + grandpa: { voice: null, pitch: 0.5, rate: 0.5, volume: 0.8 }, // Slow, gravelly old man + valley: { voice: null, pitch: 1.4, rate: 1.3, volume: 0.9 }, // Valley girl energy + movie: { voice: null, pitch: 0.6, rate: 0.7, volume: 1.0 }, // Movie trailer bass + sportscaster:{ voice: null, pitch: 1.0, rate: 1.5, volume: 1.0 }, // Play-by-play energy + opera: { voice: null, pitch: 0.9, rate: 0.5, volume: 1.0 }, // Dramatic operatic + punk: { voice: null, pitch: 1.3, rate: 1.3, volume: 1.0 }, // Aggressive snarl + wizard_v: { voice: null, pitch: 0.7, rate: 0.8, volume: 0.8 }, // Mystical old sage + pirate_v: { voice: null, pitch: 0.8, rate: 0.9, volume: 1.0 }, // Arr matey + alien_v: { voice: null, pitch: 1.6, rate: 0.7, volume: 0.7 }, // Otherworldly slow + cowboy_v: { voice: null, pitch: 0.9, rate: 0.8, volume: 0.9 }, // Drawl + ninja_v: { voice: null, pitch: 1.1, rate: 1.1, volume: 0.5 }, // Quiet but deadly + demon_v: { voice: null, pitch: 0.3, rate: 0.6, volume: 1.0 }, // Deepest evil + angel: { voice: null, pitch: 1.5, rate: 0.8, volume: 0.7 }, // Ethereal high + glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, // Stuttery fast + echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, // Reverb cave voice + hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, // Maximum speed maximum hype +} + +function loadVoices() { + if (typeof speechSynthesis === 'undefined') return + const voices = speechSynthesis.getVoices() + if (voices.length === 0) return + voicesLoaded = true + + // Find different English voices for variety + const enVoices = voices.filter(v => v.lang.startsWith('en')) + const anyVoices = enVoices.length > 0 ? enVoices : voices + + // Try to assign different voices to different profiles + const findVoice = (patterns: RegExp[]) => { + for (const p of patterns) { + const v = anyVoices.find(v => p.test(v.name)) + if (v) return v + } + return null + } + + // Prefer premium/enhanced voices (sound natural, not robotic) + // On macOS: "Evan (Premium)" / "Samantha (Enhanced)" / "Daniel" are best + // On Chrome: "Google US English" / "Google UK English Male" are high quality + const preferPremium = (patterns: RegExp[]) => { + // First try premium/enhanced voices + const premium = anyVoices.find(v => /premium|enhanced|natural|neural/i.test(v.name)) + if (premium) return premium + return findVoice(patterns) + } + voiceProfiles.announcer.voice = preferPremium([/evan/i, /aaron/i, /daniel/i, /google.*us.*male/i, /james/i, /male/i]) || anyVoices[0] + voiceProfiles.hype.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /karen/i, /google.*us/i, /female/i]) || anyVoices[Math.min(1, anyVoices.length - 1)] + voiceProfiles.deep.voice = findVoice([/evan/i, /aaron/i, /daniel/i, /alex/i, /tom/i]) || anyVoices[0] + voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0] + voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)] + voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)] + // Assign voices to new profiles — spread across available voices for max variety + const vLen = anyVoices.length + const pick = (i: number) => anyVoices[i % vLen] + voiceProfiles.whisper.voice = findVoice([/samantha/i, /tessa/i, /female/i]) || pick(0) + voiceProfiles.boomer.voice = findVoice([/evan/i, /tom/i, /alex/i, /male/i]) || pick(0) + voiceProfiles.chipmunk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) + voiceProfiles.drill.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0) + voiceProfiles.surfer.voice = findVoice([/oliver/i, /google.*us/i, /male/i]) || pick(2) + voiceProfiles.auctioneer.voice = findVoice([/evan/i, /daniel/i, /google.*us/i]) || pick(0) + voiceProfiles.preacher.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(1) + voiceProfiles.baby.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) + voiceProfiles.grandpa.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0) + voiceProfiles.valley.voice = findVoice([/samantha/i, /tessa/i, /karen/i]) || pick(1) + voiceProfiles.movie.voice = findVoice([/evan/i, /aaron/i, /daniel/i]) || pick(0) + voiceProfiles.sportscaster.voice = preferPremium([/evan/i, /james/i, /google.*us/i]) || pick(0) + voiceProfiles.opera.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) + voiceProfiles.punk.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) + voiceProfiles.wizard_v.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) + voiceProfiles.pirate_v.voice = findVoice([/evan/i, /alex/i, /tom/i]) || pick(0) + voiceProfiles.alien_v.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(3 % vLen) + voiceProfiles.cowboy_v.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) + voiceProfiles.ninja_v.voice = findVoice([/daniel/i, /oliver/i]) || pick(2) + voiceProfiles.demon_v.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0) + voiceProfiles.angel.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1) + voiceProfiles.glitch.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(0) + voiceProfiles.echo_v.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(2) + voiceProfiles.hyper.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1) +} + +if (typeof speechSynthesis !== 'undefined') { + speechSynthesis.onvoiceschanged = loadVoices + loadVoices() +} + +function speak(text: string, profileName: string, cancelPrevious: boolean = true, echo: boolean = false) { + if (typeof speechSynthesis === 'undefined') return + if (!voicesLoaded) loadVoices() + if (cancelPrevious) speechSynthesis.cancel() + const profile = voiceProfiles[profileName] || voiceProfiles.announcer + const utter = new SpeechSynthesisUtterance(text) + if (profile.voice) utter.voice = profile.voice + utter.pitch = profile.pitch + utter.rate = profile.rate + utter.volume = profile.volume + speechSynthesis.speak(utter) + // Echo effect: repeat at lower volume with slight delay + if (echo) { + setTimeout(() => { + const echo1 = new SpeechSynthesisUtterance(text) + if (profile.voice) echo1.voice = profile.voice + echo1.pitch = profile.pitch * 0.9 + echo1.rate = profile.rate * 0.95 + echo1.volume = profile.volume * 0.4 + speechSynthesis.speak(echo1) + }, 250) + setTimeout(() => { + const echo2 = new SpeechSynthesisUtterance(text) + if (profile.voice) echo2.voice = profile.voice + echo2.pitch = profile.pitch * 0.8 + echo2.rate = profile.rate * 0.9 + echo2.volume = profile.volume * 0.15 + speechSynthesis.speak(echo2) + }, 500) + } +} + +// Public voice functions +export function announce(text: string, pitch?: number, rate?: number) { + if (pitch !== undefined || rate !== undefined) { + // Custom params — use announcer voice with overrides + if (typeof speechSynthesis === 'undefined') return + if (!voicesLoaded) loadVoices() + speechSynthesis.cancel() + const utter = new SpeechSynthesisUtterance(text) + const profile = voiceProfiles.announcer + if (profile.voice) utter.voice = profile.voice + utter.pitch = pitch ?? 1.0 + utter.rate = rate ?? 0.8 + utter.volume = 1.0 + speechSynthesis.speak(utter) + } else { + speak(text, 'announcer') + } +} + +export function announceDeep(text: string) { speak(text, 'deep') } +export function announceFast(text: string) { speak(text, 'hype', false) } +export function announceRobot(text: string) { speak(text, 'robot') } +export function announceScream(text: string) { speak(text, 'screamer', false) } +export function announceSmooth(text: string) { speak(text, 'smooth') } + +// Pick a random voice profile for variety +const ALL_VOICE_KEYS = Object.keys(voiceProfiles) +export function announceRandom(text: string, echo: boolean = false) { + const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)] + speak(text, key, true, echo) +} +// Announce with a specific mood category +const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v'] +const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill'] +const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch'] +const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper'] +export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)], true, true) } +export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], false) } +export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) } +export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)]) } + +// Random dramatic commentary lines +const HYPE_LINES = [ + 'Unbelievable!', + 'What a hit!', + 'Incredible!', + 'Absolutely destroyed!', + 'No mercy!', + 'Is this even legal?', + 'The crowd goes wild!', + 'A display of raw power!', + 'That had to hurt!', + 'Total annihilation!', + 'Can you believe this?', + 'History in the making!', + 'Oh the humanity!', + 'Savage!', + 'Ladies and gentlemen!', +] + +const DEEP_INTROS = [ + 'In a world of machines...', + 'Only the strongest survive.', + 'This is... bot fights.', + 'Two enter. One leaves.', + 'No mercy. No remorse.', + 'The arena awaits blood.', + 'Silicon versus silicon.', +] + +const ROUND_HYPE = [ + 'Here we go!', + 'It\'s on!', + 'Let\'s go!', + 'Show me what you got!', + 'Bring it!', + 'Time to throw down!', + 'Get ready to rumble!', +] + +// Mortal Kombat style dramatic calls +export function announceFinishHim() { + speak('Finish him!', 'announcer', true, true) +} + +export function announceFatality() { + speak('Fatality!', 'deep', true, true) +} + +export function announceFlawlessVictory() { + speak('Flawless victory!', 'deep', true, true) +} + +export function announceRandomHype() { + const line = HYPE_LINES[Math.floor(Math.random() * HYPE_LINES.length)] + // Randomly pick voice type for variety + const voices = [announceFast, announceScream, announce, announceSmooth] + voices[Math.floor(Math.random() * voices.length)](line) +} + +export function announceDeepIntro() { + const line = DEEP_INTROS[Math.floor(Math.random() * DEEP_INTROS.length)] + announceDeep(line) +} + +export function announceRoundHype() { + const line = ROUND_HYPE[Math.floor(Math.random() * ROUND_HYPE.length)] + const voices = [announceFast, announce, announceScream] + voices[Math.floor(Math.random() * voices.length)](line) +} + +// === FANFARES (8-bit melodic announcements) === + +export function fanfareRound(roundNum: number) { + const d = sfxGain ?? getCtx().destination + const c = getCtx() + const t = c.currentTime + // Ascending power chord + tone(196, 'square', 0.15, d, t) // G3 + tone(262, 'square', 0.15, d, t + 0.12) // C4 + tone(330, 'square', 0.15, d, t + 0.24) // E4 + tone(392, 'square', 0.25, d, t + 0.36) // G4 + noise(0.08, d, t + 0.36) + // Announce after fanfare + setTimeout(() => announce(`Round ${roundNum}`, 0.4, 0.8), 400) +} + +export function fanfareFight() { + const d = sfxGain ?? getCtx().destination + const c = getCtx() + const t = c.currentTime + // Punchy staccato + tone(523, 'square', 0.08, d, t) + tone(659, 'square', 0.08, d, t + 0.08) + tone(784, 'square', 0.15, d, t + 0.16) + noise(0.1, d, t + 0.16) + setTimeout(() => announce('Fight!', 0.3, 1.1), 200) +} + +export function fanfareDevastating() { + const d = sfxGain ?? getCtx().destination + const c = getCtx() + const t = c.currentTime + tone(220, 'sawtooth', 0.2, d, t) + tone(175, 'sawtooth', 0.3, d, t + 0.15) + noise(0.15, d, t + 0.1) + setTimeout(() => speak('Devastating!', 'deep', true, true), 150) +} + +export function fanfareCritical() { + const d = sfxGain ?? getCtx().destination + const c = getCtx() + const t = c.currentTime + tone(440, 'square', 0.1, d, t) + tone(554, 'square', 0.1, d, t + 0.08) + tone(659, 'square', 0.1, d, t + 0.16) + tone(880, 'square', 0.2, d, t + 0.24) + noise(0.12, d, t + 0.24) + setTimeout(() => announce('Critical hit!', 0.3, 1.0), 300) +} + +export function fanfareCombo(count: number) { + const d = sfxGain ?? getCtx().destination + const c = getCtx() + const t = c.currentTime + for (let i = 0; i < Math.min(count, 5); i++) { + tone(440 + i * 80, 'square', 0.08, d, t + i * 0.06) + } + if (count >= 3) setTimeout(() => announce(`${count} hit combo!`, 0.4, 1.0), 200) +} + +// === MODERN SFX === +// Layered synthesis: body (low-end thump) + crack (mid snap) + air (filtered noise) + tail (reverb-like decay) + +// Create a short convolution-style reverb tail +function reverbTail(duration: number, dest: AudioNode, startTime?: number) { + const c = getCtx() + const t = startTime ?? c.currentTime + const len = Math.max(1, Math.floor(c.sampleRate * duration)) + const buf = c.createBuffer(2, len, c.sampleRate) + for (let ch = 0; ch < 2; ch++) { + const d = buf.getChannelData(ch) + for (let i = 0; i < len; i++) { + d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (len * 0.3)) + } + } + const src = c.createBufferSource(); src.buffer = buf + const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2500 + const g = c.createGain() + g.gain.setValueAtTime(0.08, t) + g.gain.exponentialRampToValueAtTime(0.001, t + duration) + src.connect(lp); lp.connect(g); g.connect(dest) + src.start(t); src.stop(t + duration) +} + +// Distorted body thump — sub-bass punch with waveshaping +function bodyThump(freq: number, duration: number, dest: AudioNode, startTime?: number, vol: number = 0.25) { + const c = getCtx() + const t = startTime ?? c.currentTime + // Sub oscillator + const sub = c.createOscillator(); sub.type = 'sine'; sub.frequency.setValueAtTime(freq, t) + sub.frequency.exponentialRampToValueAtTime(Math.max(20, freq * 0.3), t + duration) + // Waveshaper for warmth + const dist = c.createWaveShaper() + const curve = new Float32Array(256) + for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = Math.tanh(x * 3) } + dist.curve = curve + const g = c.createGain() + g.gain.setValueAtTime(vol, t) + g.gain.setValueAtTime(vol * 0.8, t + duration * 0.1) + g.gain.exponentialRampToValueAtTime(0.001, t + duration) + sub.connect(dist); dist.connect(g); g.connect(dest) + sub.start(t); sub.stop(t + duration) +} + +// Crispy high-end snap/crack +function highSnap(freq: number, duration: number, dest: AudioNode, startTime?: number) { + const c = getCtx() + const t = startTime ?? c.currentTime + const bufSize = Math.max(1, Math.floor(c.sampleRate * duration)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 2 + const g = c.createGain() + g.gain.setValueAtTime(0.2, t) + g.gain.exponentialRampToValueAtTime(0.001, t + duration) + src.connect(bp); bp.connect(g); g.connect(dest) + src.start(t); src.stop(t + duration) +} + +// FM impact — metallic ring with modulation +function fmImpact(carrier: number, modRatio: number, duration: number, dest: AudioNode, startTime?: number) { + const c = getCtx() + const t = startTime ?? c.currentTime + const mod = c.createOscillator(); mod.type = 'sine' + mod.frequency.value = carrier * modRatio + const modG = c.createGain(); modG.gain.value = carrier * 2 + mod.connect(modG) + const car = c.createOscillator(); car.type = 'sine'; car.frequency.value = carrier + modG.connect(car.frequency) + const g = c.createGain() + g.gain.setValueAtTime(0.15, t) + g.gain.exponentialRampToValueAtTime(0.001, t + duration) + car.connect(g); g.connect(dest) + car.start(t); car.stop(t + duration) + mod.start(t); mod.stop(t + duration) +} + +export function sfxPunch() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Heavy body thump + bodyThump(120, 0.12, d, t, 0.3) + // Mid-range crack + highSnap(2200, 0.04, d, t) + // Knuckle noise + noise(0.03, d, t) + // Short reverb tail + reverbTail(0.15, d, t + 0.03) +} + +export function sfxKick() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Deep bass slam + bodyThump(80, 0.18, d, t, 0.35) + // Leather slap + highSnap(3000, 0.03, d, t) + highSnap(1500, 0.05, d, t + 0.01) + // Air displacement + noise(0.06, d, t) + reverbTail(0.2, d, t + 0.04) +} + +export function sfxSpecial() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Power charge sweep + sweep(150, 800, 'sawtooth', 0.2, d) + // FM metallic shimmer + fmImpact(600, 3.5, 0.3, d, t) + // Rising high-end + highSnap(4000, 0.08, d, t + 0.1) + // Energy release + bodyThump(100, 0.15, d, t + 0.15, 0.2) + noise(0.1, d, t + 0.15) + reverbTail(0.4, d, t + 0.1) +} + +export function sfxCritical() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Massive bass hit + bodyThump(60, 0.3, d, t, 0.4) + // Double crack + highSnap(3500, 0.05, d, t) + highSnap(5000, 0.03, d, t + 0.02) + // FM distortion ring + fmImpact(200, 7, 0.25, d, t + 0.03) + // Noise burst + noise(0.15, d, t) + // Second wave + setTimeout(() => { + bodyThump(90, 0.2, d) + sweep(200, 600, 'sawtooth', 0.2, d) + noise(0.1, d) + }, 80) + // Long reverb tail + reverbTail(0.6, d, t + 0.05) +} + +export function sfxGunshot() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Sharp transient + highSnap(6000, 0.015, d, t) + // Body kick + bodyThump(200, 0.06, d, t, 0.3) + // Gunpowder noise burst + noise(0.04, d, t) + // Shell casing ring + fmImpact(2000, 1.5, 0.08, d, t + 0.03) + reverbTail(0.25, d, t + 0.02) +} + +export function sfxBulletHit() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + bodyThump(150, 0.06, d, t, 0.2) + highSnap(4000, 0.02, d, t) + fmImpact(800, 2.5, 0.06, d, t) + reverbTail(0.12, d, t + 0.02) +} + +export function sfxJetpack() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Turbine rumble — layered oscillators with modulation + const osc1 = c.createOscillator(); osc1.type = 'sawtooth' + osc1.frequency.setValueAtTime(60, t) + osc1.frequency.linearRampToValueAtTime(120, t + 0.2) + osc1.frequency.linearRampToValueAtTime(80, t + 0.5) + const osc2 = c.createOscillator(); osc2.type = 'square' + osc2.frequency.setValueAtTime(90, t) + osc2.frequency.linearRampToValueAtTime(180, t + 0.2) + osc2.frequency.linearRampToValueAtTime(100, t + 0.5) + const lp = c.createBiquadFilter(); lp.type = 'lowpass' + lp.frequency.setValueAtTime(400, t); lp.frequency.linearRampToValueAtTime(1200, t + 0.2) + lp.frequency.linearRampToValueAtTime(600, t + 0.5) + const g = c.createGain() + g.gain.setValueAtTime(0.01, t) + g.gain.linearRampToValueAtTime(0.18, t + 0.1) + g.gain.setValueAtTime(0.15, t + 0.3) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.6) + osc1.connect(lp); osc2.connect(lp); lp.connect(g); g.connect(d) + osc1.start(t); osc1.stop(t + 0.6) + osc2.start(t); osc2.stop(t + 0.6) + // Noise layer for roar + noise(0.5, d, t) +} + +export function sfxExplosion() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Massive sub thump + bodyThump(40, 0.5, d, t, 0.4) + // Shrapnel noise burst + highSnap(2000, 0.08, d, t) + highSnap(5000, 0.05, d, t + 0.01) + noise(0.2, d, t) + // Fireball sweep + sweep(300, 30, 'sawtooth', 0.4, d) + // Debris rattle + fmImpact(400, 5, 0.3, d, t + 0.05) + // Secondary boom + setTimeout(() => { + bodyThump(50, 0.3, d) + noise(0.15, d) + }, 120) + // Long reverb + reverbTail(0.8, d, t + 0.05) +} + +export function sfxBlock() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Metallic shield ring + fmImpact(800, 1.5, 0.12, d, t) + fmImpact(1200, 2, 0.08, d, t + 0.02) + highSnap(3000, 0.03, d, t) + reverbTail(0.2, d, t + 0.02) +} + +export function sfxDodge() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Quick air whoosh + sweep(300, 1200, 'sine', 0.1, d) + noise(0.06, d, t) + // Cloth rustle + highSnap(4000, 0.04, d, t + 0.02) +} + +export function sfxClash() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Two impacts overlapping + bodyThump(100, 0.1, d, t, 0.25) + fmImpact(600, 3, 0.15, d, t) + fmImpact(900, 2, 0.12, d, t + 0.02) + highSnap(3500, 0.04, d, t) + noise(0.08, d, t) + reverbTail(0.3, d, t + 0.03) +} + +// === KO + WIN === + +export function sfxKO() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Earth-shaking bass + bodyThump(30, 0.6, d, t, 0.5) + // Multi-layer impact + highSnap(2000, 0.06, d, t) + noise(0.25, d, t) + fmImpact(150, 5, 0.4, d, t) + // Second slam + setTimeout(() => { + bodyThump(40, 0.4, d) + noise(0.15, d) + highSnap(3000, 0.04, d) + }, 180) + // Heavy reverb + reverbTail(1.0, d, t + 0.05) + setTimeout(() => speak('K. O.!', 'announcer', true, true), 500) +} + +export function sfxPerfect() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Massive descending power + bodyThump(30, 0.8, d, t, 0.45) + noise(0.3, d, t) + fmImpact(300, 7, 0.5, d, t) + sweep(800, 30, 'sawtooth', 0.6, d) + // Then ascending triumph chord + setTimeout(() => { + tone(262, 'square', 0.2, d); tone(262, 'triangle', 0.2, d) // C4 + tone(330, 'square', 0.2, d); tone(330, 'triangle', 0.2, d) // E4 + tone(392, 'square', 0.3, d); tone(392, 'triangle', 0.3, d) // G4 + fmImpact(523, 1.5, 0.3, d) // C5 shimmer + }, 400) + reverbTail(1.2, d, t + 0.1) + setTimeout(() => announce('Perfect!', 0.2, 0.5), 800) +} + +export function sfxWin() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Victory jingle — richer with harmonics and FM shimmer + const melody = [ + [262, 0.12], [330, 0.12], [392, 0.12], + [523, 0.2], [392, 0.1], [440, 0.1], [523, 0.1], + [659, 0.25], [523, 0.1], [587, 0.1], [659, 0.1], + [784, 0.4], + ] as [number, number][] + let offset = 0 + for (const [freq, dur] of melody) { + tone(freq, 'square', dur + 0.05, d, t + offset) + tone(freq * 0.5, 'triangle', dur + 0.05, d, t + offset) + tone(freq * 1.005, 'sawtooth', dur + 0.03, d, t + offset) // chorus detune + fmImpact(freq, 2, dur * 0.5, d, t + offset) // shimmer + offset += dur + } + // Cymbal crash + bass + noise(0.4, d, t + offset - 0.1) + bodyThump(60, 0.3, d, t + offset - 0.1, 0.2) + reverbTail(0.8, d, t + offset) +} + +export function sfxWinAnnounce(winnerName: string) { + setTimeout(() => announce(`${winnerName} wins!`, 0.3, 0.7), 200) +} + +export function sfxRoundStart() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Bell-like tones with FM shimmer + fmImpact(440, 2, 0.2, d, t) + fmImpact(554, 2, 0.2, d, t + 0.15) + fmImpact(659, 2, 0.25, d, t + 0.3) + tone(440, 'triangle', 0.15, d, t) + tone(554, 'triangle', 0.15, d, t + 0.15) + tone(659, 'triangle', 0.2, d, t + 0.3) + reverbTail(0.3, d, t + 0.3) +} + +// === SILLY / FUNNY SOUNDS === + +export function sfxBoing() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Spring with resonance + sweep(200, 900, 'sine', 0.12, d) + fmImpact(400, 3, 0.1, d, t) + setTimeout(() => { sweep(600, 350, 'sine', 0.08, d); fmImpact(500, 2, 0.06, d) }, 80) + setTimeout(() => sweep(400, 550, 'sine', 0.06, d), 140) +} + +export function sfxWomp() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Sad trombone — richer with vibrato + const notes = [[294, 0.25], [277, 0.25], [262, 0.25], [247, 0.5]] as [number, number][] + let off = 0 + for (const [freq, dur] of notes) { + tone(freq, 'sawtooth', dur, d, t + off) + tone(freq * 0.5, 'triangle', dur, d, t + off) + off += dur + } + reverbTail(0.6, d, t + off) +} + +export function sfxSlideUp() { + const d = sfxGain ?? getCtx().destination + sweep(200, 2000, 'sine', 0.25, d) + sweep(210, 2100, 'sine', 0.25, d) // chorus +} + +export function sfxSlideDown() { + const d = sfxGain ?? getCtx().destination + sweep(2000, 100, 'sine', 0.35, d) + sweep(2020, 110, 'sine', 0.35, d) +} + +export function sfxBonk() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + bodyThump(200, 0.06, d, t, 0.2) + fmImpact(800, 3, 0.06, d, t) + highSnap(5000, 0.02, d, t) + reverbTail(0.1, d, t + 0.02) +} + +export function sfxSplat() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + bodyThump(100, 0.1, d, t, 0.2) + noise(0.12, d, t) + highSnap(1500, 0.06, d, t) + sweep(400, 80, 'sine', 0.1, d) + reverbTail(0.2, d, t + 0.05) +} + +export function sfxZap() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Electric arc + sweep(100, 4000, 'sawtooth', 0.06, d) + fmImpact(1000, 7, 0.1, d, t) + setTimeout(() => { sweep(2000, 300, 'square', 0.08, d); fmImpact(800, 5, 0.08, d) }, 40) + setTimeout(() => sweep(600, 5000, 'sawtooth', 0.05, d), 80) + highSnap(6000, 0.03, d, t) +} + +export function sfxZoomWhoosh() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + // Layered Doppler whoosh + sweep(80, 1800, 'sawtooth', 0.2, d) + sweep(100, 2200, 'sine', 0.18, d) // higher layer + noise(0.15, d, t) + // Doppler pass + setTimeout(() => { + sweep(1800, 150, 'sawtooth', 0.12, d) + sweep(2200, 200, 'sine', 0.1, d) + }, 150) + reverbTail(0.3, d, t + 0.15) +} + +export function sfxRapidPunch() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 4; i++) { + const ht = t + i * 0.04 + bodyThump(120 - i * 10, 0.04, d, ht, 0.15) + highSnap(2500 + i * 500, 0.02, d, ht) + noise(0.02, d, ht) + } +} + +export function sfxPowerUp() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 6; i++) { + const freq = 300 + i * 100 + fmImpact(freq, 2, 0.1, d, t + i * 0.06) + tone(freq, 'triangle', 0.08, d, t + i * 0.06) + } + reverbTail(0.3, d, t + 0.3) +} + +export function sfxCoin() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + fmImpact(988, 1.5, 0.1, d, t) + fmImpact(1319, 1.5, 0.15, d, t + 0.06) + tone(988, 'triangle', 0.06, d, t) + tone(1319, 'triangle', 0.12, d, t + 0.06) +} + +export function sfxFail() { + const d = sfxGain ?? getCtx().destination + const c = getCtx(); const t = c.currentTime + sweep(600, 80, 'sawtooth', 0.25, d) + bodyThump(80, 0.2, d, t + 0.15, 0.15) + setTimeout(() => noise(0.08, d), 180) + reverbTail(0.3, d, t + 0.2) +} + +// Pick a random silly sound +export function sfxRandomSilly() { + const fns = [sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp] + fns[Math.floor(Math.random() * fns.length)]() +} + +// === BACKGROUND MUSIC === +// Rich, full arcade fighting soundtrack with 6 simultaneous layers + +const MUSIC_BPM = 170 +const MUSIC_BEAT = 60 / MUSIC_BPM +const BARS = 8 +const STEPS = 16 // per bar + +// Shared delay effect for fullness +let delayNode: DelayNode | null = null +let delayGain: GainNode | null = null + +function getMusicDelay(): GainNode { + if (delayGain && delayNode) return delayGain + const c = getCtx() + delayNode = c.createDelay(0.5) + delayNode.delayTime.value = 0.18 // 8th note delay + delayGain = c.createGain() + delayGain.gain.value = 0.25 + const feedback = c.createGain() + feedback.gain.value = 0.3 + delayNode.connect(feedback) + feedback.connect(delayNode) // feedback loop + delayNode.connect(delayGain) + delayGain.connect(musicGain!) + return delayGain +} + +// Chorus tone: 2 detuned oscillators for width +function chorusTone(freq: number, type: OscillatorType, dur: number, dest: AudioNode, t: number, vol: number = 0.2) { + const c = getCtx() + for (const detune of [-8, 8]) { // slight detune for stereo width + const osc = c.createOscillator() + osc.type = type + osc.frequency.value = freq + osc.detune.value = detune + const g = c.createGain() + g.gain.setValueAtTime(vol, t) + g.gain.exponentialRampToValueAtTime(0.001, t + dur) + osc.connect(g) + g.connect(dest) + osc.start(t) + osc.stop(t + dur) + } +} + +// FM lead with 2-operator FM + detuned chorus +function fmLead(freq: number, dur: number, dest: AudioNode, t: number) { + const c = getCtx() + // Carrier + const car = c.createOscillator() + car.type = 'sawtooth' + car.frequency.value = freq + // Modulator + const mod = c.createOscillator() + mod.type = 'sine' + mod.frequency.value = freq * 2 + const modG = c.createGain() + modG.gain.value = 150 + mod.connect(modG) + modG.connect(car.frequency) + // Detuned double for width + const car2 = c.createOscillator() + car2.type = 'sawtooth' + car2.frequency.value = freq + car2.detune.value = 10 + // Output + const g = c.createGain() + g.gain.setValueAtTime(0.16, t) + g.gain.setValueAtTime(0.16, t + dur * 0.7) + g.gain.exponentialRampToValueAtTime(0.001, t + dur) + car.connect(g) + car2.connect(g) + g.connect(dest) + // Send to delay for fullness + if (delayNode) g.connect(delayNode) + car.start(t); car.stop(t + dur) + car2.start(t); car2.stop(t + dur) + mod.start(t); mod.stop(t + dur) +} + +// Thick distorted sub-bass with overtones +function thickBass(freq: number, dur: number, dest: AudioNode, t: number) { + const c = getCtx() + // Sub + const sub = c.createOscillator() + sub.type = 'sine' + sub.frequency.value = freq / 2 + const subG = c.createGain() + subG.gain.setValueAtTime(0.25, t) + subG.gain.exponentialRampToValueAtTime(0.001, t + dur) + sub.connect(subG); subG.connect(dest) + sub.start(t); sub.stop(t + dur) + // Main with distortion + const osc = c.createOscillator() + osc.type = 'sawtooth' + osc.frequency.value = freq + const dist = c.createWaveShaper() + const curve = new Float32Array(256) + for (let i = 0; i < 256; i++) { const x = (i / 128) - 1; curve[i] = (Math.PI + 6) * x / (Math.PI + 6 * Math.abs(x)) } + dist.curve = curve + const g = c.createGain() + g.gain.setValueAtTime(0.2, t) + g.gain.exponentialRampToValueAtTime(0.001, t + dur) + osc.connect(dist); dist.connect(g); g.connect(dest) + osc.start(t); osc.stop(t + dur) + // Octave up for bite + const oct = c.createOscillator() + oct.type = 'square' + oct.frequency.value = freq * 2 + const octG = c.createGain() + octG.gain.setValueAtTime(0.06, t) + octG.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.5) + oct.connect(octG); octG.connect(dest) + oct.start(t); oct.stop(t + dur) +} + +// Chord pad for harmonic fullness +function chordPad(freqs: number[], dur: number, dest: AudioNode, t: number) { + const c = getCtx() + for (const freq of freqs) { + const osc = c.createOscillator() + osc.type = 'triangle' + osc.frequency.value = freq + const g = c.createGain() + g.gain.setValueAtTime(0.04, t) + g.gain.setValueAtTime(0.04, t + dur * 0.8) + g.gain.exponentialRampToValueAtTime(0.001, t + dur) + osc.connect(g); g.connect(dest) + osc.start(t); osc.stop(t + dur) + } +} + +// Heavy layered drums +function kick(dest: AudioNode, t: number) { + const c = getCtx() + // Body + const o = c.createOscillator(); o.type = 'sine' + o.frequency.setValueAtTime(150, t) + o.frequency.exponentialRampToValueAtTime(40, t + 0.12) + const g = c.createGain() + g.gain.setValueAtTime(0.35, t) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.15) + o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.15) + // Click + noise(0.015, dest, t) + // Sub thump + const s = c.createOscillator(); s.type = 'sine'; s.frequency.value = 50 + const sg = c.createGain() + sg.gain.setValueAtTime(0.2, t) + sg.gain.exponentialRampToValueAtTime(0.001, t + 0.1) + s.connect(sg); sg.connect(dest); s.start(t); s.stop(t + 0.1) +} + +function snare(dest: AudioNode, t: number) { + noise(0.08, dest, t) + const c = getCtx() + const o = c.createOscillator(); o.type = 'triangle'; o.frequency.value = 200 + const g = c.createGain() + g.gain.setValueAtTime(0.2, t) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.06) + o.connect(g); g.connect(dest); o.start(t); o.stop(t + 0.06) + // Body + tone(180, 'square', 0.03, dest, t) +} + +function hihat(dest: AudioNode, t: number, open: boolean = false) { + const c = getCtx() + const dur = open ? 0.08 : 0.025 + const bufSize = Math.max(1, Math.floor(c.sampleRate * dur)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const d = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) d[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = open ? 6000 : 8000 + const g = c.createGain() + g.gain.setValueAtTime(open ? 0.12 : 0.08, t) + g.gain.exponentialRampToValueAtTime(0.001, t + dur) + src.connect(hp); hp.connect(g); g.connect(dest) + src.start(t); src.stop(t + dur) +} + +// === TRACK DATA === +// Each track has: bass, lead, arp patterns (128 steps = 8 bars x 16 steps), chords (8 bars), drums (128 steps), bpm + +interface MusicTrack { + name: string + bpm: number + bass: number[] + lead: number[] + arp: number[] + chords: number[][] + drums: number[] +} + +// Track 1: "Neon Fury" — Cm, relentless, 225bpm +const track1: MusicTrack = { + name: 'Neon Fury', bpm: 225, + bass: [ + 131,165,196,131, 165,196,220,196, 131,196,220,262, 220,196,165,131, + 208,262,311,208, 262,311,349,311, 196,262,330,262, 311,262,208,196, + 156,208,262,311, 262,208,156,208, 196,262,330,392, 330,262,196,262, + 196,247,294,330, 294,247,196,247, 262,330,392,440, 392,330,262,196, + 131,131,165,196, 220,262,220,196, 208,208,262,311, 349,311,262,208, + 156,156,208,262, 311,349,311,262, 196,196,262,330, 392,440,392,330, + 131,196,262,330, 392,330,262,196, 208,311,415,466, 415,311,262,208, + 196,262,330,392, 440,523,440,392, 262,330,440,523, 587,523,440,330, + ], + lead: [ + 523,622,784,622, 523,622,784,1047, 784,622,523,622, 784,1047,784,622, + 831,784,622,523, 622,784,831,1047, 1175,1047,831,784, 622,784,831,1047, + 622,784,831,1047, 831,784,622,784, 831,1047,1175,1319, 1175,1047,831,784, + 784,831,1047,1319, 1175,1047,831,784, 1047,1319,1568,1760, 1568,1319,1047,831, + 523,622,784,1047, 784,622,523,466, 622,784,1047,1319, 1047,784,622,523, + 831,784,622,784, 831,1047,1175,1319, 1175,1047,831,784, 622,784,1047,1175, + 784,1047,1319,1568, 1319,1047,784,622, 1047,1319,1568,1760, 1568,1319,1047,784, + 1047,1319,1568,1760, 2093,1760,1568,1319, 1568,1760,2093,1760, 1568,1319,1047,831, + ], + arp: [ + 262,330,392,523, 392,330,262,330, 392,523,662,523, 392,330,262,330, + 415,523,622,831, 622,523,415,523, 622,831,1047,831, 622,523,415,523, + 311,392,466,622, 466,392,311,392, 466,622,831,622, 466,392,311,392, + 392,494,587,784, 587,494,392,494, 587,784,1047,784, 587,494,392,494, + 262,392,523,784, 523,392,262,392, 415,523,622,831, 831,622,523,415, + 311,466,622,831, 622,466,311,466, 392,587,784,1047, 1047,784,587,392, + 262,523,784,1047, 1319,1047,784,523, 415,622,831,1175, 1175,831,622,415, + 392,784,1047,1319, 1568,1319,1047,784, 523,1047,1319,1568, 1760,1568,1319,1047, + ], + chords: [[131,156,196],[208,262,311],[156,196,233],[196,247,294],[131,156,196],[156,196,233],[208,262,311],[196,247,294]], + drums: [ + 1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3, 1,3,1,3, 2,3,1,1, 1,1,4,3, 2,1,1,3, + 1,3,1,3, 2,1,1,3, 1,1,4,1, 2,1,1,1, 1,1,1,3, 2,1,4,1, 2,1,1,1, 2,2,1,1, + 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,2,1, + 1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,1, 2,2,2,1, 2,2,2,2, 2,2,2,2, + ], +} + +// Track 2: "Dark Circuit" — Em, relentless grind, 235bpm +const track2: MusicTrack = { + name: 'Dark Circuit', bpm: 235, + bass: [ + 82,110,82,110, 82,110,147,110, 110,147,110,147, 110,82,110,82, + 98,131,98,131, 98,131,165,131, 131,165,131,165, 131,98,131,98, + 82,82,110,147, 165,147,110,82, 98,98,131,165, 196,165,131,98, + 110,147,165,196, 165,147,110,82, 131,165,196,220, 196,165,131,98, + 82,110,147,196, 147,110,82,110, 98,131,165,220, 165,131,98,131, + 82,110,147,165, 196,165,147,110, 98,131,165,196, 220,196,165,131, + 82,147,196,220, 247,220,196,147, 98,165,220,247, 294,247,220,165, + 110,147,196,247, 294,330,294,247, 82,110,196,247, 330,294,247,196, + ], + lead: [ + 659,784,880,784, 659,784,880,1047, 784,880,1047,880, 784,659,784,880, + 784,880,1047,880, 784,880,1047,1175, 1047,1175,1319,1175, 1047,880,784,880, + 659,784,880,1047, 880,784,659,784, 880,1047,1175,1319, 1175,1047,880,784, + 880,1047,1175,1319, 1175,1047,880,784, 1047,1175,1319,1568, 1319,1175,1047,880, + 659,784,880,1047, 1175,1047,880,784, 784,880,1047,1175, 1319,1175,1047,880, + 880,1047,1175,1047, 880,784,880,1047, 1047,1175,1319,1175, 1047,880,1047,1175, + 659,880,1175,1568, 1175,880,659,880, 784,1047,1319,1760, 1319,1047,784,1047, + 880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 1760,1568,1319,1047, + ], + arp: [ + 330,392,494,659, 494,392,330,392, 494,659,880,659, 494,392,330,392, + 392,494,587,784, 587,494,392,494, 587,784,1047,784, 587,494,392,494, + 330,494,659,880, 1175,880,659,494, 392,587,784,1047, 1319,1047,784,587, + 494,659,880,1175, 1175,880,659,494, 587,784,1047,1319, 1319,1047,784,587, + 330,392,494,659, 880,659,494,392, 392,494,587,784, 1047,784,587,494, + 330,494,659,880, 659,494,330,494, 392,587,784,1047, 784,587,392,587, + 330,659,880,1175, 1568,1175,880,659, 392,784,1047,1319, 1568,1319,1047,784, + 494,880,1175,1568, 1760,1568,1175,880, 330,880,1175,1760, 2093,1760,1175,880, + ], + chords: [[165,196,247],[196,247,294],[131,165,196],[147,175,220],[165,196,247],[131,165,196],[196,247,294],[147,175,220]], + drums: [ + 1,3,1,3, 2,3,1,1, 1,3,1,3, 2,1,1,3, 1,1,1,3, 2,3,1,1, 1,1,4,1, 2,1,1,1, + 1,3,1,1, 2,1,1,3, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1, + 1,1,1,3, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, + 1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,2, 2,2,2,1, 2,2,2,2, 2,2,2,2, + ], +} + +// Track 3: "Pixel Blitz" — F major, frantic energy, 245bpm +const track3: MusicTrack = { + name: 'Pixel Blitz', bpm: 245, + bass: [ + 175,220,262,175, 220,262,330,262, 233,294,349,233, 294,349,440,349, + 208,262,330,208, 262,330,392,330, 262,330,392,262, 330,392,440,392, + 175,175,220,262, 330,262,220,175, 233,233,294,349, 440,349,294,233, + 208,208,262,330, 392,330,262,208, 262,330,392,440, 523,440,392,330, + 175,220,262,330, 392,330,262,220, 233,294,349,440, 523,440,349,294, + 208,262,330,392, 440,392,330,262, 262,330,392,440, 523,587,523,440, + 175,262,349,440, 523,440,349,262, 233,349,440,523, 587,523,440,349, + 208,330,440,523, 587,523,440,330, 175,349,440,523, 587,698,587,523, + ], + lead: [ + 698,784,880,784, 698,784,880,1047, 880,1047,1175,1047, 880,784,698,784, + 784,880,1047,880, 784,880,1047,1175, 1047,1175,1319,1175, 1047,880,784,880, + 698,784,880,1047, 1175,1047,880,784, 880,1047,1175,1319, 1568,1319,1175,1047, + 1047,1175,1319,1568, 1319,1175,1047,880, 1175,1319,1568,1760, 1568,1319,1175,1047, + 698,880,1047,1319, 1047,880,698,880, 784,1047,1175,1568, 1175,1047,784,1047, + 880,1047,1175,1047, 880,784,880,1047, 1175,1319,1568,1319, 1175,1047,1175,1319, + 698,880,1175,1568, 1760,1568,1175,880, 784,1047,1319,1760, 2093,1760,1319,1047, + 880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 2349,2093,1760,1319, + ], + arp: [ + 349,440,523,698, 523,440,349,440, 523,698,932,698, 523,440,349,440, + 466,587,698,932, 698,587,466,587, 698,932,1175,932, 698,587,466,587, + 415,523,659,880, 1175,880,659,523, 523,659,784,1047, 1319,1047,784,659, + 523,659,784,1047, 1319,1047,784,659, 698,880,1047,1319, 1568,1319,1047,880, + 349,523,698,932, 1175,932,698,523, 466,698,932,1175, 1568,1175,932,698, + 415,659,880,1175, 1568,1175,880,659, 523,784,1047,1319, 1760,1319,1047,784, + 349,698,1047,1319, 1568,1319,1047,698, 466,932,1175,1568, 1760,1568,1175,932, + 523,1047,1319,1760, 2093,1760,1319,1047, 698,1175,1568,2093, 2349,2093,1568,1175, + ], + chords: [[175,220,262],[233,294,349],[208,262,330],[262,330,392],[175,220,262],[208,262,330],[233,294,349],[262,330,392]], + drums: [ + 1,1,1,3, 2,1,1,3, 1,1,1,3, 2,1,4,1, 1,1,1,3, 2,1,1,1, 1,1,4,1, 2,1,1,1, + 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1, + 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, + 1,1,1,1, 2,2,4,1, 1,1,1,1, 2,2,4,2, 1,1,2,2, 2,2,2,2, 2,2,2,2, 2,2,2,2, + ], +} + +// Track 4: "Skull Crusher" — Am, insane thrash, 260bpm +const track4: MusicTrack = { + name: 'Skull Crusher', bpm: 260, + bass: [ + 110,131,110,131, 110,131,165,131, 131,165,131,165, 131,110,131,110, + 98,110,98,131, 131,165,131,110, 110,131,165,196, 165,131,110,131, + 110,110,131,165, 196,220,196,165, 98,98,131,165, 196,220,196,165, + 110,131,165,196, 220,247,220,196, 98,131,165,196, 220,247,294,247, + 110,131,165,196, 220,196,165,131, 98,110,131,165, 196,165,131,110, + 110,110,131,165, 196,220,247,220, 98,98,131,165, 196,247,294,247, + 110,131,196,247, 294,330,294,247, 98,131,196,247, 330,349,330,247, + 110,165,220,294, 330,349,330,294, 110,165,247,330, 392,349,330,247, + ], + lead: [ + 880,1047,880,1047, 1175,1047,880,1047, 1047,1175,1047,1175, 1319,1175,1047,1175, + 784,880,784,880, 1047,880,784,880, 880,1047,880,1047, 1175,1047,880,1047, + 880,1047,1175,1319, 1175,1047,880,1047, 1047,1175,1319,1568, 1319,1175,1047,1175, + 1175,1319,1568,1760, 1568,1319,1175,1047, 1319,1568,1760,2093, 1760,1568,1319,1175, + 880,1047,1175,1319, 1568,1319,1175,1047, 784,880,1047,1175, 1319,1175,1047,880, + 880,1047,1319,1568, 1319,1047,880,1047, 1047,1175,1568,1760, 1568,1175,1047,1175, + 880,1175,1568,1760, 2093,1760,1568,1175, 1047,1319,1760,2093, 2349,2093,1760,1319, + 1175,1568,2093,2349, 2093,1760,1568,1175, 880,1319,1760,2349, 2637,2349,1760,1319, + ], + arp: [ + 220,262,330,440, 587,440,330,262, 196,247,294,392, 523,392,294,247, + 220,330,440,587, 784,587,440,330, 196,294,392,523, 698,523,392,294, + 220,330,440,587, 880,587,440,330, 196,294,392,523, 784,523,392,294, + 220,440,587,880, 1175,880,587,440, 196,392,523,784, 1047,784,523,392, + 220,262,330,440, 587,440,330,262, 196,247,294,392, 523,392,294,247, + 220,330,440,587, 880,587,440,330, 196,294,392,523, 784,523,392,294, + 220,440,880,1175, 1568,1175,880,440, 196,392,784,1047, 1568,1047,784,392, + 220,440,880,1568, 2093,1568,880,440, 220,880,1175,1760, 2349,1760,1175,880, + ], + chords: [[110,131,165],[98,131,147],[131,165,196],[147,175,220],[110,131,165],[131,165,196],[98,131,147],[147,175,220]], + drums: [ + 1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, + 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 2,1,1,1, 2,2,1,1, + 1,1,1,1, 2,1,1,1, 1,1,1,1, 2,1,4,1, 1,1,1,1, 2,1,1,1, 1,1,4,1, 2,1,1,1, + 1,1,1,1, 2,2,4,2, 1,1,1,1, 2,2,4,2, 2,2,2,2, 2,2,2,2, 2,2,2,2, 2,2,2,2, + ], +} + +// === TRACK GENERATOR === +// Procedurally generate tracks from musical parameters for variety +function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number): MusicTrack { + // Deterministic RNG + let s = seed + const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff } + + // Build scale note lookup: root freq across octaves + const allNotes: number[] = [] + for (let oct = -1; oct < 6; oct++) { + for (const semi of scaleIntervals) { + allNotes.push(Math.round(root * Math.pow(2, oct + semi / 12))) + } + } + const sLen = scaleIntervals.length + const note = (degree: number) => { + const idx = Math.max(0, Math.min(allNotes.length - 1, degree + sLen)) // offset by 1 octave + return allNotes[idx] + } + + // Bass: degrees 0-6, mostly stepwise, bar-level progressions + const bassProgs = [ + [0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0], + [0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2], + [0,0,0,2, 4,4,2,0, 3,3,3,5, 7,5,3,2], + [0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0], + ] + const bass: number[] = [] + for (let bar = 0; bar < BARS; bar++) { + const prog = bassProgs[bar % bassProgs.length] + const lift = Math.floor(bar / 2) // gradually ascend + for (let step = 0; step < STEPS; step++) { + bass.push(note(prog[step % prog.length] + lift)) + } + } + + // Lead: degrees 7-20 (octave 2-4), melodic contours + const leadShapes = [ + [0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,-2], + [0,4,7,11, 9,7,4,2, 0,4,9,11, 14,11,9,7], + [7,9,11,14, 11,9,7,4, 9,11,14,16, 14,11,9,7], + [0,2,4,2, 7,4,2,0, 4,7,9,11, 9,7,4,2], + ] + const lead: number[] = [] + for (let bar = 0; bar < BARS; bar++) { + const shape = leadShapes[bar % leadShapes.length] + const octShift = sLen + (bar >= 4 ? sLen : 0) // higher octave in second half + for (let step = 0; step < STEPS; step++) { + const degree = shape[step % shape.length] + octShift + // Add slight variation + const vary = rng() < 0.15 ? (rng() < 0.5 ? 1 : -1) : 0 + lead.push(note(Math.max(sLen, degree + vary))) + } + } + + // Arp: degrees in octave 1-3, arpeggiated patterns + const arpPatterns = [ + [0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,2], + [0,4,7,11, 7,4,0,4, 7,11,14,11, 7,4,0,4], + [0,7,4,11, 7,14,11,7, 4,11,7,14, 11,4,7,0], + ] + const arp: number[] = [] + for (let bar = 0; bar < BARS; bar++) { + const pat = arpPatterns[bar % arpPatterns.length] + const shift = Math.floor(sLen * 0.5) + Math.floor(bar / 3) + for (let step = 0; step < STEPS; step++) { + arp.push(note(pat[step % pat.length] + shift)) + } + } + + // Chords: triads from scale degrees + const chordDegs = [0, 3, 2, 4, 0, 5, 3, 4] + const chords: number[][] = [] + for (const d of chordDegs) { + chords.push([note(d), note(d + 2), note(d + 4)]) + } + + // Drums: base pattern repeated, with builds in later bars + const drums: number[] = [] + for (let bar = 0; bar < BARS; bar++) { + for (let step = 0; step < STEPS; step++) { + let d = drumStyle[step % drumStyle.length] + // Build: add fills in bars 6-7 + if (bar >= 6 && step >= 12 && d === 3) d = 2 + if (bar >= 7 && step >= 14) d = d === 3 ? 2 : d + // Extra hats in later bars + if (bar >= 4 && d === 0 && rng() < 0.2) d = 3 + drums.push(d) + } + } + + return { name, bpm, bass, lead, arp, chords, drums } +} + +// Drum pattern presets +const DRUMS_HEAVY = [1,3,1,3, 2,3,1,3, 1,3,1,3, 2,3,4,3] +const DRUMS_GROOVE = [1,0,3,1, 2,0,3,0, 1,0,3,1, 2,0,3,3] +const DRUMS_CHILL = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,0,0,3] +const DRUMS_FRANTIC = [1,1,1,3, 2,1,1,1, 1,1,4,1, 2,1,1,1] +const DRUMS_MARCH = [1,0,1,0, 2,0,1,0, 1,0,1,0, 2,0,4,0] +const DRUMS_SWING = [1,0,3,0, 2,3,0,3, 1,0,3,1, 2,0,3,0] +const DRUMS_HALFTIME = [1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0] +const DRUMS_DNB = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,3,1,3] + +// Scale presets (semitone intervals) +const SCALE_MINOR = [0,2,3,5,7,8,10] +const SCALE_MAJOR = [0,2,4,5,7,9,11] +const SCALE_DORIAN = [0,2,3,5,7,9,10] +const SCALE_BLUES = [0,3,5,6,7,10] +const SCALE_PHRYGIAN = [0,1,3,5,7,8,10] +const SCALE_MIXOLYDIAN = [0,2,4,5,7,9,10] +const SCALE_HARMMINOR = [0,2,3,5,7,8,11] +const SCALE_PENTATONIC = [0,2,4,7,9] +const SCALE_JAPANESE = [0,1,5,7,8] +const SCALE_ARABIC = [0,1,4,5,7,8,11] + +// Track 5: "Chill Lounge" — C major, relaxed, 155bpm +const track5 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42) + +// Track 6: "Cyber Punk" — Bb minor, aggressive, 250bpm +const track6 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99) + +// Track 7: "Retro Arcade" — G major, bouncy, 195bpm +const track7 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137) + +// Track 8: "Boss Battle" — D minor, epic, 240bpm +const track8 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256) + +// Track 9: "Jazz Fusion" — Eb dorian, smooth, 175bpm +const track9 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333) + +// Track 10: "Metal Mayhem" — E phrygian, thrash, 270bpm +const track10 = genTrack('Metal Mayhem', 270, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666) + +// Track 11: "Tropical Storm" — C mixolydian, upbeat, 185bpm +const track11 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808) + +// Track 12: "Haunted Circus" — Bb harmonic minor, creepy, 200bpm +const track12 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313) + +// Track 13: "Space Opera" — Ab major, majestic, 165bpm +const track13 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001) + +// Track 14: "Funk Machine" — D mixolydian, groovy, 210bpm +const track14 = genTrack('Funk Machine', 210, 147, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 420) + +// Track 15: "Viking Raid" — A minor, epic march, 225bpm +const track15 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793) + +// Track 16: "Synthwave Dream" — F minor, dreamy, 170bpm +const track16 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984) + +// Track 17: "Drum & Bass" — G minor, frantic, 255bpm +const track17 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174) + +// Track 18: "Western Duel" — E blues, twangy, 190bpm +const track18 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SWING, 1865) + +// Track 19: "Samurai Storm" — B japanese, intense, 245bpm +const track19 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603) + +// Track 20: "Disco Inferno" — Bb major, groovy, 215bpm +const track20 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977) + +const ALL_TRACKS = [track1, track2, track3, track4, track5, track6, track7, track8, track9, track10, track11, track12, track13, track14, track15, track16, track17, track18, track19, track20] +let activeTrack: MusicTrack = track1 +let barIndex = 0 + +function playMusicBar() { + if (!musicPlaying || !musicGain) return + const c = getCtx() + getMusicDelay() // ensure delay is created + + // Dynamic tempo: base BPM shifts with intensity (+30 BPM at max) + const dynamicBpm = activeTrack.bpm + currentIntensity * 30 + const beat = 60 / dynamicBpm + const now = c.currentTime + 0.05 + const bar = barIndex % BARS + const off = bar * STEPS + const step = beat / 2 + + // Switch tracks at bar boundaries based on intensity + // Categorize tracks by energy level + const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185) // Chill Lounge, Space Opera, Synthwave Dream, Jazz Fusion, Tropical Storm + const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230) // Retro Arcade, Western Duel, Haunted Circus, Funk Machine, Disco Inferno, Neon Fury, Viking Raid + const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230) // Dark Circuit, Pixel Blitz, Skull Crusher, Cyber Punk, Boss Battle, Metal Mayhem, Samurai Storm, Drum & Bass + if (barIndex > 0 && bar === 0) { + if (currentIntensity > 0.7 && activeTrack.bpm < 230) { + activeTrack = intenseTracks[Math.floor(Math.random() * intenseTracks.length)] + } else if (currentIntensity < 0.3 && activeTrack.bpm >= 200) { + activeTrack = chillTracks[Math.floor(Math.random() * chillTracks.length)] + } else if (currentIntensity >= 0.3 && currentIntensity <= 0.7 && Math.random() < 0.3) { + activeTrack = midTracks[Math.floor(Math.random() * midTracks.length)] + } else if (Math.random() < 0.2) { + // Random switch for variety + const others = ALL_TRACKS.filter(t => t !== activeTrack) + activeTrack = others[Math.floor(Math.random() * others.length)] + } + } + + // Chord pad for the whole bar + const barDur = STEPS * step + chordPad(activeTrack.chords[bar].map(f => f * 2), barDur, musicGain, now) + + for (let i = 0; i < STEPS; i++) { + const t = now + i * step + const idx = off + i + const nd = step - 0.01 + + // Layer 1: Bass — always plays but gets thicker with intensity + if (activeTrack.bass[idx] > 0) thickBass(activeTrack.bass[idx], nd, musicGain, t) + + // Layer 2: FM lead — fades in above 0.3 intensity + if (activeTrack.lead[idx] > 0 && currentIntensity > 0.3) { + fmLead(activeTrack.lead[idx], nd * 0.8, musicGain, t) + } + + // Layer 3: Arpeggio — fades in above 0.5 intensity + if (activeTrack.arp[idx] > 0 && currentIntensity > 0.5) { + chorusTone(activeTrack.arp[idx], 'triangle', nd * 0.6, musicGain, t, 0.04 + currentIntensity * 0.04) + } + + // Layer 4: Drums — always, but intensity controls density + const d = activeTrack.drums[idx] + if (d === 1) kick(musicGain, t) + if (d === 2 && currentIntensity > 0.2) snare(musicGain, t) + if (d === 3) hihat(musicGain, t, false) + if (d === 4 && currentIntensity > 0.4) hihat(musicGain, t, true) + + // Extra percussion at high intensity + if (currentIntensity > 0.8 && i % 2 === 0 && Math.random() < 0.3) { + hihat(musicGain, t, false) + } + } + + barIndex++ + musicTimeout = window.setTimeout(playMusicBar, barDur * 1000 - 50) +} + +export function startMusic() { + getCtx() + if (musicPlaying) return + // Pick a random track each time + activeTrack = ALL_TRACKS[Math.floor(Math.random() * ALL_TRACKS.length)] + musicPlaying = true + barIndex = 0 + playMusicBar() +} + +export function stopMusic() { + musicPlaying = false + if (musicTimeout) { + clearTimeout(musicTimeout) + musicTimeout = null + } + // Clean up delay + if (delayNode) { delayNode.disconnect(); delayNode = null } + if (delayGain) { delayGain.disconnect(); delayGain = null } +} + +export function setMusicVolume(v: number) { + if (musicGain) musicGain.gain.value = Math.max(0, Math.min(1, v)) +} + +export function setSfxVolume(v: number) { + if (sfxGain) sfxGain.gain.value = Math.max(0, Math.min(1, v)) +} + +// === CROWD SOUNDS === +// Procedural crowd reactions using layered noise + filtered tones + +export function sfxCrowdOoh() { + const c = getCtx() + const d = sfxGain ?? c.destination + const t = c.currentTime + // Rising "ooh" — filtered noise sweep + for (let i = 0; i < 3; i++) { + const osc = c.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(200 + i * 60, t) + osc.frequency.linearRampToValueAtTime(350 + i * 80, t + 0.4) + const g = c.createGain() + g.gain.setValueAtTime(0.06, t) + g.gain.linearRampToValueAtTime(0.12, t + 0.15) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.6) + osc.connect(g); g.connect(d) + osc.start(t + i * 0.03); osc.stop(t + 0.6) + } + noise(0.3, d, t) +} + +export function sfxCrowdGasp() { + const c = getCtx() + const d = sfxGain ?? c.destination + const t = c.currentTime + // Sharp intake — high noise burst + quick sine chirps + const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.15)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2000; bp.Q.value = 0.8 + const g = c.createGain() + g.gain.setValueAtTime(0.18, t) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.2) + src.connect(bp); bp.connect(g); g.connect(d) + src.start(t); src.stop(t + 0.2) + // Multiple pitched gasps + for (let i = 0; i < 4; i++) { + const osc = c.createOscillator() + osc.type = 'sine' + osc.frequency.value = 400 + Math.random() * 300 + const og = c.createGain() + og.gain.setValueAtTime(0.04, t + i * 0.02) + og.gain.exponentialRampToValueAtTime(0.001, t + 0.25) + osc.connect(og); og.connect(d) + osc.start(t + i * 0.02); osc.stop(t + 0.25) + } +} + +export function sfxCrowdCheer() { + const c = getCtx() + const d = sfxGain ?? c.destination + const t = c.currentTime + // Layered noise + sine clusters = roaring crowd + for (let layer = 0; layer < 3; layer++) { + const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.8)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const bp = c.createBiquadFilter(); bp.type = 'bandpass' + bp.frequency.value = 600 + layer * 400; bp.Q.value = 0.5 + const g = c.createGain() + g.gain.setValueAtTime(0.01, t) + g.gain.linearRampToValueAtTime(0.1, t + 0.15) + g.gain.setValueAtTime(0.1, t + 0.5) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.8) + src.connect(bp); bp.connect(g); g.connect(d) + src.start(t + layer * 0.05); src.stop(t + 0.8) + } +} + +export function sfxApplause() { + const c = getCtx() + const d = sfxGain ?? c.destination + const t = c.currentTime + // Crackling filtered noise = many hands clapping + for (let burst = 0; burst < 6; burst++) { + const delay = burst * 0.12 + Math.random() * 0.05 + const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.06)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3000 + Math.random() * 2000 + const g = c.createGain() + g.gain.setValueAtTime(0.08 + Math.random() * 0.04, t + delay) + g.gain.exponentialRampToValueAtTime(0.001, t + delay + 0.08) + src.connect(hp); hp.connect(g); g.connect(d) + src.start(t + delay); src.stop(t + delay + 0.08) + } +} + +export function sfxDrumRoll() { + const c = getCtx() + const d = sfxGain ?? c.destination + const t = c.currentTime + // Rapid snare hits building in intensity + const hits = 16 + for (let i = 0; i < hits; i++) { + const hitTime = t + i * 0.04 + const vol = 0.05 + (i / hits) * 0.15 + const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.03)) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i2 = 0; i2 < bufSize; i2++) data[i2] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const g = c.createGain() + g.gain.setValueAtTime(vol, hitTime) + g.gain.exponentialRampToValueAtTime(0.001, hitTime + 0.04) + src.connect(g); g.connect(d) + src.start(hitTime); src.stop(hitTime + 0.04) + // Body tone + tone(180 + i * 5, 'triangle', 0.025, d, hitTime) + } +} + +export function announceCrowdReaction(type: 'cheer' | 'gasp' | 'ooh' | 'applause') { + const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause } + fns[type]() +} + +// === MUSIC INTENSITY === +// Dynamically adjust music volume/energy based on fight state + +let currentIntensity = 0.5 + +export function setMusicIntensity(level: number) { + // level: 0.0 (calm) to 1.0 (maximum hype) + currentIntensity = Math.max(0, Math.min(1, level)) + if (!musicGain) return + const baseVol = 0.06 + const maxVol = 0.18 + const targetVol = baseVol + (maxVol - baseVol) * currentIntensity + const c = getCtx() + musicGain.gain.cancelScheduledValues(c.currentTime) + musicGain.gain.setTargetAtTime(targetVol, c.currentTime, 0.3) +} diff --git a/frontend/src/game/sprites/archetypes/alien.ts b/frontend/src/game/sprites/archetypes/alien.ts new file mode 100644 index 0000000..a7fc503 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/alien.ts @@ -0,0 +1,71 @@ +import type { Archetype } from '../constants' + +export const alien: Archetype = { + name: 'alien', + weight: 0.03, + dimensionOverrides: (tier) => ({ + hw: 13 + tier, + hh: 11 + tier, + bw: 8 + tier * 2, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, knockback, frame, + hx, hy, hh, hw, cx, hOff, by } = p + + // Big oval eyes (huge, almond-shaped) + if (!ko) { + const eyeY = hy + Math.floor(hh * 0.3) + const leX = hx + 1 + const reX = hx + Math.floor(hw * 0.55) + const eyeW = Math.floor(hw * 0.35) + const eyeH = Math.floor(hh * 0.3) + // Large dark eyes + for (let ey = eyeY; ey < eyeY + eyeH; ey++) { + const progress = (ey - eyeY) / eyeH + const rowW = Math.floor(eyeW * (1 - Math.abs(progress - 0.5) * 1.5)) + for (let ex = 0; ex < rowW; ex++) { + px(leX + ex + Math.floor((eyeW - rowW) / 2), ey, '#112211', ox, oy) + px(reX + ex + Math.floor((eyeW - rowW) / 2), ey, '#112211', ox, oy) + } + } + // Glowing pupil + const pupY = eyeY + Math.floor(eyeH / 2) + px(leX + Math.floor(eyeW / 2), pupY, '#44ff44', ox, oy) + px(reX + Math.floor(eyeW / 2), pupY, '#44ff44', ox, oy) + // Pupil flicker + if (frame % 3 === 0) { + px(leX + Math.floor(eyeW / 2), pupY, '#88ff88', ox, oy) + px(reX + Math.floor(eyeW / 2), pupY, '#88ff88', ox, oy) + } + } + + // Antenna (single, glowing tip) + if (!ko) { + const antX = cx + hOff + const antBase = hy - 1 + for (let a = 0; a < 4; a++) { + const wobble = idle ? Math.round(Math.sin(t * Math.PI * 3 + a * 0.5) * 1) : 0 + px(antX + wobble, antBase - a, '#44aa44', ox, oy) + } + // Glowing tip + px(antX, antBase - 4, '#88ff88', ox, oy) + if (idle && Math.sin(t * Math.PI * 6) > 0) { + px(antX - 1, antBase - 4, '#44ff44', ox, oy) + px(antX + 1, antBase - 4, '#44ff44', ox, oy) + } + } + + // Small slit mouth + if (!ko) { + const mY = hy + Math.floor(hh * 0.7) + px(cx + hOff - 1, mY, '#224422', ox, oy) + px(cx + hOff, mY, '#224422', ox, oy) + px(cx + hOff + 1, mY, '#224422', ox, oy) + } + + // Green tinted body patches + for (let iy = by + 1; iy < by + p.bh; iy += 3) { + px(p.bx + 1, iy, '#33aa55', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/batch_animals2.ts b/frontend/src/game/sprites/archetypes/batch_animals2.ts new file mode 100644 index 0000000..6348ca2 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/batch_animals2.ts @@ -0,0 +1,355 @@ +import type { Archetype } from '../constants' + +export const elephant: Archetype = { + name: 'elephant', weight: 0.02, + dimensionOverrides: (tier) => ({ legW: 6 + tier, legH: 6 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Big ears + for (let iy = hy; iy < hy + hh; iy++) { + px(hx - 2, iy, '#999999', ox, oy); px(hx - 3, iy, '#888888', ox, oy) + px(hx + hw + 1, iy, '#999999', ox, oy); px(hx + hw + 2, iy, '#888888', ox, oy) + } + // Trunk + const trunkWave = idle ? Math.sin(t * Math.PI * 2) * 2 : 0 + for (let i = 0; i < 5; i++) { + px(hx + Math.floor(hw / 2) + Math.round(trunkWave * (i / 5)), hy + hh + i, '#999999', ox, oy) + } + // Tusks + px(hx + 1, hy + hh - 1, '#ffffcc', ox, oy); px(hx + 1, hy + hh, '#ffffcc', ox, oy) + px(hx + hw - 2, hy + hh - 1, '#ffffcc', ox, oy); px(hx + hw - 2, hy + hh, '#ffffcc', ox, oy) + }, +} + +export const giraffe: Archetype = { + name: 'giraffe', weight: 0.02, + dimensionOverrides: () => ({ legH: 10 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Spots on body + const spots = [[bx + 2, by + 1], [bx + bw - 3, by + 2], [bx + 1, by + bh - 3], [bx + bw - 2, by + bh - 2]] + for (const [sx, sy] of spots) { + px(sx, sy, '#aa7722', ox, oy); px(sx + 1, sy, '#aa7722', ox, oy) + px(sx, sy + 1, '#aa7722', ox, oy) + } + // Ossicones (little horns) + px(hx + 2, hy - 1, '#886633', ox, oy); px(hx + 2, hy - 2, '#ffcc88', ox, oy) + px(hx + hw - 3, hy - 1, '#886633', ox, oy); px(hx + hw - 3, hy - 2, '#ffcc88', ox, oy) + // Long eyelashes + px(hx + 1, hy + Math.floor(hh * 0.3), '#000000', ox, oy) + px(hx + hw - 2, hy + Math.floor(hh * 0.3), '#000000', ox, oy) + }, +} + +export const hippo: Archetype = { + name: 'hippo', weight: 0.02, + dimensionOverrides: (tier) => ({ legW: 6 + tier }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw } = p + if (ko) return + // Wide mouth (when idle, mouth opens) + const mouthOpen = idle ? Math.sin(t * Math.PI) > 0.5 : false + if (mouthOpen) { + for (let iy = hy + Math.floor(hh * 0.6); iy < hy + hh + 2; iy++) { + for (let ix = hx; ix < hx + hw; ix++) px(ix, iy, '#ff6688', ox, oy) + } + // Teeth + px(hx + 1, hy + Math.floor(hh * 0.6), '#ffffff', ox, oy) + px(hx + hw - 2, hy + Math.floor(hh * 0.6), '#ffffff', ox, oy) + } + // Small ears on top + px(hx + 1, hy - 1, '#998877', ox, oy); px(hx + hw - 2, hy - 1, '#998877', ox, oy) + // Nostrils + px(hx + Math.floor(hw / 2) - 1, hy + Math.floor(hh * 0.5), '#553344', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy + Math.floor(hh * 0.5), '#553344', ox, oy) + }, +} + +export const lion: Archetype = { + name: 'lion', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Mane (circle of fur around head) + for (let angle = 0; angle < 12; angle++) { + const a = angle * Math.PI * 2 / 12 + const mx = hx + Math.floor(hw / 2) + Math.round(Math.cos(a) * (hw / 2 + 2)) + const my = hy + Math.floor(hh / 2) + Math.round(Math.sin(a) * (hh / 2 + 2)) + px(mx, my, '#cc8822', ox, oy) + } + // Nose + px(hx + Math.floor(hw / 2), hy + Math.floor(hh * 0.55), '#332211', ox, oy) + // Tail with tuft + px(bx + bw + 1, by + bh - 2, '#ccaa44', ox, oy) + px(bx + bw + 2, by + bh - 3, '#ccaa44', ox, oy) + px(bx + bw + 3, by + bh - 3, '#cc8822', ox, oy) + px(bx + bw + 3, by + bh - 4, '#cc8822', ox, oy) + }, +} + +export const monkey: Archetype = { + name: 'monkey', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Big round ears + for (let d = -1; d <= 1; d++) { + px(hx - 2, hy + Math.floor(hh / 2) + d, '#cc9966', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2) + d, '#cc9966', ox, oy) + } + px(hx - 2, hy + Math.floor(hh / 2), '#ffaa88', ox, oy) // inner + px(hx + hw + 1, hy + Math.floor(hh / 2), '#ffaa88', ox, oy) + // Curled tail + const curl = idle ? Math.sin(t * Math.PI * 2) * 2 : 0 + px(bx + bw + 1, by + bh - 1, '#886644', ox, oy) + px(bx + bw + 2, by + bh - 2 + Math.round(curl), '#886644', ox, oy) + px(bx + bw + 3, by + bh - 1 + Math.round(curl), '#886644', ox, oy) + px(bx + bw + 3, by + bh + Math.round(curl), '#886644', ox, oy) + // Belly patch + px(bx + Math.floor(bw / 2), by + Math.floor(bh / 2), '#ffcc99', ox, oy) + px(bx + Math.floor(bw / 2) + 1, by + Math.floor(bh / 2), '#ffcc99', ox, oy) + }, +} + +export const parrot: Archetype = { + name: 'parrot', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Colorful feather crest + const feathers = ['#ff0000', '#ffaa00', '#ffff00', '#00ff00', '#0088ff'] + for (let i = 0; i < feathers.length; i++) px(hx + Math.floor(hw / 2), hy - 1 - i, feathers[i], ox, oy) + // Curved beak + px(hx + hw, hy + Math.floor(hh / 2), '#ff8800', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ff6600', ox, oy) + // Colorful body stripes + px(bx + 1, by + 1, '#ff0000', ox, oy); px(bx + 2, by + 1, '#ff0000', ox, oy) + px(bx + 1, by + 3, '#00ff00', ox, oy); px(bx + 2, by + 3, '#00ff00', ox, oy) + px(bx + 1, by + 5, '#0088ff', ox, oy); px(bx + 2, by + 5, '#0088ff', ox, oy) + // Tail feathers + const wave = idle ? Math.sin(t * Math.PI * 2) * 2 : 0 + px(bx + bw + 1, by + bh - 2, '#ff0000', ox, oy) + px(bx + bw + 2, by + bh - 1 + Math.round(wave), '#00ff00', ox, oy) + px(bx + bw + 3, by + bh + Math.round(wave), '#0088ff', ox, oy) + }, +} + +export const raccoon: Archetype = { + name: 'raccoon', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Mask (black around eyes) + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + px(ix, hy + Math.floor(hh * 0.3), '#222222', ox, oy) + px(ix, hy + Math.floor(hh * 0.4), '#222222', ox, oy) + } + // Pointy ears + px(hx + 1, hy - 1, '#888877', ox, oy); px(hx + hw - 2, hy - 1, '#888877', ox, oy) + // Striped tail + for (let i = 0; i < 6; i++) { + const color = i % 2 === 0 ? '#888877' : '#333322' + px(bx + bw + 1 + Math.floor(i / 2), by + bh - 2 + (i % 3), color, ox, oy) + } + // Tiny hands + px(bx - 1, by + bh, '#444444', ox, oy); px(bx + bw, by + bh, '#444444', ox, oy) + }, +} + +export const snakeArch: Archetype = { + name: 'snake', weight: 0.02, + dimensionOverrides: () => ({ legH: 2, legW: 4 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Forked tongue + if (idle && Math.sin(t * Math.PI * 4) > 0) { + px(hx + hw, hy + Math.floor(hh / 2), '#ff0044', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2) - 1, '#ff0044', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ff0044', ox, oy) + } + // Diamond pattern on body + for (let ix = bx + 1; ix < bx + bw - 1; ix += 3) { + px(ix, by + Math.floor(bh / 2), '#ffcc44', ox, oy) + } + // Slit eyes + px(hx + 2, hy + Math.floor(hh * 0.35), '#ffcc00', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ffcc00', ox, oy) + // Coiled body underneath + const coil = Math.sin(t * Math.PI * 2) * 2 + px(bx + 2, by + bh + 1 + Math.round(coil), '#448833', ox, oy) + px(bx + bw - 3, by + bh + 1 - Math.round(coil), '#448833', ox, oy) + }, +} + +export const turtle: Archetype = { + name: 'turtle', weight: 0.02, + dimensionOverrides: () => ({ legH: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p + if (ko) return + // Shell (dome on back) + for (let iy = by - 1; iy < by + bh + 1; iy++) { + for (let ix = bx - 1; ix < bx + bw + 1; ix++) px(ix, iy, '#448833', ox, oy) + } + // Shell pattern + px(bx + Math.floor(bw / 2), by + Math.floor(bh / 2), '#336622', ox, oy) + px(bx + 2, by + 2, '#336622', ox, oy); px(bx + bw - 3, by + 2, '#336622', ox, oy) + px(bx + 2, by + bh - 3, '#336622', ox, oy); px(bx + bw - 3, by + bh - 3, '#336622', ox, oy) + // Beak + px(hx + hw, hy + Math.floor(hh / 2), '#aaaa44', ox, oy) + }, +} + +export const whale: Archetype = { + name: 'whale', weight: 0.02, + dimensionOverrides: () => ({ legH: 2 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Belly (lighter underbelly) + for (let ix = bx + 1; ix < bx + bw - 1; ix++) { + px(ix, by + bh - 2, '#aabbcc', ox, oy); px(ix, by + bh - 1, '#99aabb', ox, oy) + } + // Tail fluke + px(bx + bw + 1, by + Math.floor(bh / 2) - 2, '#6688aa', ox, oy) + px(bx + bw + 2, by + Math.floor(bh / 2) - 3, '#6688aa', ox, oy) + px(bx + bw + 1, by + Math.floor(bh / 2) + 2, '#6688aa', ox, oy) + px(bx + bw + 2, by + Math.floor(bh / 2) + 3, '#6688aa', ox, oy) + // Spout (when idle) + if (idle && Math.sin(t * Math.PI * 2) > 0.5) { + for (let h = 0; h < 4; h++) { + px(cx + hOff, hy - 2 - h, '#aaddff', ox, oy) + } + px(cx + hOff - 1, hy - 5, '#aaddff', ox, oy) + px(cx + hOff + 1, hy - 5, '#aaddff', ox, oy) + } + // Tiny eye + px(hx + 1, hy + Math.floor(hh * 0.4), '#222222', ox, oy) + }, +} + +export const crocodile: Archetype = { + name: 'crocodile', weight: 0.02, + dimensionOverrides: () => ({ legH: 4 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Long snout + px(hx + hw, hy + Math.floor(hh / 2), '#557744', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2), '#557744', ox, oy) + px(hx + hw + 2, hy + Math.floor(hh / 2), '#446633', ox, oy) + // Teeth + px(hx + hw, hy + Math.floor(hh / 2) + 1, '#ffffff', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ffffff', ox, oy) + // Scaly back ridge + for (let ix = bx + 1; ix < bx + bw; ix += 2) { + px(ix, by - 1, '#446633', ox, oy) + } + // Thick tail + px(bx + bw + 1, by + bh - 2, '#557744', ox, oy) + px(bx + bw + 2, by + bh - 1, '#557744', ox, oy) + px(bx + bw + 3, by + bh, '#557744', ox, oy) + px(bx + bw + 4, by + bh, '#446633', ox, oy) + }, +} + +export const flamingo: Archetype = { + name: 'flamingo', weight: 0.02, + dimensionOverrides: () => ({ legH: 10, legW: 2 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Curved beak + px(hx + hw, hy + Math.floor(hh / 2), '#ff8844', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#222222', ox, oy) + // Wing (folded, pink gradient) + for (let iy = by + 1; iy < by + bh - 1; iy++) { + px(bx + bw, iy, '#ff88aa', ox, oy) + px(bx + bw + 1, iy, '#ff6699', ox, oy) + } + // Feather tuft on tail + px(bx + bw + 1, by + bh - 1, '#ff44aa', ox, oy) + px(bx + bw + 2, by + bh, '#ff44aa', ox, oy) + // Pink body tint + for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) { + px(ix, by + 1, '#ffaacc', ox, oy) + } + }, +} + +export const hedgehog: Archetype = { + name: 'hedgehog', weight: 0.02, + dimensionOverrides: () => ({ legH: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p + if (ko) return + // Spines on back + for (let ix = bx; ix < bx + bw; ix += 2) { + px(ix, by - 1, '#886644', ox, oy); px(ix, by - 2, '#aa8866', ox, oy) + } + for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) { + px(ix, by - 1, '#775533', ox, oy) + } + // Cute nose + px(hx + hw, hy + Math.floor(hh * 0.6), '#222222', ox, oy) + // Small round ears + px(hx + 1, hy - 1, '#ccaa88', ox, oy); px(hx + hw - 2, hy - 1, '#ccaa88', ox, oy) + // Tiny feet visible + px(bx, by + bh + 1, '#ccaa88', ox, oy); px(bx + bw - 1, by + bh + 1, '#ccaa88', ox, oy) + }, +} + +export const panda: Archetype = { + name: 'panda', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Black eye patches + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + px(hx + 2 + dx, hy + Math.floor(hh * 0.35) + dy, '#000000', ox, oy) + px(hx + hw - 3 + dx, hy + Math.floor(hh * 0.35) + dy, '#000000', ox, oy) + } + } + // Round ears + px(hx, hy - 1, '#000000', ox, oy); px(hx + 1, hy - 1, '#000000', ox, oy) + px(hx + hw - 1, hy - 1, '#000000', ox, oy); px(hx + hw - 2, hy - 1, '#000000', ox, oy) + // White belly patch + for (let iy = by + 2; iy < by + bh - 1; iy++) { + px(bx + Math.floor(bw / 2), iy, '#ffffff', ox, oy) + px(bx + Math.floor(bw / 2) + 1, iy, '#ffffff', ox, oy) + } + // Black arms/legs coloring + px(bx, by, '#000000', ox, oy); px(bx + bw - 1, by, '#000000', ox, oy) + }, +} + +export const hamster: Archetype = { + name: 'hamster', weight: 0.02, + dimensionOverrides: () => ({ legH: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw } = p + if (ko) return + // Puffy cheeks + px(hx - 1, hy + Math.floor(hh * 0.5), '#ffcc99', ox, oy) + px(hx - 2, hy + Math.floor(hh * 0.5), '#ffbb88', ox, oy) + px(hx + hw, hy + Math.floor(hh * 0.5), '#ffcc99', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh * 0.5), '#ffbb88', ox, oy) + // Cheek stuffing animation + if (idle && Math.sin(t * Math.PI * 2) > 0.7) { + px(hx - 3, hy + Math.floor(hh * 0.5), '#ffaa77', ox, oy) + px(hx + hw + 2, hy + Math.floor(hh * 0.5), '#ffaa77', ox, oy) + } + // Tiny round ears + px(hx + 1, hy - 1, '#ffbb88', ox, oy) + px(hx + hw - 2, hy - 1, '#ffbb88', ox, oy) + // Buck teeth + px(hx + Math.floor(hw / 2), hy + hh, '#ffffff', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy + hh, '#ffffff', ox, oy) + // Tiny stub tail + px(p.bx + p.bw + 1, p.by + p.bh - 1, '#ffcc99', ox, oy) + }, +} diff --git a/frontend/src/game/sprites/archetypes/batch_jobs.ts b/frontend/src/game/sprites/archetypes/batch_jobs.ts new file mode 100644 index 0000000..537a7d6 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/batch_jobs.ts @@ -0,0 +1,346 @@ +import type { Archetype } from '../constants' + +export const chef: Archetype = { + name: 'chef', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p + if (ko) return + // Chef hat (tall white) + for (let h = 0; h < 6; h++) { + const w = h < 3 ? hw + 2 : hw - 2 + for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) { + px(ix, hy - 1 - h, '#ffffff', ox, oy) + } + } + // Apron + for (let iy = by + 2; iy < by + bh + 2; iy++) { + for (let ix = bx + 1; ix < bx + bw - 1; ix++) px(ix, iy, '#ffffff', ox, oy) + } + // Apron string + px(bx + Math.floor(bw / 2), by + 2, '#cccccc', ox, oy) + // Mustache + px(hx + 1, hy + Math.floor(hh * 0.6), '#443322', ox, oy) + px(hx + 2, hy + Math.floor(hh * 0.6), '#443322', ox, oy) + px(hx + hw - 2, hy + Math.floor(hh * 0.6), '#443322', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.6), '#443322', ox, oy) + }, +} + +export const firefighter: Archetype = { + name: 'firefighter', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Helmet + for (let ix = hx - 1; ix < hx + hw + 1; ix++) px(ix, hy - 1, '#ff2222', ox, oy) + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#ff2222', ox, oy) + // Helmet shield + px(hx + Math.floor(hw / 2), hy - 1, '#ffcc00', ox, oy) + // Yellow stripes on body + for (let ix = bx; ix < bx + bw; ix++) { + px(ix, by + Math.floor(bh / 3), '#ffcc00', ox, oy) + px(ix, by + Math.floor(bh * 2 / 3), '#ffcc00', ox, oy) + } + // Oxygen tank on back + px(bx + bw + 1, by + 1, '#444444', ox, oy) + px(bx + bw + 1, by + 2, '#444444', ox, oy) + px(bx + bw + 1, by + 3, '#444444', ox, oy) + }, +} + +export const astronautArch: Archetype = { + name: 'astronaut', weight: 0.02, canHaveMohawk: false, canHaveVisor: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Helmet dome + for (let ix = hx - 1; ix < hx + hw + 1; ix++) { + px(ix, hy - 1, '#ffffff', ox, oy) + px(ix, hy + hh, '#ffffff', ox, oy) + } + px(hx - 1, hy, '#ffffff', ox, oy); px(hx + hw, hy, '#ffffff', ox, oy) + px(hx - 1, hy + hh - 1, '#ffffff', ox, oy); px(hx + hw, hy + hh - 1, '#ffffff', ox, oy) + // Visor (gold tint) + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + for (let iy = hy + 1; iy < hy + Math.floor(hh * 0.5); iy++) { + px(ix, iy, '#ffcc44', ox, oy) + } + } + // Backpack + px(bx + bw + 1, by, '#cccccc', ox, oy); px(bx + bw + 1, by + 1, '#cccccc', ox, oy) + px(bx + bw + 1, by + 2, '#cccccc', ox, oy); px(bx + bw + 2, by + 1, '#aaaaaa', ox, oy) + // Flag patch + px(bx + 1, by + 1, '#ff0000', ox, oy); px(bx + 2, by + 1, '#ffffff', ox, oy); px(bx + 3, by + 1, '#0000ff', ox, oy) + }, +} + +export const clown: Archetype = { + name: 'clown', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Red nose + px(cx + hOff, hy + Math.floor(hh * 0.55), '#ff0000', ox, oy) + px(cx + hOff + 1, hy + Math.floor(hh * 0.55), '#ff0000', ox, oy) + // Rainbow wig + const colors = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff'] + for (let i = 0; i < colors.length; i++) { + px(hx - 1 + i, hy - 1, colors[i], ox, oy) + px(hx + hw - colors.length + i, hy - 1, colors[colors.length - 1 - i], ox, oy) + } + // Ruffle collar + for (let ix = bx - 1; ix < bx + bw + 1; ix++) { + px(ix, by - 1, (ix % 2 === 0) ? '#ffffff' : '#ff4444', ox, oy) + } + // Big shoes + px(bx - 2, by + bh + 5, '#ff0000', ox, oy); px(bx - 3, by + bh + 5, '#ff0000', ox, oy) + px(bx + bw + 1, by + bh + 5, '#ff0000', ox, oy); px(bx + bw + 2, by + bh + 5, '#ff0000', ox, oy) + // Flower on chest + const flowerBlink = Math.sin(t * Math.PI * 3) > 0 + px(cx + hOff, by + 1, flowerBlink ? '#ff44ff' : '#ffff00', ox, oy) + }, +} + +export const detective: Archetype = { + name: 'detective', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Fedora + for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#443322', ox, oy) + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#554433', ox, oy) + for (let ix = hx + 1; ix < hx + hw - 1; ix++) px(ix, hy - 3, '#554433', ox, oy) + // Trench coat + for (let iy = by; iy < by + bh + 3; iy++) { + px(bx - 1, iy, '#aa9966', ox, oy); px(bx + bw, iy, '#aa9966', ox, oy) + } + // Belt + for (let ix = bx; ix < bx + bw; ix++) px(ix, by + Math.floor(bh * 0.7), '#554433', ox, oy) + // Magnifying glass + px(bx + bw + 2, by + Math.floor(bh / 2), '#888888', ox, oy) + px(bx + bw + 3, by + Math.floor(bh / 2) - 1, '#aaddff', ox, oy) + px(bx + bw + 3, by + Math.floor(bh / 2), '#aaddff', ox, oy) + }, +} + +export const nurse: Archetype = { + name: 'nurse', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Nurse cap + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#ffffff', ox, oy) + px(cx + hOff, hy - 1, '#ff0000', ox, oy) // red cross + px(cx + hOff - 1, hy - 2, '#ff0000', ox, oy) + px(cx + hOff + 1, hy - 2, '#ff0000', ox, oy) + px(cx + hOff, hy - 2, '#ff0000', ox, oy) + // White coat + for (let iy = by; iy < by + bh; iy++) { + px(bx, iy, '#ffffff', ox, oy); px(bx + bw - 1, iy, '#ffffff', ox, oy) + } + // Stethoscope + px(cx + hOff - 1, by + 1, '#444444', ox, oy) + px(cx + hOff - 2, by + 2, '#444444', ox, oy) + px(cx + hOff - 2, by + 3, '#888888', ox, oy) + }, +} + +export const lumberjack: Archetype = { + name: 'lumberjack', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Beanie + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#ff2222', ox, oy) + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#ff2222', ox, oy) + // Plaid pattern on body + for (let iy = by; iy < by + bh; iy += 2) { + for (let ix = bx; ix < bx + bw; ix += 2) px(ix, iy, '#cc2222', ox, oy) + } + for (let iy = by + 1; iy < by + bh; iy += 2) { + for (let ix = bx + 1; ix < bx + bw; ix += 2) px(ix, iy, '#222222', ox, oy) + } + // Big beard + for (let iy = hy + Math.floor(hh * 0.6); iy < hy + hh + 2; iy++) { + px(hx + 1, iy, '#884422', ox, oy); px(hx + 2, iy, '#884422', ox, oy) + px(hx + hw - 2, iy, '#884422', ox, oy); px(hx + hw - 3, iy, '#884422', ox, oy) + } + // Axe on back + px(bx + bw + 1, by - 2, '#886633', ox, oy) + px(bx + bw + 1, by - 1, '#886633', ox, oy) + px(bx + bw + 1, by, '#886633', ox, oy) + px(bx + bw + 2, by - 2, '#888888', ox, oy) + px(bx + bw + 2, by - 3, '#888888', ox, oy) + }, +} + +export const scientist: Archetype = { + name: 'scientist', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Lab coat + for (let iy = by; iy < by + bh + 2; iy++) { + px(bx - 1, iy, '#ffffff', ox, oy); px(bx + bw, iy, '#ffffff', ox, oy) + } + // Safety goggles + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + px(ix, hy + Math.floor(hh * 0.3), '#88ccff', ox, oy) + } + px(hx, hy + Math.floor(hh * 0.3), '#888888', ox, oy) + px(hx + hw - 1, hy + Math.floor(hh * 0.3), '#888888', ox, oy) + // Beaker in hand (bubbling) + const bubble = Math.sin(t * Math.PI * 4) > 0 + px(bx + bw + 2, by + bh - 4, '#88ffcc', ox, oy) + px(bx + bw + 2, by + bh - 3, '#88ffcc', ox, oy) + px(bx + bw + 2, by + bh - 2, '#44cc88', ox, oy) + if (bubble) px(bx + bw + 2, by + bh - 5, '#aaffdd', ox, oy) + // Wild hair + px(hx - 1, hy, '#ffffff', ox, oy); px(hx + hw, hy, '#ffffff', ox, oy) + px(hx, hy - 1, '#ffffff', ox, oy); px(hx + hw - 1, hy - 1, '#ffffff', ox, oy) + }, +} + +export const wrestler: Archetype = { + name: 'wrestler', weight: 0.02, canHaveMohawk: false, + dimensionOverrides: (tier) => ({ legW: 5 + tier }), + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Luchador mask + for (let ix = hx; ix < hx + hw; ix++) { + for (let iy = hy; iy < hy + hh; iy++) px(ix, iy, '#ff0044', ox, oy) + } + // Eye holes + px(hx + 2, hy + Math.floor(hh * 0.35), '#000000', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#000000', ox, oy) + // Mouth hole + px(hx + Math.floor(hw / 2), hy + Math.floor(hh * 0.7), '#000000', ox, oy) + // Championship belt + for (let ix = bx; ix < bx + bw; ix++) px(ix, by + bh - 2, '#ffcc00', ox, oy) + px(bx + Math.floor(bw / 2), by + bh - 2, '#ffffff', ox, oy) + // Wrist bands + px(bx - 1, by + bh - 1, '#ff0044', ox, oy); px(bx + bw, by + bh - 1, '#ff0044', ox, oy) + }, +} + +export const boxer: Archetype = { + name: 'boxer', weight: 0.02, + drawFeatures: (p) => { + const { px, pal, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, armLx, armRx, armAttach, armH } = p + if (ko) return + // Boxing gloves (big circles at arm ends) + const gloveY = armAttach + armH - 2 + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + if (dx * dx + dy * dy <= 5) { + px(armLx + dx + p.hOff, gloveY + dy, '#ff0000', p.ox, p.oy) + px(armRx + dx + p.hOff, gloveY + dy, '#ff0000', p.ox, p.oy) + } + } + } + // Shorts + for (let ix = bx; ix < bx + bw; ix++) { + px(ix, by + bh - 1, '#ffcc00', ox, oy) + px(ix, by + bh, '#ffcc00', ox, oy) + } + // Headband + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy + 1, '#ff0000', ox, oy) + }, +} + +export const gladiator: Archetype = { + name: 'gladiator', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Helmet with plume + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#ccaa44', ox, oy) + for (let h = 0; h < 4; h++) px(hx + Math.floor(hw / 2), hy - 2 - h, '#ff2222', ox, oy) + // Chest plate + for (let iy = by; iy < by + 3; iy++) { + for (let ix = bx + 1; ix < bx + bw - 1; ix++) px(ix, iy, '#ccaa44', ox, oy) + } + // Shield (on left arm) + for (let iy = by + 1; iy < by + 5; iy++) { + for (let ix = bx - 4; ix < bx - 1; ix++) px(ix, iy, '#886633', ox, oy) + } + px(bx - 3, by + 3, '#ccaa44', ox, oy) // boss on shield + // Sandals + px(bx - 1, by + bh + 5, '#886633', ox, oy); px(bx + bw, by + bh + 5, '#886633', ox, oy) + }, +} + +export const samuraiArch: Archetype = { + name: 'samurai', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Kabuto helmet + for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#886633', ox, oy) + px(cx + hOff, hy - 2, '#886633', ox, oy) + // Crescent moon ornament + px(cx + hOff - 2, hy - 3, '#ffcc00', ox, oy) + px(cx + hOff, hy - 4, '#ffcc00', ox, oy) + px(cx + hOff + 2, hy - 3, '#ffcc00', ox, oy) + // Armor plates + for (let iy = by; iy < by + bh; iy += 2) { + for (let ix = bx; ix < bx + bw; ix++) px(ix, iy, '#445566', ox, oy) + } + // Katana on back + px(bx + bw + 1, by - 3, '#888888', ox, oy) + for (let h = 0; h < 6; h++) px(bx + bw + 1, by - 2 + h, '#886633', ox, oy) + }, +} + +export const vikingArch: Archetype = { + name: 'viking', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Horned helmet + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#888888', ox, oy) + px(hx - 1, hy - 1, '#ccaa44', ox, oy); px(hx - 2, hy - 2, '#ccaa44', ox, oy); px(hx - 3, hy - 3, '#ccaa44', ox, oy) + px(hx + hw, hy - 1, '#ccaa44', ox, oy); px(hx + hw + 1, hy - 2, '#ccaa44', ox, oy); px(hx + hw + 2, hy - 3, '#ccaa44', ox, oy) + // Big beard + for (let iy = hy + Math.floor(hh * 0.5); iy < hy + hh + 3; iy++) { + const bw2 = Math.max(1, 3 - (iy - hy - Math.floor(hh * 0.5))) + for (let ix = hx + Math.floor(hw / 2) - bw2; ix <= hx + Math.floor(hw / 2) + bw2; ix++) { + px(ix, iy, '#cc8833', ox, oy) + } + } + // Fur vest + for (let ix = bx; ix < bx + bw; ix += 2) { + px(ix, by, '#886644', ox, oy); px(ix, by + 1, '#776633', ox, oy) + } + // Shield on back + px(bx + bw + 1, by + 2, '#886633', ox, oy); px(bx + bw + 2, by + 2, '#886633', ox, oy) + px(bx + bw + 1, by + 3, '#886633', ox, oy); px(bx + bw + 2, by + 3, '#886633', ox, oy) + }, +} + +export const knight: Archetype = { + name: 'knight', weight: 0.02, canHaveMohawk: false, canHaveVisor: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Full helmet + for (let ix = hx; ix < hx + hw; ix++) { + for (let iy = hy; iy < hy + hh; iy++) px(ix, iy, '#aaaaaa', ox, oy) + } + // Visor slit + for (let ix = hx + 1; ix < hx + hw - 1; ix++) px(ix, hy + Math.floor(hh * 0.4), '#333333', ox, oy) + // Plume + for (let h = 0; h < 4; h++) px(cx + hOff, hy - 1 - h, '#ff0000', ox, oy) + // Full body armor + for (let ix = bx; ix < bx + bw; ix++) { + for (let iy = by; iy < by + bh; iy++) px(ix, iy, '#999999', ox, oy) + } + // Cross on chest + px(cx + hOff, by + 2, '#ff0000', ox, oy) + px(cx + hOff - 1, by + 3, '#ff0000', ox, oy) + px(cx + hOff, by + 3, '#ff0000', ox, oy) + px(cx + hOff + 1, by + 3, '#ff0000', ox, oy) + px(cx + hOff, by + 4, '#ff0000', ox, oy) + }, +} diff --git a/frontend/src/game/sprites/archetypes/batch_mythology.ts b/frontend/src/game/sprites/archetypes/batch_mythology.ts new file mode 100644 index 0000000..d2f1125 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/batch_mythology.ts @@ -0,0 +1,352 @@ +import type { Archetype } from '../constants' + +export const minotaur: Archetype = { + name: 'minotaur', weight: 0.02, canHaveHorns: false, + dimensionOverrides: (tier) => ({ legW: 5 + tier, legH: 7 + tier }), + drawFeatures: (p) => { + const { px, pal, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Big curved horns + for (let h = 0; h < 5; h++) { + px(hx - 1 - h, hy - h, '#aa8844', ox, oy) + px(hx + hw + h, hy - h, '#aa8844', ox, oy) + } + px(hx - 6, hy - 4, '#ffcc88', ox, oy); px(hx + hw + 5, hy - 4, '#ffcc88', ox, oy) + // Nose ring + px(hx + Math.floor(hw / 2), hy + hh - 1, '#ffcc00', ox, oy) + px(hx + Math.floor(hw / 2) - 1, hy + hh, '#ffcc00', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy + hh, '#ffcc00', ox, oy) + // Furry chest + for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) px(ix, by + 1, '#886644', ox, oy) + }, +} + +export const unicorn: Archetype = { + name: 'unicorn', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh, feetY, globalY, vBounce } = p + if (ko) return + // Spiral horn + for (let h = 0; h < 7; h++) { + const color = h % 2 === 0 ? '#ffaaff' : '#ffffff' + px(cx + hOff, hy - 1 - h, color, ox, oy) + } + // Rainbow mane + const rainbow = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff'] + for (let i = 0; i < rainbow.length; i++) { + px(hx - 1, hy + 1 + i, rainbow[i], ox, oy) + } + // Sparkle trail + if (idle) { + const sparkX = bx - 3 - Math.floor(t * 5) % 8 + const sparkY = by + Math.floor(bh / 2) + Math.round(Math.sin(t * Math.PI * 3 + 1) * 3) + px(sparkX, sparkY, '#ffff88', ox, oy) + px(sparkX - 3, sparkY + 2, '#ffaaff', ox, oy) + } + }, +} + +export const phoenix: Archetype = { + name: 'phoenix', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Flame wings + const wingFlap = idle ? Math.sin(t * Math.PI * 3) * 3 : 0 + const flames = ['#ff4400', '#ff8800', '#ffcc00', '#ffee88'] + for (let f = 0; f < 4; f++) { + px(bx - 2 - f, by + 1 + Math.round(wingFlap) + f, flames[f], ox, oy) + px(bx + bw + 1 + f, by + 1 - Math.round(wingFlap) + f, flames[f], ox, oy) + } + // Flame tail + for (let i = 0; i < 5; i++) { + px(bx + bw + 1 + i, by + bh - 1 + Math.round(Math.sin(t * Math.PI * 4 + i) * 2), flames[i % 4], ox, oy) + } + // Crown feathers + px(cx + hOff - 1, hy - 2, '#ff4400', ox, oy) + px(cx + hOff, hy - 3, '#ff8800', ox, oy) + px(cx + hOff + 1, hy - 2, '#ff4400', ox, oy) + // Beak + px(hx + hw, hy + Math.floor(hh / 2), '#ffaa00', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2), '#ff8800', ox, oy) + }, +} + +export const dragonArch: Archetype = { + name: 'dragon', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, atk, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Horns + px(hx, hy - 1, '#886644', ox, oy); px(hx - 1, hy - 2, '#886644', ox, oy) + px(hx + hw - 1, hy - 1, '#886644', ox, oy); px(hx + hw, hy - 2, '#886644', ox, oy) + // Spiky back ridge + for (let i = 0; i < 5; i++) { + px(bx + bw + 1 + i, by + i * 2, '#44aa22', ox, oy) + px(bx + bw + 1 + i, by + i * 2 + 1, '#338811', ox, oy) + } + // Wings (folded) + for (let w = 0; w < 3; w++) { + px(bx - 1 - w, by + w, '#44aa44', ox, oy) + px(bx - 1 - w, by + w + 1, '#338833', ox, oy) + } + // Fire breath on attack + if (atk) { + const colors = ['#ff4400', '#ff8800', '#ffcc00'] + for (let f = 0; f < 6; f++) { + px(hx + hw + f, hy + Math.floor(hh / 2) + Math.round(Math.sin(t * 10 + f) * 2), colors[f % 3], ox, oy) + } + } + }, +} + +export const mermaid: Archetype = { + name: 'mermaid', weight: 0.02, + dimensionOverrides: () => ({ legH: 4, legW: 6 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, feetY, globalY, vBounce } = p + if (ko) return + // Tail fin at feet (replaces legs visually) + const tailY = feetY + vBounce + globalY + px(bx + Math.floor(bw / 2) - 4, tailY + 2, '#44aacc', ox, oy) + px(bx + Math.floor(bw / 2) + 3, tailY + 2, '#44aacc', ox, oy) + px(bx + Math.floor(bw / 2) - 5, tailY + 3, '#228899', ox, oy) + px(bx + Math.floor(bw / 2) + 4, tailY + 3, '#228899', ox, oy) + // Scale pattern on body + for (let iy = by + 2; iy < by + bh; iy += 2) { + for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) { + px(ix, iy, '#55ccaa', ox, oy) + } + } + // Shell crown + px(hx + Math.floor(hw / 2), hy - 1, '#ffaacc', ox, oy) + px(hx + Math.floor(hw / 2) - 1, hy - 1, '#ff88aa', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy - 1, '#ff88aa', ox, oy) + // Flowing hair + if (idle) { + const wave = Math.sin(t * Math.PI * 2) * 2 + for (let h = 0; h < 4; h++) { + px(hx - 1, hy + h + Math.round(wave), '#44ccff', ox, oy) + } + } + }, +} + +export const griffin: Archetype = { + name: 'griffin', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw } = p + if (ko) return + // Eagle head features: beak + px(hx + hw, hy + Math.floor(hh / 2), '#ffaa00', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ff8800', ox, oy) + // Wings + const flap = idle ? Math.sin(t * Math.PI * 2) * 4 : 0 + for (let w = 0; w < 5; w++) { + px(bx - 1 - w, by + Math.round(flap) + w, '#886644', ox, oy) + px(bx + bw + w, by - Math.round(flap) + w, '#886644', ox, oy) + } + // Feathered chest + for (let iy = by; iy < by + 3; iy++) { + for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) px(ix, iy, '#ddbb88', ox, oy) + } + // Lion tail + px(bx + bw + 1, by + bh - 2, '#aa8844', ox, oy) + px(bx + bw + 2, by + bh - 3, '#aa8844', ox, oy) + px(bx + bw + 3, by + bh - 3, '#cc9955', ox, oy) + }, +} + +export const cyclops: Archetype = { + name: 'cyclops', weight: 0.02, canHaveVisor: false, + dimensionOverrides: (tier) => ({ legW: 5 + tier }), + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // One big eye (covers normal eyes) + const eyeX = cx + hOff, eyeY = hy + Math.floor(hh * 0.35) + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + if (dx * dx + dy * dy <= 5) px(eyeX + dx, eyeY + dy, '#ffffff', ox, oy) + } + } + px(eyeX, eyeY, '#ff0000', ox, oy) + px(eyeX + 1, eyeY, '#880000', ox, oy) + // Brow ridge + for (let i = -3; i <= 3; i++) px(eyeX + i, eyeY - 3, '#886644', ox, oy) + }, +} + +export const gargoyle: Archetype = { + name: 'gargoyle', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw } = p + if (ko) return + // Small horns + px(hx, hy - 1, '#666666', ox, oy); px(hx - 1, hy - 1, '#555555', ox, oy) + px(hx + hw - 1, hy - 1, '#666666', ox, oy); px(hx + hw, hy - 1, '#555555', ox, oy) + // Bat wings + const flap = idle ? Math.sin(t * Math.PI * 1.5) * 3 : 0 + for (let w = 0; w < 6; w++) { + px(bx - 1 - w, by + 2 + Math.round(flap) + Math.floor(w / 2), '#555555', ox, oy) + px(bx + bw + w, by + 2 - Math.round(flap) + Math.floor(w / 2), '#555555', ox, oy) + } + // Stone texture + for (let ix = bx + 1; ix < bx + bw - 1; ix += 3) { + for (let iy = by + 1; iy < by + bh; iy += 3) px(ix, iy, '#777777', ox, oy) + } + // Glowing eyes + px(hx + 2, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy) + }, +} + +export const golem: Archetype = { + name: 'golem', weight: 0.02, + dimensionOverrides: (tier) => ({ legW: 6 + tier, legH: 7 }), + drawFeatures: (p) => { + const { px, ox, oy, t, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Cracks/rune patterns + const rune = '#44aaff' + px(cx + hOff, by + 2, rune, ox, oy); px(cx + hOff, by + 4, rune, ox, oy) + px(cx + hOff - 2, by + 3, rune, ox, oy); px(cx + hOff + 2, by + 3, rune, ox, oy) + // Glowing core + const pulse = Math.sin(t * Math.PI * 2) > 0 ? '#44aaff' : '#2266aa' + px(cx + hOff, by + Math.floor(bh / 2), pulse, ox, oy) + px(cx + hOff - 1, by + Math.floor(bh / 2), pulse, ox, oy) + px(cx + hOff + 1, by + Math.floor(bh / 2), pulse, ox, oy) + // Rocky texture + for (let ix = bx; ix < bx + bw; ix += 4) px(ix, by + bh - 1, '#999999', ox, oy) + // Forehead rune + px(cx + hOff, hy + 1, rune, ox, oy) + }, +} + +export const vampire: Archetype = { + name: 'vampire', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Cape + for (let iy = by; iy < by + bh + 3; iy++) { + px(bx - 1, iy, '#440000', ox, oy); px(bx - 2, iy + 1, '#330000', ox, oy) + px(bx + bw, iy, '#440000', ox, oy); px(bx + bw + 1, iy + 1, '#330000', ox, oy) + } + // Widow's peak hair + px(hx + Math.floor(hw / 2), hy - 1, '#111111', ox, oy) + px(hx + Math.floor(hw / 2) - 1, hy, '#111111', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy, '#111111', ox, oy) + // Fangs + px(hx + 2, hy + hh, '#ffffff', ox, oy) + px(hx + hw - 3, hy + hh, '#ffffff', ox, oy) + // Red eyes + px(hx + 2, hy + Math.floor(hh * 0.35), '#ff0000', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ff0000', ox, oy) + }, +} + +export const werewolf: Archetype = { + name: 'werewolf', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Fur tufts everywhere + for (let ix = bx; ix < bx + bw; ix += 2) px(ix, by - 1, '#665544', ox, oy) + for (let iy = by; iy < by + bh; iy += 3) { + px(bx - 1, iy, '#665544', ox, oy); px(bx + bw, iy, '#665544', ox, oy) + } + // Pointy ears + px(hx, hy - 1, '#665544', ox, oy); px(hx - 1, hy - 2, '#665544', ox, oy) + px(hx + hw - 1, hy - 1, '#665544', ox, oy); px(hx + hw, hy - 2, '#665544', ox, oy) + // Snout + px(hx + Math.floor(hw / 2), hy + hh - 1, '#553322', ox, oy) + px(hx + Math.floor(hw / 2), hy + hh, '#553322', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy + hh, '#222222', ox, oy) + // Claws + px(bx - 1, by + bh, '#cccccc', ox, oy); px(bx + bw, by + bh, '#cccccc', ox, oy) + }, +} + +export const zombie: Archetype = { + name: 'zombie', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Green skin patches + px(hx + 2, hy + 1, '#44aa44', ox, oy); px(hx + hw - 3, hy + 2, '#44aa44', ox, oy) + px(bx + 2, by + 2, '#44aa44', ox, oy); px(bx + bw - 3, by + bh - 2, '#44aa44', ox, oy) + // Exposed ribs + for (let r = 0; r < 3; r++) { + px(bx + 1, by + 2 + r * 2, '#ddddcc', ox, oy) + px(bx + 2, by + 2 + r * 2, '#ddddcc', ox, oy) + } + // Droopy eye + px(hx + 2, hy + Math.floor(hh * 0.5), '#ff4444', ox, oy) + // Torn clothes + px(bx + bw - 1, by + bh - 1, '#554433', ox, oy) + px(bx + bw - 2, by + bh, '#554433', ox, oy) + // Brain showing + px(hx + Math.floor(hw / 2), hy, '#ff88aa', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy, '#ff88aa', ox, oy) + }, +} + +export const witch: Archetype = { + name: 'witch', weight: 0.02, canHaveMohawk: false, canHaveVisor: false, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p + if (ko) return + // Pointed hat + for (let h = 0; h < 7; h++) { + const w = Math.max(1, 4 - h) + for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) { + px(ix, hy - 1 - h, '#220044', ox, oy) + } + } + // Hat brim + for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#330055', ox, oy) + // Wart on nose + px(hx + Math.floor(hw / 2) + 1, hy + Math.floor(hh * 0.6), '#448833', ox, oy) + // Broom (floating next to body) + if (idle) { + px(bx - 3, by + bh - 2, '#886633', ox, oy) + px(bx - 3, by + bh - 1, '#886633', ox, oy) + px(bx - 3, by + bh, '#886633', ox, oy) + px(bx - 4, by + bh + 1, '#aa9944', ox, oy) + px(bx - 3, by + bh + 1, '#aa9944', ox, oy) + px(bx - 2, by + bh + 1, '#aa9944', ox, oy) + } + // Cat familiar (tiny, near feet) + px(bx - 2, by + bh + 3, '#111111', ox, oy) + px(bx - 1, by + bh + 3, '#111111', ox, oy) + px(bx - 2, by + bh + 2, '#111111', ox, oy) + }, +} + +export const demon: Archetype = { + name: 'demon', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Demon horns (curved forward) + px(hx, hy - 1, '#cc2222', ox, oy); px(hx - 1, hy - 2, '#cc2222', ox, oy); px(hx, hy - 3, '#cc2222', ox, oy) + px(hx + hw - 1, hy - 1, '#cc2222', ox, oy); px(hx + hw, hy - 2, '#cc2222', ox, oy); px(hx + hw - 1, hy - 3, '#cc2222', ox, oy) + // Pointed tail + px(bx + bw + 1, by + bh - 1, '#cc2222', ox, oy) + px(bx + bw + 2, by + bh - 2, '#cc2222', ox, oy) + px(bx + bw + 3, by + bh - 3, '#cc2222', ox, oy) + px(bx + bw + 4, by + bh - 3, '#ff4444', ox, oy) // arrow tip + px(bx + bw + 3, by + bh - 4, '#ff4444', ox, oy) + // Glowing eyes + px(hx + 2, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy) + // Dark aura + if (p.idle) { + const flicker = Math.sin(t * Math.PI * 4) > 0 + if (flicker) { + px(bx - 1, by - 1, '#440000', ox, oy); px(bx + bw, by - 1, '#440000', ox, oy) + } + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/batch_robots.ts b/frontend/src/game/sprites/archetypes/batch_robots.ts new file mode 100644 index 0000000..f1b2257 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/batch_robots.ts @@ -0,0 +1,335 @@ +import type { Archetype } from '../constants' + +export const robot: Archetype = { + name: 'robot', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p + if (ko) return + // Antenna on head + px(cx + hOff, hy - 3, '#888888', ox, oy) + px(cx + hOff, hy - 4, '#888888', ox, oy) + px(cx + hOff, hy - 5, idle ? '#ff0000' : '#00ff00', ox, oy) + // Panel lines on body + for (let ix = bx + 2; ix < bx + bw - 2; ix += 3) { + px(ix, by + Math.floor(bh / 2), '#666666', ox, oy) + } + // Chest light + const blink = Math.sin(t * Math.PI * 4) > 0 + px(cx + hOff, by + 2, blink ? '#00ff44' : '#004411', ox, oy) + px(cx + hOff + 1, by + 2, blink ? '#00ff44' : '#004411', ox, oy) + // Bolts on joints + px(bx, by, '#aaaaaa', ox, oy); px(bx + bw - 1, by, '#aaaaaa', ox, oy) + px(bx, by + bh - 1, '#aaaaaa', ox, oy); px(bx + bw - 1, by + bh - 1, '#aaaaaa', ox, oy) + }, +} + +export const android: Archetype = { + name: 'android', weight: 0.02, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p + if (ko) return + // Glowing circuit lines on body + const glow = Math.sin(t * Math.PI * 2) * 0.5 + 0.5 > 0.5 ? '#44ffff' : '#228888' + for (let iy = by + 1; iy < by + bh; iy += 2) { + px(cx + hOff, iy, glow, ox, oy) + } + // Ear sensors + px(hx - 1, hy + Math.floor(hh / 2), '#44ffff', ox, oy) + px(hx + hw, hy + Math.floor(hh / 2), '#44ffff', ox, oy) + // Visor line across eyes + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + px(ix, hy + Math.floor(hh * 0.35), '#44ffff', ox, oy) + } + }, +} + +export const droneBug: Archetype = { + name: 'drone', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Propellers on top (spinning) + const spin = Math.floor(t * 20) % 2 + if (spin === 0) { + for (let i = -8; i <= 8; i++) px(cx + hOff + i, by - 5, '#666666', ox, oy) + } else { + for (let i = -2; i <= 2; i++) px(cx + hOff + i, by - 5, '#666666', ox, oy) + } + // Camera lens on front + px(bx + bw - 1, by + Math.floor(bh / 2), '#ff0000', ox, oy) + px(bx + bw, by + Math.floor(bh / 2), '#ff0000', ox, oy) + // LED strip on bottom + const led = Math.floor(t * 6) % 3 + px(bx + 2 + led * 3, by + bh, '#00ff00', ox, oy) + }, +} + +export const toaster: Archetype = { + name: 'toaster', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + dimensionOverrides: () => ({ legH: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, cx, hOff, hy } = p + if (ko) return + // Toast popping out of top + const pop = idle ? Math.sin(t * Math.PI * 2) * 3 : 0 + px(cx + hOff - 2, hy - 3 + pop, '#dda855', ox, oy) + px(cx + hOff - 1, hy - 3 + pop, '#dda855', ox, oy) + px(cx + hOff, hy - 3 + pop, '#cc9944', ox, oy) + px(cx + hOff + 1, hy - 3 + pop, '#dda855', ox, oy) + px(cx + hOff - 2, hy - 4 + pop, '#cc9944', ox, oy) + px(cx + hOff + 1, hy - 4 + pop, '#cc9944', ox, oy) + // Dial on side + px(bx, by + Math.floor(bh / 2), '#888888', ox, oy) + // Lever + px(bx + bw, by + 2, '#666666', ox, oy) + px(bx + bw, by + 3, '#666666', ox, oy) + // Chrome stripe + for (let ix = bx + 1; ix < bx + bw - 1; ix++) { + px(ix, by + bh - 2, '#cccccc', ox, oy) + } + }, +} + +export const tvHead: Archetype = { + name: 'tv_head', weight: 0.02, canHaveMohawk: false, canHaveVisor: false, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Static/scan lines on face + for (let iy = hy + 1; iy < hy + hh - 1; iy += 2) { + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + const flicker = Math.random() > 0.5 ? '#224488' : '#113366' + px(ix, iy, flicker, ox, oy) + } + } + // Antenna ears + px(hx, hy - 1, '#888888', ox, oy) + px(hx - 1, hy - 2, '#888888', ox, oy) + px(hx + hw - 1, hy - 1, '#888888', ox, oy) + px(hx + hw, hy - 2, '#888888', ox, oy) + // Power button + px(hx + hw, hy + hh - 2, '#ff0000', ox, oy) + }, +} + +export const calculator: Archetype = { + name: 'calculator', weight: 0.02, canHaveMohawk: false, + dimensionOverrides: () => ({ legH: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Number display on head + const num = Math.floor(t * 3) % 10 + const digits = ['1','3','3','7','4','2','0','6','9','5'] + // Just draw a green rect for display + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + px(ix, hy + 1, '#003300', ox, oy) + px(ix, hy + 2, '#003300', ox, oy) + } + px(hx + 2, hy + 1, '#00ff00', ox, oy) + px(hx + hw - 3, hy + 1, '#00ff00', ox, oy) + // Button grid on body + for (let gx = 0; gx < 3; gx++) { + for (let gy = 0; gy < 3; gy++) { + px(bx + 2 + gx * 3, by + 1 + gy * 3, '#888888', ox, oy) + } + } + }, +} + +export const satellite: Archetype = { + name: 'satellite', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Solar panels (wide rectangles extending from body) + for (let iy = by + 1; iy < by + 4; iy++) { + for (let ix = bx - 8; ix < bx - 1; ix++) px(ix, iy, '#2244aa', ox, oy) + for (let ix = bx + bw + 1; ix < bx + bw + 8; ix++) px(ix, iy, '#2244aa', ox, oy) + } + // Dish on top + for (let i = -3; i <= 3; i++) px(cx + hOff + i, by - 2, '#cccccc', ox, oy) + px(cx + hOff, by - 3, '#cccccc', ox, oy) + // Blinking light + const blink = Math.sin(t * Math.PI * 3) > 0 + px(cx + hOff, by - 4, blink ? '#ff0000' : '#440000', ox, oy) + }, +} + +export const mech: Archetype = { + name: 'mech', weight: 0.02, + dimensionOverrides: (tier) => ({ legW: 6 + tier, legH: 8 + tier }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Shoulder pads + for (let ix = bx - 3; ix < bx; ix++) { px(ix, by, '#888888', ox, oy); px(ix, by + 1, '#666666', ox, oy) } + for (let ix = bx + bw; ix < bx + bw + 3; ix++) { px(ix, by, '#888888', ox, oy); px(ix, by + 1, '#666666', ox, oy) } + // Cockpit window on chest + for (let iy = by + 2; iy < by + 5; iy++) { + px(cx + hOff - 1, iy, '#44aaff', ox, oy) + px(cx + hOff, iy, '#88ccff', ox, oy) + px(cx + hOff + 1, iy, '#44aaff', ox, oy) + } + // Exhaust pipes on back + px(bx + bw + 1, by + bh - 3, '#555555', ox, oy) + px(bx + bw + 1, by + bh - 2, '#555555', ox, oy) + px(bx + bw + 1, by + bh - 1, '#555555', ox, oy) + }, +} + +export const ledCube: Archetype = { + name: 'led_cube', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, bx, by, bw, bh, hx, hy, hh, hw } = p + if (ko) return + // Pulsing colored LEDs all over body + const colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'] + for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) { + for (let iy = by + 1; iy < by + bh - 1; iy += 2) { + const ci = (ix + iy + Math.floor(t * 4)) % colors.length + px(ix, iy, colors[ci], ox, oy) + } + } + // Face LEDs + const faceColor = colors[Math.floor(t * 2) % colors.length] + px(hx + 2, hy + 2, faceColor, ox, oy) + px(hx + hw - 3, hy + 2, faceColor, ox, oy) + }, +} + +export const circuit: Archetype = { + name: 'circuit', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, bx, by, bw, bh, cx, hOff, hy, hh } = p + if (ko) return + // PCB traces on body + const trace = '#44aa22' + for (let ix = bx + 1; ix < bx + bw; ix += 3) { + for (let iy = by; iy < by + bh; iy++) px(ix, iy, trace, ox, oy) + } + for (let iy = by + 2; iy < by + bh; iy += 3) { + for (let ix = bx; ix < bx + bw; ix++) px(ix, iy, trace, ox, oy) + } + // Chip on body center + px(cx + hOff - 1, by + Math.floor(bh / 2), '#222222', ox, oy) + px(cx + hOff, by + Math.floor(bh / 2), '#222222', ox, oy) + px(cx + hOff + 1, by + Math.floor(bh / 2), '#222222', ox, oy) + // Solder points + px(bx + 2, by + 1, '#cccccc', ox, oy) + px(bx + bw - 3, by + bh - 2, '#cccccc', ox, oy) + }, +} + +export const antennaBug: Archetype = { + name: 'antenna_bot', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hw, cx, hOff } = p + if (ko) return + // Multiple antennae + const wobble = idle ? Math.sin(t * Math.PI * 3) * 2 : 0 + for (let a = 0; a < 3; a++) { + const ax = hx + 1 + a * Math.floor((hw - 2) / 2) + for (let h = 0; h < 4 + a; h++) { + px(ax, hy - 1 - h + Math.round(wobble * (a === 1 ? -1 : 1)), '#888888', ox, oy) + } + const tipColor = ['#ff0000', '#00ff00', '#0000ff'][a] + px(ax, hy - 5 - a + Math.round(wobble * (a === 1 ? -1 : 1)), tipColor, ox, oy) + } + }, +} + +export const microwave: Archetype = { + name: 'microwave', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + dimensionOverrides: () => ({ legH: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Door window on face + for (let ix = hx + 1; ix < hx + hw - 2; ix++) { + for (let iy = hy + 1; iy < hy + hh - 1; iy++) { + px(ix, iy, '#223344', ox, oy) + } + } + // Spinning plate inside (when idle) + const spin = Math.floor(t * 4) % 4 + const plateX = hx + Math.floor(hw / 2) + (spin < 2 ? -1 : 1) + px(plateX, hy + Math.floor(hh / 2), '#ffee88', ox, oy) + // Buttons on right side + px(hx + hw - 1, hy + 1, '#ff0000', ox, oy) + px(hx + hw - 1, hy + 3, '#00ff00', ox, oy) + // Handle + px(bx + bw, by + Math.floor(bh / 2), '#aaaaaa', ox, oy) + px(bx + bw, by + Math.floor(bh / 2) + 1, '#aaaaaa', ox, oy) + }, +} + +export const cyberdog: Archetype = { + name: 'cyberdog', weight: 0.02, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff, feetY, globalY, vBounce } = p + if (ko) return + // Floppy robo-ears with LEDs + px(hx - 1, hy + 1, '#888888', ox, oy); px(hx - 1, hy + 2, '#888888', ox, oy) + px(hx - 1, hy + 3, '#00ff00', ox, oy) + px(hx + hw, hy + 1, '#888888', ox, oy); px(hx + hw, hy + 2, '#888888', ox, oy) + px(hx + hw, hy + 3, '#00ff00', ox, oy) + // Robo-tail (wagging) + const wag = idle ? Math.sin(t * Math.PI * 4) * 4 : 0 + px(bx + bw + 1, by + 2, '#888888', ox, oy) + px(bx + bw + 2, by + 1 + Math.round(wag), '#888888', ox, oy) + px(bx + bw + 3, by + Math.round(wag), '#ff4444', ox, oy) + // Snout + px(hx + Math.floor(hw / 2), hy + hh - 1, '#444444', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy + hh - 1, '#444444', ox, oy) + px(hx + Math.floor(hw / 2), hy + hh, '#222222', ox, oy) + }, +} + +export const robocat: Archetype = { + name: 'robocat', weight: 0.02, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Pointy metal ears + px(hx + 1, hy - 1, '#aaaaaa', ox, oy); px(hx, hy - 2, '#aaaaaa', ox, oy) + px(hx + hw - 2, hy - 1, '#aaaaaa', ox, oy); px(hx + hw - 1, hy - 2, '#aaaaaa', ox, oy) + // Whisker sensors + for (let w = 1; w <= 3; w++) { + px(hx - w, hy + Math.floor(hh * 0.6) + (w === 2 ? -1 : w === 3 ? 1 : 0), '#cccccc', ox, oy) + px(hx + hw + w - 1, hy + Math.floor(hh * 0.6) + (w === 2 ? -1 : w === 3 ? 1 : 0), '#cccccc', ox, oy) + } + // Curled tail with LED tip + const curl = Math.sin(t * Math.PI * 2) * 2 + px(bx + bw + 1, by + bh - 2, '#888888', ox, oy) + px(bx + bw + 2, by + bh - 3, '#888888', ox, oy) + px(bx + bw + 2, by + bh - 4 + Math.round(curl), '#ff00ff', ox, oy) + }, +} + +export const ufoBot: Archetype = { + name: 'ufo_bot', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, bx, by, bw, bh, cx, hOff, hy } = p + if (ko) return + // Dome on top + for (let i = -2; i <= 2; i++) px(cx + hOff + i, hy - 2, '#88ffcc', ox, oy) + for (let i = -1; i <= 1; i++) px(cx + hOff + i, hy - 3, '#aaffdd', ox, oy) + px(cx + hOff, hy - 4, '#ccffee', ox, oy) + // Rotating lights around body + const phase = Math.floor(t * 6) % 6 + const colors = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'] + for (let i = 0; i < 6; i++) { + const angle = (i + phase) * Math.PI * 2 / 6 + const lx = cx + hOff + Math.round(Math.cos(angle) * (bw / 2 + 2)) + const ly = by + Math.floor(bh / 2) + Math.round(Math.sin(angle) * 2) + px(lx, ly, colors[i], ox, oy) + } + // Tractor beam below (when idle) + if (p.idle) { + for (let iy = by + bh + 1; iy < by + bh + 4; iy++) { + px(cx + hOff, iy, '#44ff88', ox, oy) + } + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/batch_silly.ts b/frontend/src/game/sprites/archetypes/batch_silly.ts new file mode 100644 index 0000000..de445f2 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/batch_silly.ts @@ -0,0 +1,373 @@ +import type { Archetype } from '../constants' + +export const sockPuppet: Archetype = { + name: 'sock_puppet', weight: 0.02, canHaveMohawk: false, + dimensionOverrides: () => ({ legH: 2 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Googly eyes (one bigger) + px(hx + 2, hy + 2, '#ffffff', ox, oy); px(hx + 3, hy + 2, '#ffffff', ox, oy) + px(hx + 2, hy + 3, '#ffffff', ox, oy); px(hx + 3, hy + 3, '#000000', ox, oy) + px(hx + hw - 3, hy + 1, '#ffffff', ox, oy); px(hx + hw - 2, hy + 1, '#ffffff', ox, oy) + px(hx + hw - 3, hy + 2, '#ffffff', ox, oy); px(hx + hw - 2, hy + 2, '#000000', ox, oy) + px(hx + hw - 3, hy + 3, '#ffffff', ox, oy); px(hx + hw - 2, hy + 3, '#ffffff', ox, oy) + // Mouth (flapping) + const flap = idle ? Math.sin(t * Math.PI * 3) > 0 : false + if (flap) { + for (let ix = hx + 1; ix < hx + hw - 1; ix++) px(ix, hy + hh, '#ff4466', ox, oy) + } + // Yarn hair + px(cx + hOff - 1, hy - 1, '#ff8844', ox, oy) + px(cx + hOff, hy - 2, '#ff8844', ox, oy) + px(cx + hOff + 1, hy - 1, '#ff8844', ox, oy) + }, +} + +export const trafficCone: Archetype = { + name: 'traffic_cone', weight: 0.02, canHaveMohawk: false, canHaveHorns: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Cone shape above head (orange + white stripes) + for (let h = 0; h < 8; h++) { + const w = Math.max(1, 8 - h) + const color = (h % 3 === 0) ? '#ffffff' : '#ff6600' + for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) { + px(ix, hy - 1 - h, color, ox, oy) + } + } + // Tip + px(cx + hOff, hy - 9, '#ff4400', ox, oy) + // Base (wider at head level) + for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#ff6600', ox, oy) + }, +} + +export const toiletMan: Archetype = { + name: 'toilet_man', weight: 0.02, canHaveMohawk: false, + dimensionOverrides: () => ({ legH: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Toilet seat around head + for (let ix = hx - 1; ix < hx + hw + 1; ix++) { + px(ix, hy - 1, '#ffffff', ox, oy); px(ix, hy + hh, '#ffffff', ox, oy) + } + px(hx - 1, hy, '#ffffff', ox, oy); px(hx + hw, hy, '#ffffff', ox, oy) + px(hx - 1, hy + hh - 1, '#ffffff', ox, oy); px(hx + hw, hy + hh - 1, '#ffffff', ox, oy) + // Tank on back + px(bx + bw + 1, by, '#ffffff', ox, oy); px(bx + bw + 1, by + 1, '#ffffff', ox, oy) + px(bx + bw + 1, by + 2, '#ffffff', ox, oy) + // Handle + px(bx + bw + 2, by, '#cccccc', ox, oy) + // Water splash eyes + px(hx + 2, hy + Math.floor(hh * 0.35), '#4488ff', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#4488ff', ox, oy) + }, +} + +export const potato: Archetype = { + name: 'potato', weight: 0.02, + dimensionOverrides: () => ({ legH: 3, legW: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p + if (ko) return + // Eyes (spots) on body + px(bx + 2, by + 2, '#443322', ox, oy) + px(bx + bw - 3, by + 3, '#443322', ox, oy) + px(bx + 1, by + bh - 3, '#443322', ox, oy) + // Sprout on top + px(hx + Math.floor(hw / 2), hy - 1, '#44aa22', ox, oy) + px(hx + Math.floor(hw / 2), hy - 2, '#44aa22', ox, oy) + px(hx + Math.floor(hw / 2) - 1, hy - 3, '#66cc44', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy - 3, '#66cc44', ox, oy) + // Dirt spots + px(bx + Math.floor(bw / 2), by + bh - 1, '#553311', ox, oy) + }, +} + +export const cloudMan: Archetype = { + name: 'cloud_man', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Fluffy cloud outline + for (let ix = bx - 2; ix < bx + bw + 2; ix++) px(ix, by - 1, '#ffffff', ox, oy) + for (let ix = bx - 1; ix < bx + bw + 1; ix++) px(ix, by - 2, '#ffffff', ox, oy) + // Rain drops below (when idle) + if (idle) { + const phase = Math.floor(t * 6) % 4 + for (let d = 0; d < 3; d++) { + const dx = bx + 1 + d * Math.floor(bw / 3) + const dy = by + bh + 1 + (phase + d) % 4 + px(dx, dy, '#4488ff', ox, oy) + } + } + // Poofy top + px(cx + hOff - 2, hy - 1, '#ffffff', ox, oy) + px(cx + hOff, hy - 2, '#ffffff', ox, oy) + px(cx + hOff + 2, hy - 1, '#ffffff', ox, oy) + }, +} + +export const rockMan: Archetype = { + name: 'rock_man', weight: 0.02, + dimensionOverrides: () => ({ legH: 4 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p + if (ko) return + // Cracks on body + px(bx + 2, by + 1, '#555555', ox, oy); px(bx + 3, by + 2, '#555555', ox, oy) + px(bx + 4, by + 2, '#555555', ox, oy); px(bx + 5, by + 3, '#555555', ox, oy) + px(bx + bw - 3, by + bh - 3, '#555555', ox, oy); px(bx + bw - 4, by + bh - 2, '#555555', ox, oy) + // Mossy patches + px(bx + 1, by, '#448833', ox, oy); px(bx + 2, by, '#558844', ox, oy) + px(bx + bw - 2, by + bh - 1, '#448833', ox, oy) + // Crystal embedded + px(bx + Math.floor(bw / 2), by + Math.floor(bh / 2), '#88aaff', ox, oy) + px(bx + Math.floor(bw / 2) + 1, by + Math.floor(bh / 2), '#aaccff', ox, oy) + // Stone face texture + px(hx + 1, hy + hh - 1, '#777777', ox, oy); px(hx + hw - 2, hy + hh - 1, '#777777', ox, oy) + }, +} + +export const balloonMan: Archetype = { + name: 'balloon_man', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Balloon on string above head + const bob = idle ? Math.sin(t * Math.PI * 2) * 3 : 0 + // String + for (let h = 0; h < 4; h++) px(cx + hOff, hy - 2 - h, '#888888', ox, oy) + // Balloon + const bY = hy - 6 + Math.round(bob) + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + if (dx * dx + dy * dy <= 5) px(cx + hOff + dx, bY + dy, '#ff4488', ox, oy) + } + } + // Knot + px(cx + hOff, bY + 3, '#cc2266', ox, oy) + // Highlight + px(cx + hOff - 1, bY - 1, '#ffaacc', ox, oy) + }, +} + +export const trashCan: Archetype = { + name: 'trash_can', weight: 0.02, canHaveMohawk: false, + dimensionOverrides: () => ({ legH: 2 }), + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Lid (on top of head, slightly ajar when idle) + const lidAngle = idle ? Math.sin(t * Math.PI * 2) * 2 : 0 + for (let ix = hx - 1; ix < hx + hw + 1; ix++) { + px(ix, hy - 1 + Math.round(lidAngle > 1 ? -1 : 0), '#888888', ox, oy) + } + // Handle on lid + px(hx + Math.floor(hw / 2), hy - 2, '#aaaaaa', ox, oy) + // Garbage sticking out when lid is open + if (lidAngle > 1) { + px(hx + 2, hy - 2, '#ff4444', ox, oy) // apple core + px(hx + hw - 3, hy - 2, '#ffcc44', ox, oy) // banana peel + } + // Dented texture + px(bx + 2, by + 3, '#666666', ox, oy); px(bx + bw - 3, by + bh - 3, '#666666', ox, oy) + // Flies (tiny dots) + if (idle) { + const flyX = bx + Math.floor(bw / 2) + Math.round(Math.sin(t * Math.PI * 5) * 4) + const flyY = hy - 3 + Math.round(Math.cos(t * Math.PI * 7) * 2) + px(flyX, flyY, '#222222', ox, oy) + } + }, +} + +export const rubberDuckArch: Archetype = { + name: 'rubber_duck', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Beak + px(hx + hw, hy + Math.floor(hh / 2), '#ff8800', ox, oy) + px(hx + hw + 1, hy + Math.floor(hh / 2), '#ff6600', ox, oy) + // Highlight on body (shiny rubber) + px(bx + 2, by + 1, '#ffee88', ox, oy) + px(bx + 3, by + 1, '#ffee88', ox, oy) + px(bx + 2, by + 2, '#ffee88', ox, oy) + // Bobbing motion water ripples + if (idle) { + const ripple = Math.sin(t * Math.PI * 2) + px(bx - 2, by + bh + 1, ripple > 0 ? '#88ccff' : '#4488cc', ox, oy) + px(bx + bw + 1, by + bh + 1, ripple < 0 ? '#88ccff' : '#4488cc', ox, oy) + } + // Crown (bath time king!) + px(hx + Math.floor(hw / 2) - 1, hy - 1, '#ffcc00', ox, oy) + px(hx + Math.floor(hw / 2), hy - 2, '#ffcc00', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy - 1, '#ffcc00', ox, oy) + }, +} + +export const snowman: Archetype = { + name: 'snowman', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Top hat + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + px(ix, hy - 1, '#222222', ox, oy); px(ix, hy - 2, '#222222', ox, oy) + px(ix, hy - 3, '#222222', ox, oy) + } + for (let ix = hx - 1; ix < hx + hw + 1; ix++) px(ix, hy - 1, '#333333', ox, oy) + // Carrot nose + px(hx + Math.floor(hw / 2), hy + Math.floor(hh * 0.5), '#ff8800', ox, oy) + px(hx + Math.floor(hw / 2) + 1, hy + Math.floor(hh * 0.5), '#ff6600', ox, oy) + // Coal buttons + px(cx + hOff, by + 2, '#222222', ox, oy) + px(cx + hOff, by + Math.floor(bh / 2), '#222222', ox, oy) + px(cx + hOff, by + bh - 2, '#222222', ox, oy) + // Scarf + for (let ix = bx - 1; ix < bx + bw + 1; ix++) px(ix, by - 1, '#ff0000', ox, oy) + px(bx - 1, by, '#ff0000', ox, oy); px(bx - 1, by + 1, '#ff0000', ox, oy) + // Stick arms + px(bx - 2, by + Math.floor(bh / 2), '#886633', ox, oy) + px(bx - 3, by + Math.floor(bh / 2) - 1, '#886633', ox, oy) + px(bx + bw + 1, by + Math.floor(bh / 2), '#886633', ox, oy) + px(bx + bw + 2, by + Math.floor(bh / 2) - 1, '#886633', ox, oy) + }, +} + +export const scarecrow: Archetype = { + name: 'scarecrow', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p + if (ko) return + // Straw hat + for (let ix = hx - 3; ix < hx + hw + 3; ix++) px(ix, hy - 1, '#ddbb66', ox, oy) + for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#ccaa55', ox, oy) + // Button eyes + px(hx + 2, hy + Math.floor(hh * 0.35), '#444444', ox, oy) + px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#444444', ox, oy) + // Stitched mouth + for (let ix = hx + 1; ix < hx + hw - 1; ix += 2) { + px(ix, hy + Math.floor(hh * 0.7), '#444444', ox, oy) + } + // Straw poking out + px(bx - 1, by + bh - 1, '#ddbb66', ox, oy) + px(bx + bw, by + bh - 1, '#ddbb66', ox, oy) + px(hx - 1, hy + Math.floor(hh / 2), '#ddbb66', ox, oy) + // Patched clothes + px(bx + 2, by + 2, '#886644', ox, oy); px(bx + 3, by + 2, '#886644', ox, oy) + px(bx + 2, by + 3, '#886644', ox, oy); px(bx + 3, by + 3, '#886644', ox, oy) + // Crow on shoulder + px(bx + bw + 1, by - 1, '#222222', ox, oy); px(bx + bw + 2, by - 1, '#222222', ox, oy) + px(bx + bw + 1, by - 2, '#222222', ox, oy) + }, +} + +export const jackOLantern: Archetype = { + name: 'jack_o_lantern', weight: 0.02, canHaveMohawk: false, canHaveVisor: false, + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, cx, hOff } = p + if (ko) return + // Stem + px(cx + hOff, hy - 1, '#44aa22', ox, oy); px(cx + hOff, hy - 2, '#44aa22', ox, oy) + // Carved face (replaces normal face) + const glow = Math.sin(t * Math.PI * 3) > 0 ? '#ffcc00' : '#ff8800' + // Triangle eyes + px(hx + 2, hy + 2, glow, ox, oy) + px(hx + 1, hy + 3, glow, ox, oy); px(hx + 2, hy + 3, glow, ox, oy); px(hx + 3, hy + 3, glow, ox, oy) + px(hx + hw - 3, hy + 2, glow, ox, oy) + px(hx + hw - 4, hy + 3, glow, ox, oy); px(hx + hw - 3, hy + 3, glow, ox, oy); px(hx + hw - 2, hy + 3, glow, ox, oy) + // Jagged mouth + for (let ix = hx + 1; ix < hx + hw - 1; ix++) { + px(ix, hy + Math.floor(hh * 0.7), glow, ox, oy) + if (ix % 2 === 0) px(ix, hy + Math.floor(hh * 0.7) - 1, glow, ox, oy) + } + // Ridges + for (let iy = hy; iy < hy + hh; iy += 2) { + px(hx, iy, '#cc6600', ox, oy); px(hx + hw - 1, iy, '#cc6600', ox, oy) + } + }, +} + +export const gardenGnome: Archetype = { + name: 'garden_gnome', weight: 0.02, canHaveMohawk: false, + dimensionOverrides: () => ({ legH: 4 }), + drawFeatures: (p) => { + const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p + if (ko) return + // Pointy red hat + for (let h = 0; h < 6; h++) { + const w = Math.max(1, 4 - h) + for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) { + px(ix, hy - 1 - h, '#ff0000', ox, oy) + } + } + // Big white beard + for (let iy = hy + Math.floor(hh * 0.5); iy < hy + hh + 4; iy++) { + const bw2 = Math.max(1, 4 - (iy - hy - Math.floor(hh * 0.5))) + for (let ix = cx + hOff - bw2; ix <= cx + hOff + bw2; ix++) { + px(ix, iy, '#ffffff', ox, oy) + } + } + // Rosy cheeks + px(hx + 1, hy + Math.floor(hh * 0.5), '#ff8888', ox, oy) + px(hx + hw - 2, hy + Math.floor(hh * 0.5), '#ff8888', ox, oy) + // Belt with buckle + for (let ix = bx; ix < bx + bw; ix++) px(ix, by + Math.floor(bh * 0.7), '#886633', ox, oy) + px(cx + hOff, by + Math.floor(bh * 0.7), '#ffcc00', ox, oy) + // Fishing rod or lantern + px(bx + bw + 1, by + 2, '#886633', ox, oy) + px(bx + bw + 1, by + 3, '#886633', ox, oy) + px(bx + bw + 2, by + 3, '#ffcc00', ox, oy) + }, +} + +export const lampPost: Archetype = { + name: 'lamp_post', weight: 0.02, canHaveMohawk: false, + dimensionOverrides: () => ({ legH: 2, legW: 3 }), + drawFeatures: (p) => { + const { px, ox, oy, t, ko, hx, hy, hh, hw, cx, hOff, bx, by } = p + if (ko) return + // Lamp shade on top + for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#445566', ox, oy) + // Light glow + const glow = Math.sin(t * Math.PI * 2) > 0 ? '#ffee88' : '#ffcc44' + for (let ix = hx - 1; ix < hx + hw + 1; ix++) px(ix, hy - 2, glow, ox, oy) + px(hx + Math.floor(hw / 2), hy - 3, glow, ox, oy) + // Moths circling (tiny dots) + const moth1X = cx + hOff + Math.round(Math.sin(t * Math.PI * 4) * 5) + const moth1Y = hy - 3 + Math.round(Math.cos(t * Math.PI * 4) * 3) + px(moth1X, moth1Y, '#ccccaa', ox, oy) + const moth2X = cx + hOff + Math.round(Math.cos(t * Math.PI * 3) * 4) + const moth2Y = hy - 2 + Math.round(Math.sin(t * Math.PI * 3) * 2) + px(moth2X, moth2Y, '#ccccaa', ox, oy) + // Base plate + for (let ix = bx - 1; ix < bx + p.bw + 1; ix++) px(ix, by + p.bh + 1, '#445566', ox, oy) + }, +} + +export const broomMan: Archetype = { + name: 'broom_man', weight: 0.02, canHaveMohawk: false, + drawFeatures: (p) => { + const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY } = p + if (ko) return + // Bristles at feet + const footY = feetY + globalY + p.vBounce + for (let ix = bx - 2; ix < bx + bw + 2; ix++) { + px(ix, footY + 1, '#ccaa55', ox, oy) + px(ix, footY + 2, '#bbaa44', ox, oy) + } + // Handle extends up through head + for (let iy = hy - 4; iy < hy; iy++) { + px(cx + hOff, iy, '#886633', ox, oy) + } + // Dust cloud when moving + if (!p.idle && !p.knockback) { + px(bx - 3, footY + 2, '#ccccaa', ox, oy) + px(bx - 4, footY + 1, '#ccccaa', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/bee.ts b/frontend/src/game/sprites/archetypes/bee.ts new file mode 100644 index 0000000..3bfecd1 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/bee.ts @@ -0,0 +1,60 @@ +import type { Archetype } from '../constants' + +export const bee: Archetype = { + name: 'bee', + weight: 0.03, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, knockback, win, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, bounce } = p + + // Yellow/black stripes on body + for (let iy = by; iy < by + bh; iy++) { + if ((iy - by) % 3 === 0) { + for (let ix = bx + 1; ix < bx + bw - 1; ix++) { + px(ix, iy, '#ffcc00', ox, oy) + } + } + } + + // Wings (transparent, buzzing) + if (!ko && !knockback) { + const wingPhase = idle || win ? Math.sin(t * Math.PI * 8) * 3 : 0 + // Left wing + const lwx = bx - 3 + const lwy = by - 2 + Math.round(wingPhase) + px(lwx, lwy, '#aaddff', ox, oy) + px(lwx - 1, lwy, '#88bbee', ox, oy) + px(lwx - 2, lwy - 1, '#88bbee', ox, oy) + px(lwx, lwy - 1, '#aaddff', ox, oy) + px(lwx - 1, lwy - 1, '#aaddff', ox, oy) + // Right wing + const rwx = bx + bw + 2 + const rwy = by - 2 - Math.round(wingPhase) + px(rwx, rwy, '#aaddff', ox, oy) + px(rwx + 1, rwy, '#88bbee', ox, oy) + px(rwx + 2, rwy - 1, '#88bbee', ox, oy) + px(rwx, rwy - 1, '#aaddff', ox, oy) + px(rwx + 1, rwy - 1, '#aaddff', ox, oy) + } + + // Stinger at bottom + if (!ko) { + const stingX = cx + hOff + const stingY = feetY + globalY + 2 + px(stingX, stingY, '#222222', ox, oy) + px(stingX, stingY + 1, '#111111', ox, oy) + px(stingX, stingY + 2, '#ffcc00', ox, oy) + } + + // Antennae (short, bobbing) + if (!ko) { + const antWobble = idle ? Math.round(Math.sin(t * Math.PI * 3) * 1) : 0 + px(hx + 2, hy - 1, '#222222', ox, oy) + px(hx + 1, hy - 2 + antWobble, '#222222', ox, oy) + px(hx + 0, hy - 3 + antWobble, '#ffcc00', ox, oy) + px(hx + hw - 3, hy - 1, '#222222', ox, oy) + px(hx + hw - 2, hy - 2 - antWobble, '#222222', ox, oy) + px(hx + hw - 1, hy - 3 - antWobble, '#ffcc00', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/blob.ts b/frontend/src/game/sprites/archetypes/blob.ts new file mode 100644 index 0000000..26bcd53 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/blob.ts @@ -0,0 +1,53 @@ +import type { Archetype } from '../constants' + +export const blob: Archetype = { + name: 'blob', + weight: 0.14, + canHaveMohawk: false, + dimensionOverrides: (tier) => ({ + bw: 12 + tier * 2, + bh: 10 + tier, + hw: 12 + tier, + hh: 11 + tier, + legH: 4, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, hit, ko, + cx, hOff, by, bw, bh, hx, hw, hy, hh, globalY } = p + + // Amorphous body -- wobbly outline + const wobAmp = idle ? 1 : hit ? 2 : 0 + for (let s = 0; s < 8; s++) { + const sa = s * Math.PI * 2 / 8 + t * 0.3 + const sr = Math.floor(bw / 2) + 2 + Math.round(Math.sin(sa * 3 + t * Math.PI * 4) * wobAmp) + const sx = cx + hOff + Math.round(Math.cos(sa) * sr) + const sy = by + Math.floor(bh / 2) + Math.round(Math.sin(sa) * (bh / 2)) + globalY + px(sx, sy, pal.light, ox, oy) + px(sx, sy + 1, pal.light, ox, oy) + } + // Googly eyes -- oversized, bouncy + if (!ko) { + const geS = 3 + Math.floor(tier * 0.3) + const geLx = hx + 1 + const geRx = hx + hw - geS - 1 + const geY = hy + Math.floor(hh * 0.2) + // Big white circles + for (let ey = geY; ey < geY + geS; ey++) { + for (let ex = geLx; ex < geLx + geS; ex++) px(ex, ey, '#ffffff', ox, oy) + for (let ex = geRx; ex < geRx + geS; ex++) px(ex, ey, '#ffffff', ox, oy) + } + // Bouncing pupils + const pupOff = idle ? Math.round(Math.sin(t * Math.PI * 2) * 1) : 0 + px(geLx + Math.floor(geS / 2) + pupOff, geY + geS - 2, '#000000', ox, oy) + px(geLx + Math.floor(geS / 2) + pupOff + 1, geY + geS - 2, '#000000', ox, oy) + px(geRx + Math.floor(geS / 2) - pupOff, geY + geS - 2, '#000000', ox, oy) + px(geRx + Math.floor(geS / 2) - pupOff + 1, geY + geS - 2, '#000000', ox, oy) + } + // Drool / slime drip + if (idle || hit) { + const drY = hy + Math.floor(hh * 0.8) + Math.round(t * 2) + px(cx + hOff, drY, pal.accLight, ox, oy) + px(cx + hOff, drY + 1, pal.accLight, ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/cactus.ts b/frontend/src/game/sprites/archetypes/cactus.ts new file mode 100644 index 0000000..19f0044 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/cactus.ts @@ -0,0 +1,47 @@ +import type { Archetype } from '../constants' + +export const cactus: Archetype = { + name: 'cactus', + weight: 0.03, + canHaveMohawk: false, + dimensionOverrides: () => ({ + bw: 14, + armW: 2, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, tier, ko, + bx, by, bw, bh, hx, hy, hh } = p + + // Green body override tint + const green = '#2d8a4e' + const darkGreen = '#1a5e33' + + // Spikes all over body + if (!ko) { + for (let s = 0; s < 8 + tier; s++) { + const sx = bx + Math.floor(Math.random() * bw) + const sy = by + Math.floor(Math.random() * bh) + const sDir = sx < bx + bw / 2 ? -1 : 1 + px(sx + sDir * 1, sy, '#cccc44', ox, oy) + px(sx + sDir * 2, sy, '#aaaa33', ox, oy) + } + } + + // Spikes on head + for (let s = 0; s < 5; s++) { + const sx = hx + 1 + Math.floor(s * (p.hw - 2) / 4) + px(sx, hy - 1, '#cccc44', ox, oy) + px(sx, hy - 2, '#aaaa33', ox, oy) + } + + // Small flower on top + const flowerY = hy - 3 + const flowerX = hx + Math.floor(p.hw / 2) + 2 + px(flowerX, flowerY, '#ff66aa', ox, oy) + px(flowerX - 1, flowerY, '#ff88cc', ox, oy) + px(flowerX + 1, flowerY, '#ff88cc', ox, oy) + px(flowerX, flowerY - 1, '#ff88cc', ox, oy) + px(flowerX, flowerY + 1, '#ff88cc', ox, oy) + px(flowerX, flowerY, '#ffff00', ox, oy) // center + }, +} diff --git a/frontend/src/game/sprites/archetypes/cat.ts b/frontend/src/game/sprites/archetypes/cat.ts new file mode 100644 index 0000000..63de05a --- /dev/null +++ b/frontend/src/game/sprites/archetypes/cat.ts @@ -0,0 +1,54 @@ +import type { Archetype } from '../constants' + +export const cat: Archetype = { + name: 'cat', + weight: 0.04, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, idle, ko, knockback, atk, special, + cx, hOff, hx, hw, hy, hh, bw, feetY, globalY, bounce } = p + + if (ko) return + + // Pointed ears (triangles above head) + px(hx, hy - 1, pal.body, ox, oy) + px(hx - 1, hy - 2, pal.body, ox, oy) + px(hx - 2, hy - 3, pal.dark, ox, oy) + px(hx, hy - 2, '#ffaaaa', ox, oy) // inner ear pink + px(hx + hw - 1, hy - 1, pal.body, ox, oy) + px(hx + hw, hy - 2, pal.body, ox, oy) + px(hx + hw + 1, hy - 3, pal.dark, ox, oy) + px(hx + hw - 1, hy - 2, '#ffaaaa', ox, oy) + + // Whiskers (3 per side) + const wY = hy + Math.floor(hh * 0.5) + for (let w = 0; w < 3; w++) { + const wd = w - 1 + px(hx - 2 - w, wY + wd, pal.out, ox, oy) + px(hx - 3 - w, wY + wd, pal.out, ox, oy) + px(hx + hw + 1 + w, wY + wd, pal.out, ox, oy) + px(hx + hw + 2 + w, wY + wd, pal.out, ox, oy) + } + + // Slit eyes override (narrow pupils) + const eyeY = hy + Math.floor(hh * 0.35) + const leX = hx + Math.floor(hw * 0.25) + const reX = hx + Math.floor(hw * 0.65) + if (atk || special) { + px(leX, eyeY, '#ffcc00', ox, oy) + px(reX, eyeY, '#ffcc00', ox, oy) + } + + // Curled tail + if (!knockback) { + const tailDir = -1 + const curlPhase = idle ? t * Math.PI * 2 : 0 + for (let tt = 1; tt <= 5; tt++) { + const curlX = Math.round(Math.sin(curlPhase + tt * 0.5) * 2) + px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt) + curlX, feetY - tt + globalY, pal.body, ox, oy) + } + // Curl at tip + px(cx + hOff + tailDir * (Math.floor(bw / 2) + 5) + 2, feetY - 6 + globalY, pal.dark, ox, oy) + px(cx + hOff + tailDir * (Math.floor(bw / 2) + 5) + 3, feetY - 5 + globalY, pal.dark, ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/cowboy.ts b/frontend/src/game/sprites/archetypes/cowboy.ts new file mode 100644 index 0000000..bcf1a07 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/cowboy.ts @@ -0,0 +1,67 @@ +import type { Archetype } from '../constants' + +export const cowboy: Archetype = { + name: 'cowboy', + weight: 0.03, + canHaveVisor: false, + canHaveMohawk: false, + drawFeatures: (p) => { + const { px, pal, ox, oy, tier, ko, idle, t, bounce, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, vBounce, ll, rl, legW } = p + + // Cowboy hat (wide brim, tall crown) + if (!ko) { + const hatY = hy - 1 + // Wide brim + for (let hbx = hx - 4; hbx < hx + hw + 4; hbx++) { + px(hbx, hatY, '#8B6914', ox, oy) + px(hbx, hatY + 1, '#7a5c12', ox, oy) + } + // Crown (tall rectangle) + for (let hcy = hatY - 4; hcy < hatY; hcy++) { + for (let hcx = hx + 1; hcx < hx + hw - 1; hcx++) { + px(hcx, hcy, '#8B6914', ox, oy) + } + } + // Hat band + for (let hcx = hx + 1; hcx < hx + hw - 1; hcx++) { + px(hcx, hatY - 1, '#cc8833', ox, oy) + } + // Dent in top + px(cx + hOff, hatY - 4, '#7a5c12', ox, oy) + } + + // Bandana (around neck) + if (!ko) { + const bandY = by + for (let bx2 = bx; bx2 < bx + bw; bx2++) { + px(bx2, bandY, '#cc3333', ox, oy) + } + // Hanging triangle + px(cx + hOff, bandY + 1, '#cc3333', ox, oy) + px(cx + hOff - 1, bandY + 1, '#aa2222', ox, oy) + px(cx + hOff + 1, bandY + 1, '#aa2222', ox, oy) + px(cx + hOff, bandY + 2, '#882222', ox, oy) + } + + // Boots with spurs + if (!ko && !p.knockback) { + const bootY = feetY + vBounce + globalY + // Left boot + px(ll - 1, bootY, '#663311', ox, oy) + px(ll + legW, bootY, '#663311', ox, oy) + px(ll + legW + 1, bootY + 1, '#ffd700', ox, oy) // spur + // Right boot + px(rl - 1, bootY, '#663311', ox, oy) + px(rl + legW, bootY, '#663311', ox, oy) + px(rl + legW + 1, bootY + 1, '#ffd700', ox, oy) // spur + } + + // Belt buckle (big oval) + const beltY = by + bh - 2 + px(cx + hOff - 1, beltY, '#ffd700', ox, oy) + px(cx + hOff, beltY, '#ffee44', ox, oy) + px(cx + hOff + 1, beltY, '#ffd700', ox, oy) + px(cx + hOff, beltY - 1, '#ffd700', ox, oy) + }, +} diff --git a/frontend/src/game/sprites/archetypes/cyborg.ts b/frontend/src/game/sprites/archetypes/cyborg.ts new file mode 100644 index 0000000..94f6244 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/cyborg.ts @@ -0,0 +1,43 @@ +import type { Archetype } from '../constants' + +export const cyborg: Archetype = { + name: 'cyborg', + weight: 0.14, + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, t, atk, special, ko, knockback, + bx, by, bw, bh, hx, hw, hy, hh } = p + + // Half-human half-robot: left side robot, right side organic + // Robot half -- metal plating on left arm/body + for (let iy = by; iy < by + bh; iy++) { + px(bx, iy, '#888888', ox, oy) + px(bx + 1, iy, '#666666', ox, oy) + } + // Exposed wiring + if (!ko) { + px(bx + 2, by + 2, '#ff2200', ox, oy) + px(bx + 2, by + 4, '#00ff44', ox, oy) + px(bx + 2, by + 6, '#0088ff', ox, oy) + } + // Robot eye (left eye is mechanical) + const cyEyeY = hy + Math.floor(hh * 0.35) + if (!ko && !knockback) { + const cyEyeX = hx + Math.floor(hw * 0.2) + px(cyEyeX - 1, cyEyeY - 1, '#444444', ox, oy) + px(cyEyeX + 1, cyEyeY - 1, '#444444', ox, oy) + px(cyEyeX, cyEyeY, '#ff0000', ox, oy) + // Glowing scan line + if (p.frame % 2 === 0) px(cyEyeX - 1, cyEyeY, '#ff0000', ox, oy) + } + // Metal jaw plate + const jawY = hy + Math.floor(hh * 0.6) + for (let jx = hx; jx < hx + Math.floor(hw / 2); jx++) { + px(jx, jawY, '#777777', ox, oy) + } + // Sparking joint + if ((atk || special) && t > 0.3) { + px(bx + Math.floor(bw / 2), by - 1, '#ffff00', ox, oy) + px(bx + Math.floor(bw / 2) + 1, by - 2, '#ffffff', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/dinosaur.ts b/frontend/src/game/sprites/archetypes/dinosaur.ts new file mode 100644 index 0000000..5b24d13 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/dinosaur.ts @@ -0,0 +1,65 @@ +import type { Archetype } from '../constants' + +export const dinosaur: Archetype = { + name: 'dinosaur', + weight: 0.03, + dimensionOverrides: (tier) => ({ + bw: 13 + tier * 2, + bh: 9 + tier, + armW: 2, + armH: 3 + Math.floor(tier * 0.5), + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, knockback, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY } = p + + // Spiky back (row of spines along top of body) + if (!ko) { + const spineCount = 4 + tier + for (let s = 0; s < spineCount; s++) { + const sx = bx + 1 + Math.floor(s * (bw - 2) / (spineCount - 1)) + px(sx, by - 1, pal.acc, ox, oy) + px(sx, by - 2, pal.accDark, ox, oy) + if (s % 2 === 0) px(sx, by - 3, pal.accDark, ox, oy) + } + } + + // Big jaw on head (wider lower face) + const jawY = hy + Math.floor(hh * 0.55) + for (let jx = hx; jx < hx + hw; jx++) { + px(jx, jawY, pal.dark, ox, oy) + } + // Teeth (jagged) + for (let tx = hx + 1; tx < hx + hw - 1; tx += 2) { + px(tx, jawY + 1, '#eeeeee', ox, oy) + } + + // Tiny arms (T-rex style — already small from dimension override) + // Just add claws at tips + if (!ko && !knockback) { + px(p.armLx - 1, p.armAttach + p.armH, '#cccc88', ox, oy) + px(p.armRx + p.armW, p.armAttach + p.armH, '#cccc88', ox, oy) + } + + // Thick tail + if (!knockback) { + const tailDir = -1 + for (let tt = 1; tt <= 5 + tier; tt++) { + const tailW = Math.max(1, 3 - Math.floor(tt / 3)) + for (let tw = 0; tw < tailW; tw++) { + px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt), + feetY - Math.floor(tt * 0.7) + tw + globalY, pal.body, ox, oy) + } + } + // Tail tip + px(cx + hOff + tailDir * (Math.floor(bw / 2) + 6 + tier), + feetY - Math.floor((5 + tier) * 0.7) + globalY, pal.dark, ox, oy) + } + + // Nostril on snout + if (!ko) { + const nY = hy + Math.floor(hh * 0.45) + px(hx + hw - 2, nY, '#222222', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/dog.ts b/frontend/src/game/sprites/archetypes/dog.ts new file mode 100644 index 0000000..0bcfe54 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/dog.ts @@ -0,0 +1,56 @@ +import type { Archetype } from '../constants' + +export const dog: Archetype = { + name: 'dog', + weight: 0.04, + drawFeatures: (p) => { + const { px, box, pal, ox, oy, t, tier, idle, ko, knockback, hit, atk, win, + cx, hOff, hx, hw, hy, hh, by, bh, bw, globalY, vBounce, feetY, bounce } = p + + if (ko) return + + // Floppy ears + const earDrop = idle ? Math.abs(bounce) + 2 : hit ? 3 : 1 + px(hx - 1, hy + 1, pal.body, ox, oy) + px(hx - 2, hy + 2, pal.body, ox, oy) + px(hx - 2, hy + 2 + earDrop, pal.dark, ox, oy) + px(hx - 3, hy + 3 + earDrop, pal.dark, ox, oy) + px(hx + hw, hy + 1, pal.body, ox, oy) + px(hx + hw + 1, hy + 2, pal.body, ox, oy) + px(hx + hw + 1, hy + 2 + earDrop, pal.dark, ox, oy) + px(hx + hw + 2, hy + 3 + earDrop, pal.dark, ox, oy) + + // Snout with nose + const mY = hy + Math.floor(hh * 0.55) + px(cx + hOff + 2, mY, pal.skin, ox, oy) + px(cx + hOff + 3, mY, pal.skin, ox, oy) + px(cx + hOff + 3, mY + 1, pal.skinDark, ox, oy) + px(cx + hOff + 4, mY, '#222222', ox, oy) // nose + + // Tongue (sticks out when idle or winning) + if (idle || win) { + const tongueY = mY + 2 + Math.round(Math.sin(t * Math.PI * 2) * 0.5) + px(cx + hOff + 2, tongueY, '#ff6688', ox, oy) + px(cx + hOff + 3, tongueY, '#ff6688', ox, oy) + px(cx + hOff + 2, tongueY + 1, '#ee5577', ox, oy) + } + + // Wagging tail + if (!knockback) { + const wagOffset = idle || win ? Math.round(Math.sin(t * Math.PI * 4) * 3) : 0 + const tailDir = -1 + for (let tt = 1; tt <= 4; tt++) { + px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt), feetY - tt + globalY + wagOffset, pal.body, ox, oy) + } + px(cx + hOff + tailDir * (Math.floor(bw / 2) + 5), feetY - 5 + globalY + wagOffset, pal.dark, ox, oy) + } + + // Collar + const collarY = by + bh - 3 + for (let cx2 = cx + hOff - Math.floor(bw / 2); cx2 < cx + hOff + Math.floor(bw / 2); cx2++) { + px(cx2, collarY, '#cc2222', ox, oy) + } + // Tag + px(cx + hOff, collarY + 1, '#ffd700', ox, oy) + }, +} diff --git a/frontend/src/game/sprites/archetypes/frog.ts b/frontend/src/game/sprites/archetypes/frog.ts new file mode 100644 index 0000000..3b9220a --- /dev/null +++ b/frontend/src/game/sprites/archetypes/frog.ts @@ -0,0 +1,80 @@ +import type { Archetype } from '../constants' + +export const frog: Archetype = { + name: 'frog', + weight: 0.03, + dimensionOverrides: (tier) => ({ + legH: 4 + Math.floor(tier * 0.5), + legW: 4 + Math.floor(tier * 0.5), + }), + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, t, tier, idle, ko, atk, special, knockback, + hx, hy, hh, hw, cx, hOff, bx, by, bw, bh, feetY, globalY, vBounce, ll, rl, legW } = p + + // Bulging eyes (extend above head) + if (!ko) { + const eyeY = hy - 2 + const leX = hx + 1 + const reX = hx + hw - 4 + // Eye bulges (circles above head) + for (let ey = eyeY - 2; ey <= eyeY; ey++) { + px(leX, ey, '#88cc44', ox, oy) + px(leX + 1, ey, '#88cc44', ox, oy) + px(leX + 2, ey, '#88cc44', ox, oy) + px(reX, ey, '#88cc44', ox, oy) + px(reX + 1, ey, '#88cc44', ox, oy) + px(reX + 2, ey, '#88cc44', ox, oy) + } + // Pupils + px(leX + 1, eyeY, '#000000', ox, oy) + px(reX + 1, eyeY, '#000000', ox, oy) + // Highlight + px(leX, eyeY - 2, '#aaffaa', ox, oy) + px(reX + 2, eyeY - 2, '#aaffaa', ox, oy) + } + + // Wide mouth (extends beyond head width) + if (!ko) { + const mY = hy + Math.floor(hh * 0.7) + for (let mx = hx - 1; mx <= hx + hw; mx++) { + px(mx, mY, '#226622', ox, oy) + } + } + + // Long tongue (on attack) + if (atk || special) { + const tongueY = hy + Math.floor(hh * 0.7) + const tongueLen = Math.round(Math.sin(t * Math.PI) * (15 + tier * 3)) + const dir = p.armRx > cx ? 1 : -1 + for (let tl = 0; tl < tongueLen; tl++) { + px(hx + (dir > 0 ? hw : 0) + dir * tl, tongueY, '#ff4466', ox, oy) + } + // Tongue tip (wider) + if (tongueLen > 3) { + px(hx + (dir > 0 ? hw : 0) + dir * tongueLen, tongueY - 1, '#ff4466', ox, oy) + px(hx + (dir > 0 ? hw : 0) + dir * tongueLen, tongueY + 1, '#ff4466', ox, oy) + } + } + + // Webbed feet + if (!ko && !knockback) { + const footY = feetY + vBounce + globalY + // Extra-wide toe spread + px(ll - 2, footY + 1, '#44aa22', ox, oy) + px(ll + legW + 1, footY + 1, '#44aa22', ox, oy) + px(rl - 2, footY + 1, '#44aa22', ox, oy) + px(rl + legW + 1, footY + 1, '#44aa22', ox, oy) + } + + // Spotted belly + const spots = [ + [bx + 2, by + 2], + [bx + bw - 3, by + 3], + [bx + Math.floor(bw / 2), by + bh - 3], + ] + for (const [sx, sy] of spots) { + px(sx, sy, '#aaee66', ox, oy) + px(sx + 1, sy, '#aaee66', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/ghost.ts b/frontend/src/game/sprites/archetypes/ghost.ts new file mode 100644 index 0000000..f9946ce --- /dev/null +++ b/frontend/src/game/sprites/archetypes/ghost.ts @@ -0,0 +1,64 @@ +import type { Archetype } from '../constants' + +export const ghost: Archetype = { + name: 'ghost', + weight: 0.03, + canHaveMohawk: false, + canHaveHorns: false, + dimensionOverrides: () => ({ + legH: 2, + legW: 2, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, idle, ko, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY } = p + + // Wavy bottom (no real legs -- ghostly wisp) + if (!ko) { + const waveY = by + bh + for (let wx = bx; wx < bx + bw; wx++) { + const wave = Math.round(Math.sin(t * Math.PI * 3 + wx * 0.4) * 2) + px(wx, waveY + wave, pal.light, ox, oy) + px(wx, waveY + wave + 1, pal.light, ox, oy) + px(wx, waveY + wave + 2, pal.body, ox, oy) + } + } + + // Semi-transparent overlay effect (lighter body patches) + for (let iy = by + 1; iy < by + bh; iy += 2) { + for (let ix = bx + 1; ix < bx + bw - 1; ix += 3) { + px(ix, iy, pal.light, ox, oy) + } + } + + // Glowing eyes (large, circular) + if (!ko) { + const eyeY = hy + Math.floor(hh * 0.35) + const leX = hx + Math.floor(hw * 0.2) + const reX = hx + Math.floor(hw * 0.6) + // Large glowing circles + px(leX, eyeY, '#44ffff', ox, oy) + px(leX + 1, eyeY, '#44ffff', ox, oy) + px(leX, eyeY + 1, '#22cccc', ox, oy) + px(leX + 1, eyeY + 1, '#22cccc', ox, oy) + px(reX, eyeY, '#44ffff', ox, oy) + px(reX + 1, eyeY, '#44ffff', ox, oy) + px(reX, eyeY + 1, '#22cccc', ox, oy) + px(reX + 1, eyeY + 1, '#22cccc', ox, oy) + // Glow halo + const glowPulse = idle ? Math.sin(t * Math.PI * 4) * 0.5 : 0 + if (glowPulse > 0) { + px(leX - 1, eyeY, '#22ffff', ox, oy) + px(reX + 2, eyeY, '#22ffff', ox, oy) + } + } + + // Open mouth (always slightly open, spooky) + if (!ko) { + const mY = hy + Math.floor(hh * 0.65) + px(cx + hOff, mY, '#111122', ox, oy) + px(cx + hOff - 1, mY, '#111122', ox, oy) + px(cx + hOff + 1, mY, '#111122', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/index.ts b/frontend/src/game/sprites/archetypes/index.ts new file mode 100644 index 0000000..caf5506 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/index.ts @@ -0,0 +1,68 @@ +import type { Archetype } from '../constants' +import { standard } from './standard' +import { lobster } from './lobster' +import { sheep } from './sheep' +import { cyborg } from './cyborg' +import { blob } from './blob' +import { tank } from './tank' +import { dog } from './dog' +import { cat } from './cat' +import { cactus } from './cactus' +import { pizza } from './pizza' +import { mushroom } from './mushroom' +import { shark } from './shark' +import { penguin } from './penguin' +import { octopus } from './octopus' +import { skeleton } from './skeleton' +import { ghost } from './ghost' +import { alien } from './alien' +import { dinosaur } from './dinosaur' +import { pirate } from './pirate' +import { ninja } from './ninja' +import { cowboy } from './cowboy' +import { wizard } from './wizard' +import { bee } from './bee' +import { frog } from './frog' +import { snail } from './snail' +// Batch 4: Robots & Tech +import { robot, android, droneBug, toaster, tvHead, calculator, satellite, mech, ledCube, circuit, antennaBug, microwave, cyberdog, robocat, ufoBot } from './batch_robots' +// Batch 5: Mythology & Fantasy +import { minotaur, unicorn, phoenix, dragonArch, mermaid, griffin, cyclops, gargoyle, golem, vampire, werewolf, zombie, witch, demon } from './batch_mythology' +// Batch 6: Jobs & Warriors +import { chef, firefighter, astronautArch, clown, detective, nurse, lumberjack, scientist, wrestler, boxer, gladiator, samuraiArch, vikingArch, knight } from './batch_jobs' +// Batch 7: More Animals +import { elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster } from './batch_animals2' +// Batch 8: Silly & Objects +import { sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan } from './batch_silly' + +export const archetypes: Archetype[] = [ + // Original 6 + standard, lobster, sheep, cyborg, blob, tank, + // Batch 1: animals & food + dog, cat, cactus, pizza, mushroom, shark, penguin, octopus, + // Batch 2: fantasy & themed + skeleton, ghost, alien, dinosaur, pirate, ninja, cowboy, wizard, + // Batch 3: critters + bee, frog, snail, + // Batch 4: robots & tech + robot, android, droneBug, toaster, tvHead, calculator, satellite, mech, ledCube, circuit, antennaBug, microwave, cyberdog, robocat, ufoBot, + // Batch 5: mythology & fantasy + minotaur, unicorn, phoenix, dragonArch, mermaid, griffin, cyclops, gargoyle, golem, vampire, werewolf, zombie, witch, demon, + // Batch 6: jobs & warriors + chef, firefighter, astronautArch, clown, detective, nurse, lumberjack, scientist, wrestler, boxer, gladiator, samuraiArch, vikingArch, knight, + // Batch 7: more animals + elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster, + // Batch 8: silly & objects + sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan, +] + +export function rollArchetype(roll: number): Archetype { + // Normalize: sum all weights, then compare proportionally + const totalWeight = archetypes.reduce((sum, a) => sum + a.weight, 0) + let cumulative = 0 + for (const arch of archetypes) { + cumulative += arch.weight / totalWeight + if (roll < cumulative) return arch + } + return archetypes[0] +} diff --git a/frontend/src/game/sprites/archetypes/lobster.ts b/frontend/src/game/sprites/archetypes/lobster.ts new file mode 100644 index 0000000..6bf026d --- /dev/null +++ b/frontend/src/game/sprites/archetypes/lobster.ts @@ -0,0 +1,58 @@ +import type { Archetype } from '../constants' + +export const lobster: Archetype = { + name: 'lobster', + weight: 0.15, + canHaveHorns: false, + dimensionOverrides: (tier) => ({ + hw: 8 + tier, + hh: 7 + tier, + armW: 4, + armH: 4 + tier, + }), + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, t, tier, idle, atk, kick, special, knockback, ko, + cx, hOff, armLx, armRx, armAttach, armH, armW, globalY, vBounce, + hx, hw, hy, bx, bw, by, bh, feetY } = p + if (ko) return + + // Lobster claws replace fists -- big pincers on each arm + const clawSize = 4 + Math.floor(tier * 0.5) + const clawOpen = (atk || kick || special) ? 2 : 0 + // Left claw + const lcx = armLx - 2 + hOff + const lcy = armAttach + armH + globalY + vBounce + px(lcx, lcy, pal.acc, ox, oy); px(lcx + 1, lcy, pal.acc, ox, oy) + px(lcx - 1, lcy - clawOpen, pal.acc, ox, oy); px(lcx + 2, lcy - clawOpen, pal.acc, ox, oy) + px(lcx - 1, lcy + 1 + clawOpen, pal.acc, ox, oy); px(lcx + 2, lcy + 1 + clawOpen, pal.acc, ox, oy) + for (let c = 0; c < clawSize; c++) { px(lcx - 2 - c, lcy - clawOpen, pal.accDark, ox, oy); px(lcx - 2 - c, lcy + 1 + clawOpen, pal.accDark, ox, oy) } + // Right claw + const rcx = armRx + armW + hOff + const rcy = armAttach + armH + globalY + vBounce - (atk ? Math.round(Math.sin(t * Math.PI) * 6) : 0) + px(rcx, rcy, pal.acc, ox, oy); px(rcx + 1, rcy, pal.acc, ox, oy) + px(rcx - 1, rcy - clawOpen, pal.acc, ox, oy); px(rcx + 2, rcy - clawOpen, pal.acc, ox, oy) + px(rcx - 1, rcy + 1 + clawOpen, pal.acc, ox, oy); px(rcx + 2, rcy + 1 + clawOpen, pal.acc, ox, oy) + for (let c = 0; c < clawSize; c++) { px(rcx + 3 + c, rcy - clawOpen, pal.accDark, ox, oy); px(rcx + 3 + c, rcy + 1 + clawOpen, pal.accDark, ox, oy) } + // Antennae -- long feelers from head + const antL = 5 + tier + for (let a = 1; a <= antL; a++) { + const wobble = idle ? Math.round(Math.sin(t * Math.PI * 2 + a * 0.5) * 1) : 0 + px(hx + 2 + hOff, hy - a + wobble, pal.acc, ox, oy) + px(hx + hw - 3 + hOff, hy - a - wobble, pal.acc, ox, oy) + } + px(hx + 2 + hOff, hy - antL - 1, pal.accLight, ox, oy) + px(hx + hw - 3 + hOff, hy - antL - 1, pal.accLight, ox, oy) + // Segmented body lines + for (let s = by + 2; s < by + bh; s += 2) { + for (let sx = bx; sx < bx + bw; sx++) px(sx, s, pal.accDark, ox, oy) + } + // Tail + if (!knockback) { + const tailDir = -1 // behind + for (let tt = 1; tt <= 3; tt++) { + px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt), feetY - tt + globalY, pal.accDark, ox, oy) + px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt), feetY - tt + 1 + globalY, pal.accDark, ox, oy) + } + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/mushroom.ts b/frontend/src/game/sprites/archetypes/mushroom.ts new file mode 100644 index 0000000..829a3a1 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/mushroom.ts @@ -0,0 +1,52 @@ +import type { Archetype } from '../constants' + +export const mushroom: Archetype = { + name: 'mushroom', + weight: 0.03, + canHaveMohawk: false, + canHaveHorns: false, + dimensionOverrides: (tier) => ({ + hh: 12 + tier, + legH: 4, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, + hx, hy, hw, hh, cx, hOff, by, bh, globalY } = p + + // Dome cap on head (wider than head, rounded) + const capW = hw + 6 + const capH = Math.floor(hh * 0.6) + const capX = hx - 3 + const capY = hy - capH + 2 + for (let iy = 0; iy < capH; iy++) { + const rowW = capW - Math.floor(iy * iy / capH) + const rx = capX + Math.floor((capW - rowW) / 2) + for (let ix = rx; ix < rx + rowW; ix++) { + px(ix, capY + iy, pal.acc, ox, oy) + } + } + + // Spots on cap + if (!ko) { + const spotPositions = [ + [capX + 2, capY + 2], + [capX + capW - 3, capY + 3], + [capX + Math.floor(capW / 2), capY + 1], + [capX + Math.floor(capW / 3), capY + capH - 2], + ] + for (const [sx, sy] of spotPositions) { + px(sx, sy, pal.accLight, ox, oy) + px(sx + 1, sy, pal.accLight, ox, oy) + } + } + + // Spore particles (floating up when idle) + if (idle) { + for (let s = 0; s < 3; s++) { + const sporeX = cx + hOff + Math.round(Math.sin(t * Math.PI * 2 + s * 2) * 8) + const sporeY = by + bh + 2 - Math.round(t * 4 + s * 3) % 10 + px(sporeX, sporeY + globalY, pal.accLight, ox, oy) + } + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/ninja.ts b/frontend/src/game/sprites/archetypes/ninja.ts new file mode 100644 index 0000000..24c74b6 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/ninja.ts @@ -0,0 +1,68 @@ +import type { Archetype } from '../constants' + +export const ninja: Archetype = { + name: 'ninja', + weight: 0.03, + canHaveVisor: false, + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, tier, ko, idle, atk, special, t, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + + // Mask covering lower face + if (!ko) { + const maskY = hy + Math.floor(hh * 0.5) + for (let my = maskY; my < hy + hh; my++) { + for (let mx = hx + 1; mx < hx + hw - 1; mx++) { + px(mx, my, '#222222', ox, oy) + } + } + } + + // Headband (dark with knot trailing) + if (!ko) { + const bandY = hy + 2 + for (let bx2 = hx; bx2 < hx + hw; bx2++) { + px(bx2, bandY, '#cc2222', ox, oy) + } + // Trailing tails + const trail = idle ? Math.round(Math.sin(t * Math.PI * 2) * 1) : 0 + px(hx - 1, bandY + 1 + trail, '#cc2222', ox, oy) + px(hx - 2, bandY + 2 + trail, '#aa1111', ox, oy) + px(hx - 3, bandY + 3 + trail, '#881111', ox, oy) + } + + // Throwing star on back + if (!ko) { + const starX = bx + bw - 2 + const starY = by + 2 + px(starX, starY, '#aaaaaa', ox, oy) + px(starX - 1, starY - 1, '#888888', ox, oy) + px(starX + 1, starY - 1, '#888888', ox, oy) + px(starX - 1, starY + 1, '#888888', ox, oy) + px(starX + 1, starY + 1, '#888888', ox, oy) + } + + // Dark body wraps + for (let wy = by + 1; wy < by + bh; wy += 3) { + for (let wx = bx; wx < bx + bw; wx++) { + px(wx, wy, '#1a1a2a', ox, oy) + } + } + + // Visible eyes only (narrow, intense) + if (!ko) { + const eyeY = hy + Math.floor(hh * 0.35) + const leX = hx + Math.floor(hw * 0.2) + const reX = hx + Math.floor(hw * 0.6) + // Narrow slits + px(leX, eyeY + 1, '#ffffff', ox, oy) + px(leX + 1, eyeY + 1, '#ffffff', ox, oy) + px(reX, eyeY + 1, '#ffffff', ox, oy) + px(reX + 1, eyeY + 1, '#ffffff', ox, oy) + if (atk || special) { + px(leX, eyeY + 1, '#ff4444', ox, oy) + px(reX + 1, eyeY + 1, '#ff4444', ox, oy) + } + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/octopus.ts b/frontend/src/game/sprites/archetypes/octopus.ts new file mode 100644 index 0000000..ae59d73 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/octopus.ts @@ -0,0 +1,59 @@ +import type { Archetype } from '../constants' + +export const octopus: Archetype = { + name: 'octopus', + weight: 0.03, + canHaveMohawk: false, + dimensionOverrides: (tier) => ({ + hw: 13 + tier, + hh: 10 + tier, + legH: 3, + armW: 2, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, knockback, + cx, hOff, bx, by, bw, bh, feetY, globalY, vBounce } = p + + // Tentacle legs (4 visible, wavy) + if (!ko) { + for (let leg = 0; leg < 4; leg++) { + const baseX = bx + Math.floor(leg * bw / 3) + hOff + const baseY = feetY + globalY + for (let seg = 0; seg < 6; seg++) { + const wave = idle ? Math.round(Math.sin(t * Math.PI * 3 + leg * 1.5 + seg * 0.8) * 2) : 0 + const tx = baseX + wave + const ty = baseY + seg + const color = seg < 4 ? pal.body : pal.dark + px(tx, ty, color, ox, oy) + px(tx + 1, ty, color, ox, oy) + } + // Suction cups + if (idle) { + px(bx + Math.floor(leg * bw / 3) + hOff + 1, feetY + 3 + globalY, pal.accLight, ox, oy) + } + } + } + + // Tentacle arms (replace normal arms with wavy ones) + if (!ko && !knockback) { + for (let arm = 0; arm < 2; arm++) { + const armDir = arm === 0 ? -1 : 1 + const armBase = cx + hOff + armDir * Math.floor(bw / 2 + 2) + for (let seg = 0; seg < 5 + tier; seg++) { + const wave = idle ? Math.round(Math.sin(t * Math.PI * 2 + arm * Math.PI + seg * 0.6) * 2) : 0 + px(armBase + armDir * seg, by + 3 + wave + globalY, pal.body, ox, oy) + // Suction cup + if (seg % 2 === 1) px(armBase + armDir * seg, by + 4 + wave + globalY, pal.accLight, ox, oy) + } + } + } + + // Large head already provided by base, but add dome bump + const domeX = cx + hOff + const domeY = p.hy - 2 + px(domeX, domeY, pal.body, ox, oy) + px(domeX - 1, domeY, pal.body, ox, oy) + px(domeX + 1, domeY, pal.body, ox, oy) + px(domeX, domeY - 1, pal.dark, ox, oy) + }, +} diff --git a/frontend/src/game/sprites/archetypes/penguin.ts b/frontend/src/game/sprites/archetypes/penguin.ts new file mode 100644 index 0000000..57c36a3 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/penguin.ts @@ -0,0 +1,58 @@ +import type { Archetype } from '../constants' + +export const penguin: Archetype = { + name: 'penguin', + weight: 0.03, + dimensionOverrides: (tier) => ({ + armW: 4, + armH: 6 + tier, + }), + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, tier, ko, idle, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, bounce } = p + + // White belly (tuxedo front) + const bellyX = bx + 2 + const bellyW = bw - 4 + const bellyY = by + 2 + const bellyH = bh - 3 + for (let iy = bellyY; iy < bellyY + bellyH; iy++) { + for (let ix = bellyX; ix < bellyX + bellyW; ix++) { + px(ix, iy, '#eeeeee', ox, oy) + } + } + + // Orange beak + if (!ko) { + const beakY = hy + Math.floor(hh * 0.5) + const beakX = cx + hOff + px(beakX, beakY, '#ff8800', ox, oy) + px(beakX + 1, beakY, '#ff8800', ox, oy) + px(beakX + 2, beakY, '#ff6600', ox, oy) + px(beakX, beakY + 1, '#ff6600', ox, oy) + } + + // Flipper-like arms (wider, more rounded at tips) + // Flippers already drawn by base as arms, just add tips + if (!ko) { + const armAttach = by + 2 + (idle ? bounce : 0) + // Flipper tips on left + px(bx - 5 + hOff, armAttach + p.armH, pal.dark, ox, oy) + px(bx - 5 + hOff, armAttach + p.armH + 1, pal.dark, ox, oy) + // Flipper tips on right + px(bx + bw + 4 + hOff, armAttach + p.armH, pal.dark, ox, oy) + px(bx + bw + 4 + hOff, armAttach + p.armH + 1, pal.dark, ox, oy) + } + + // Orange feet + if (!ko) { + const feetY = p.feetY + p.vBounce + p.globalY + px(p.ll, feetY, '#ff8800', ox, oy) + px(p.ll + 1, feetY, '#ff8800', ox, oy) + px(p.ll - 1, feetY, '#ff6600', ox, oy) + px(p.rl, feetY, '#ff8800', ox, oy) + px(p.rl + 1, feetY, '#ff8800', ox, oy) + px(p.rl + 2, feetY, '#ff6600', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/pirate.ts b/frontend/src/game/sprites/archetypes/pirate.ts new file mode 100644 index 0000000..4361346 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/pirate.ts @@ -0,0 +1,68 @@ +import type { Archetype } from '../constants' + +export const pirate: Archetype = { + name: 'pirate', + weight: 0.03, + canHaveVisor: false, + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, tier, ko, knockback, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, vBounce, ll, rl, legW } = p + + // Pirate hat (wide brim) + if (!ko) { + const hatY = hy - 2 + // Brim + for (let hbx = hx - 2; hbx < hx + hw + 2; hbx++) { + px(hbx, hatY, '#222222', ox, oy) + } + // Crown of hat + for (let hcy = hatY - 3; hcy < hatY; hcy++) { + for (let hcx = hx + 1; hcx < hx + hw - 1; hcx++) { + px(hcx, hcy, '#222222', ox, oy) + } + } + // Skull emblem + px(cx + hOff, hatY - 2, '#ffffff', ox, oy) + px(cx + hOff - 1, hatY - 1, '#ffffff', ox, oy) + px(cx + hOff + 1, hatY - 1, '#ffffff', ox, oy) + } + + // Eye patch (covers one eye) + if (!ko) { + const eyeY = hy + Math.floor(hh * 0.35) + const patchX = hx + Math.floor(hw * 0.2) + fill(patchX - 1, eyeY - 1, 4, 4, '#111111', ox, oy) + // Strap + for (let sx = patchX + 3; sx < hx + hw; sx++) { + px(sx, eyeY - 1, '#222222', ox, oy) + } + } + + // Peg leg (replaces one leg) + if (!ko && !knockback) { + const pegX = rl + const pegY = p.legsTop + p.vBounce + p.globalY + for (let py = pegY; py < feetY + vBounce + globalY + 2; py++) { + px(pegX + 1, py, '#aa8844', ox, oy) + } + } + + // Hook hand (replaces one arm end) + if (!ko && !knockback) { + const hookX = p.armRx + p.armW + hOff + const hookY = p.armAttach + p.armH + p.globalY + px(hookX, hookY, '#aaaaaa', ox, oy) + px(hookX + 1, hookY + 1, '#888888', ox, oy) + px(hookX, hookY + 1, '#888888', ox, oy) + px(hookX - 1, hookY + 1, '#888888', ox, oy) + } + + // Belt with buckle + const beltY = by + bh - 2 + for (let beltX = bx; beltX < bx + bw; beltX++) { + px(beltX, beltY, '#8B4513', ox, oy) + } + px(cx + hOff, beltY, '#ffd700', ox, oy) + px(cx + hOff + 1, beltY, '#ffd700', ox, oy) + }, +} diff --git a/frontend/src/game/sprites/archetypes/pizza.ts b/frontend/src/game/sprites/archetypes/pizza.ts new file mode 100644 index 0000000..1d0d026 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/pizza.ts @@ -0,0 +1,52 @@ +import type { Archetype } from '../constants' + +export const pizza: Archetype = { + name: 'pizza', + weight: 0.03, + canHaveMohawk: false, + canHaveHorns: false, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p + + // Triangular body shape hint -- darker edges converging up + for (let iy = by; iy < by + bh; iy++) { + const progress = (iy - by) / bh + const indent = Math.floor(progress * 3) + px(bx + indent, iy, '#e8a030', ox, oy) // crust color + px(bx + bw - 1 - indent, iy, '#e8a030', ox, oy) + } + + // Cheese drip effect + if (!ko) { + const dripCount = 3 + Math.floor(tier * 0.5) + for (let d = 0; d < dripCount; d++) { + const dx = bx + 2 + Math.floor(d * (bw - 4) / (dripCount - 1)) + const dLen = 2 + Math.floor(Math.sin(t * Math.PI * 2 + d) * 1.5) + for (let dy = 0; dy < Math.max(1, dLen); dy++) { + px(dx, by + bh + dy, '#ffdd44', ox, oy) + } + } + } + + // Pepperoni spots + const spots = [ + [bx + 3, by + 2], + [bx + bw - 4, by + 3], + [bx + Math.floor(bw / 2), by + Math.floor(bh / 2)], + [bx + 2, by + bh - 3], + [bx + bw - 3, by + bh - 2], + ] + for (const [sx, sy] of spots) { + px(sx, sy, '#cc3322', ox, oy) + px(sx + 1, sy, '#cc3322', ox, oy) + px(sx, sy + 1, '#aa2211', ox, oy) + } + + // Crusty edges on head + const crustY = hy + hh - 1 + for (let cx2 = hx; cx2 < hx + hw; cx2++) { + px(cx2, crustY, '#c88020', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/shark.ts b/frontend/src/game/sprites/archetypes/shark.ts new file mode 100644 index 0000000..528fad8 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/shark.ts @@ -0,0 +1,62 @@ +import type { Archetype } from '../constants' + +export const shark: Archetype = { + name: 'shark', + weight: 0.03, + dimensionOverrides: (tier) => ({ + bw: 12 + tier * 2, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, tier, ko, knockback, atk, idle, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, t } = p + + // Dorsal fin on back + if (!ko && !knockback) { + const finX = cx + hOff + const finY = by - 2 + px(finX, finY, pal.dark, ox, oy) + px(finX, finY - 1, pal.dark, ox, oy) + px(finX, finY - 2, pal.body, ox, oy) + px(finX - 1, finY, pal.dark, ox, oy) + px(finX + 1, finY, pal.dark, ox, oy) + px(finX, finY - 3, pal.body, ox, oy) + } + + // Teeth row on head (visible grin) + if (!ko) { + const teethY = hy + Math.floor(hh * 0.7) + for (let tx = hx + 1; tx < hx + hw - 1; tx += 2) { + px(tx, teethY, '#ffffff', ox, oy) + px(tx, teethY + 1, '#eeeeee', ox, oy) + } + } + + // Tail fin + if (!knockback) { + const tailDir = -1 + const tailX = cx + hOff + tailDir * (Math.floor(bw / 2) + 1) + const tailY = by + Math.floor(bh / 2) + globalY + px(tailX, tailY, pal.dark, ox, oy) + px(tailX + tailDir, tailY - 2, pal.body, ox, oy) + px(tailX + tailDir, tailY + 2, pal.body, ox, oy) + px(tailX + tailDir * 2, tailY - 3, pal.dark, ox, oy) + px(tailX + tailDir * 2, tailY + 3, pal.dark, ox, oy) + } + + // Gills on body (3 slashes) + for (let g = 0; g < 3; g++) { + px(bx + 2, by + 2 + g * 2, pal.dark, ox, oy) + px(bx + 3, by + 2 + g * 2, pal.dark, ox, oy) + } + + // Beady black eyes + if (!ko) { + const eyeY = hy + Math.floor(hh * 0.3) + px(hx + 2, eyeY, '#000000', ox, oy) + px(hx + hw - 3, eyeY, '#000000', ox, oy) + // Tiny white reflection + px(hx + 2, eyeY, '#111111', ox, oy) + px(hx + hw - 3, eyeY, '#111111', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/sheep.ts b/frontend/src/game/sprites/archetypes/sheep.ts new file mode 100644 index 0000000..57ef93a --- /dev/null +++ b/frontend/src/game/sprites/archetypes/sheep.ts @@ -0,0 +1,45 @@ +import type { Archetype } from '../constants' + +export const sheep: Archetype = { + name: 'sheep', + weight: 0.13, + canHaveMohawk: false, + drawFeatures: (p) => { + const { px, box, pal, ox, oy, t, tier, idle, hit, ko, knockback, kick, + cx, hOff, by, bw, bh, hx, hw, hy, hh, globalY, vBounce, feetY, legW, ll, rl } = p + + // Woolly body -- fluffy circles around torso + const woolColors = [pal.light, '#eeeeee', '#dddddd', pal.light] + for (let w = 0; w < 6 + tier; w++) { + const angle = w * Math.PI * 2 / (6 + tier) + (idle ? t * 0.2 : 0) + const wr = Math.floor(bw / 2) + 2 + const wx = cx + hOff + Math.round(Math.cos(angle) * wr) + const wy = by + Math.floor(bh / 2) + Math.round(Math.sin(angle) * (bh / 2 - 1)) + globalY + const wc = woolColors[w % woolColors.length] + px(wx, wy, wc, ox, oy); px(wx + 1, wy, wc, ox, oy) + px(wx, wy + 1, wc, ox, oy) + } + // Fluffy head wool + for (let w = 0; w < 5; w++) { + const wa = w * Math.PI * 2 / 5 + const wrx = Math.round(Math.cos(wa) * (hw / 2 + 1)) + const wry = Math.round(Math.sin(wa) * (hh / 2)) + px(hx + Math.floor(hw / 2) + wrx, hy + Math.floor(hh / 2) + wry - 2, '#eeeeee', ox, oy) + } + // Floppy ears + if (!ko) { + const earDrop = idle ? Math.abs(p.bounce) : hit ? 2 : 0 + px(hx - 1, hy + 2 + earDrop, pal.skin, ox, oy) + px(hx - 2, hy + 3 + earDrop, pal.skin, ox, oy) + px(hx - 2, hy + 4 + earDrop, pal.skinDark, ox, oy) + px(hx + hw, hy + 2 + earDrop, pal.skin, ox, oy) + px(hx + hw + 1, hy + 3 + earDrop, pal.skin, ox, oy) + px(hx + hw + 1, hy + 4 + earDrop, pal.skinDark, ox, oy) + } + // Stubby hooves instead of feet + if (!ko && !knockback && !kick) { + box(ll - 1, feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy) + box(rl - 1, feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/skeleton.ts b/frontend/src/game/sprites/archetypes/skeleton.ts new file mode 100644 index 0000000..9671165 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/skeleton.ts @@ -0,0 +1,58 @@ +import type { Archetype } from '../constants' + +export const skeleton: Archetype = { + name: 'skeleton', + weight: 0.03, + drawFeatures: (p) => { + const { px, pal, ox, oy, ko, + bx, by, bw, bh, hx, hy, hh, hw } = p + + // Visible ribs on body + for (let r = 0; r < 4; r++) { + const ribY = by + 2 + r * 2 + const ribW = bw - 4 - r + const ribX = bx + 2 + Math.floor(r / 2) + for (let rx = ribX; rx < ribX + ribW; rx++) { + px(rx, ribY, '#ddddcc', ox, oy) + } + } + + // Spine down center + for (let sy = by + 1; sy < by + bh - 1; sy++) { + px(bx + Math.floor(bw / 2), sy, '#ccccbb', ox, oy) + } + + // Skull head shape (hollow eyes, jaw) + if (!ko) { + // Dark eye sockets + const eyeY = hy + Math.floor(hh * 0.3) + const leX = hx + Math.floor(hw * 0.2) + const reX = hx + Math.floor(hw * 0.6) + px(leX, eyeY, '#111111', ox, oy) + px(leX + 1, eyeY, '#111111', ox, oy) + px(leX, eyeY + 1, '#111111', ox, oy) + px(leX + 1, eyeY + 1, '#111111', ox, oy) + px(reX, eyeY, '#111111', ox, oy) + px(reX + 1, eyeY, '#111111', ox, oy) + px(reX, eyeY + 1, '#111111', ox, oy) + px(reX + 1, eyeY + 1, '#111111', ox, oy) + // Glowing dots in sockets + px(leX + 1, eyeY + 1, '#ff2222', ox, oy) + px(reX, eyeY + 1, '#ff2222', ox, oy) + } + + // Nose hole + const noseY = hy + Math.floor(hh * 0.5) + px(hx + Math.floor(hw / 2), noseY, '#111111', ox, oy) + + // Teeth on lower face + const jawY = hy + Math.floor(hh * 0.65) + for (let tx = hx + 2; tx < hx + hw - 2; tx += 2) { + px(tx, jawY, '#eeeeee', ox, oy) + } + + // Bony limbs (lighter color on arms/legs) + px(p.armLx + 1, p.armAttach + 2, '#ddddcc', ox, oy) + px(p.armRx + 1, p.armAttach + 2, '#ddddcc', ox, oy) + }, +} diff --git a/frontend/src/game/sprites/archetypes/snail.ts b/frontend/src/game/sprites/archetypes/snail.ts new file mode 100644 index 0000000..19e7635 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/snail.ts @@ -0,0 +1,76 @@ +import type { Archetype } from '../constants' + +export const snail: Archetype = { + name: 'snail', + weight: 0.03, + canHaveMohawk: false, + canHaveHorns: false, + dimensionOverrides: () => ({ + legH: 3, + legW: 5, + }), + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, knockback, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, vBounce } = p + + // Shell on back (spiral circle) + if (!ko) { + const shellX = bx + bw + 1 + const shellY = by + Math.floor(bh / 2) + const shellR = Math.floor(bh / 2) + 2 + // Outer shell + for (let a = 0; a < 16; a++) { + const angle = a * Math.PI * 2 / 16 + const sx = shellX + Math.round(Math.cos(angle) * shellR) + const sy = shellY + Math.round(Math.sin(angle) * shellR) + px(sx, sy, pal.acc, ox, oy) + } + // Inner spiral + for (let a = 0; a < 12; a++) { + const angle = a * Math.PI * 2 / 12 + const r2 = shellR * 0.6 + const sx = shellX + Math.round(Math.cos(angle) * r2) + const sy = shellY + Math.round(Math.sin(angle) * r2) + px(sx, sy, pal.accDark, ox, oy) + } + // Center + px(shellX, shellY, pal.accLight, ox, oy) + px(shellX + 1, shellY, pal.accLight, ox, oy) + } + + // Eye stalks (extend from top of head) + if (!ko) { + const stalkH = 4 + Math.floor(tier * 0.5) + const wobble = idle ? Math.round(Math.sin(t * Math.PI * 2) * 1) : 0 + // Left stalk + for (let s = 0; s < stalkH; s++) { + px(hx + 2 + hOff, hy - 1 - s + wobble, pal.body, ox, oy) + } + px(hx + 2 + hOff, hy - 1 - stalkH + wobble, '#ffffff', ox, oy) + px(hx + 2 + hOff, hy - stalkH + wobble, '#000000', ox, oy) + // Right stalk + for (let s = 0; s < stalkH; s++) { + px(hx + hw - 3 + hOff, hy - 1 - s - wobble, pal.body, ox, oy) + } + px(hx + hw - 3 + hOff, hy - 1 - stalkH - wobble, '#ffffff', ox, oy) + px(hx + hw - 3 + hOff, hy - stalkH - wobble, '#000000', ox, oy) + } + + // Slime trail (behind body, on ground) + if (!ko && !knockback) { + const slimeY = feetY + vBounce + globalY + 2 + for (let sx = bx - 8; sx < bx; sx++) { + px(sx + hOff, slimeY, '#88cc88', ox, oy) + } + } + + // Gooey body texture + if (idle) { + for (let g = 0; g < 3; g++) { + const gx = bx + 1 + g * Math.floor(bw / 3) + const gy = by + bh + Math.round(Math.sin(t * Math.PI * 2 + g) * 1) + px(gx, gy, pal.light, ox, oy) + } + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/standard.ts b/frontend/src/game/sprites/archetypes/standard.ts new file mode 100644 index 0000000..5d95394 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/standard.ts @@ -0,0 +1,8 @@ +import type { Archetype } from '../constants' + +export const standard: Archetype = { + name: 'standard', + weight: 0.30, + canHaveVisor: true, + drawFeatures: () => {}, +} diff --git a/frontend/src/game/sprites/archetypes/tank.ts b/frontend/src/game/sprites/archetypes/tank.ts new file mode 100644 index 0000000..c286d48 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/tank.ts @@ -0,0 +1,48 @@ +import type { Archetype } from '../constants' + +export const tank: Archetype = { + name: 'tank', + weight: 0.14, + dimensionOverrides: (tier) => ({ + bw: 14 + tier * 2, + bh: 6 + tier, + legH: 4 + tier, + legW: 4 + Math.floor(tier * 0.5), + }), + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, tier, ko, knockback, + cx, hOff, bx, by, bw, bh, hx, hw, hy, hh, feetY, globalY, vBounce } = p + + // Heavy armor plating -- double outline + for (let iy = by; iy < by + bh; iy++) { + px(bx - 1, iy, pal.dark, ox, oy) + px(bx + bw, iy, pal.dark, ox, oy) + } + // Rivets + px(bx, by + 1, '#888888', ox, oy); px(bx + bw - 1, by + 1, '#888888', ox, oy) + px(bx, by + bh - 2, '#888888', ox, oy); px(bx + bw - 1, by + bh - 2, '#888888', ox, oy) + // Thick neck (connects head to body more solidly) + const neckW = Math.floor(hw * 0.4) + for (let nx = cx - Math.floor(neckW / 2); nx < cx + Math.floor(neckW / 2); nx++) { + px(nx + hOff, by - 1 + globalY + vBounce, pal.body, ox, oy) + px(nx + hOff, by - 2 + globalY + vBounce, pal.dark, ox, oy) + } + // Treads instead of feet + if (!ko && !knockback) { + const treadY = feetY + vBounce + globalY + for (let tx = cx - Math.floor(bw / 2) - 1; tx <= cx + Math.floor(bw / 2) + 1; tx++) { + px(tx + hOff, treadY, '#444444', ox, oy) + px(tx + hOff, treadY + 1, '#333333', ox, oy) + if (tx % 2 === 0) px(tx + hOff, treadY, '#555555', ox, oy) + } + } + // Helmet visor + if (!ko) { + const vizY = hy + 1 + for (let vx = hx + 1; vx < hx + hw - 1; vx++) { + px(vx, vizY, pal.accDark, ox, oy) + } + px(hx + 1, vizY, pal.accLight, ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/archetypes/wizard.ts b/frontend/src/game/sprites/archetypes/wizard.ts new file mode 100644 index 0000000..a72569f --- /dev/null +++ b/frontend/src/game/sprites/archetypes/wizard.ts @@ -0,0 +1,78 @@ +import type { Archetype } from '../constants' + +export const wizard: Archetype = { + name: 'wizard', + weight: 0.03, + canHaveMohawk: false, + canHaveVisor: false, + drawFeatures: (p) => { + const { px, pal, ox, oy, t, tier, idle, ko, special, atk, frame, + bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, armLx, armAttach, armH, globalY } = p + + // Pointed wizard hat (tall triangle) + if (!ko) { + const hatBase = hy - 1 + const hatHeight = 8 + tier + for (let h = 0; h < hatHeight; h++) { + const rowW = Math.max(1, Math.floor((hatHeight - h) / hatHeight * (hw - 2))) + const rowX = cx + hOff - Math.floor(rowW / 2) + for (let hx2 = rowX; hx2 < rowX + rowW; hx2++) { + px(hx2, hatBase - h, pal.acc, ox, oy) + } + } + // Hat brim + for (let hbx = hx - 2; hbx < hx + hw + 2; hbx++) { + px(hbx, hatBase, pal.accDark, ox, oy) + } + // Star on hat + px(cx + hOff, hatBase - Math.floor(hatHeight * 0.6), '#ffd700', ox, oy) + px(cx + hOff - 1, hatBase - Math.floor(hatHeight * 0.6), '#ffee88', ox, oy) + px(cx + hOff + 1, hatBase - Math.floor(hatHeight * 0.6), '#ffee88', ox, oy) + } + + // Long robe (extends body down, covers legs partially) + if (!ko) { + const robeY = by + bh + for (let ry = 0; ry < 3; ry++) { + const robeW = bw + 2 + ry + const robeX = bx - 1 - Math.floor(ry / 2) + for (let rx = robeX; rx < robeX + robeW; rx++) { + px(rx, robeY + ry, pal.acc, ox, oy) + } + } + } + + // Staff replaces left arm + if (!ko && !p.knockback) { + const staffX = armLx - 1 + hOff + const staffTop = armAttach - 10 + globalY + const staffBot = p.feetY + globalY + 2 + for (let sy = staffTop; sy < staffBot; sy++) { + px(staffX, sy, '#8B6914', ox, oy) + } + // Orb at top + const orbY = staffTop - 1 + px(staffX, orbY, pal.accLight, ox, oy) + px(staffX - 1, orbY, pal.acc, ox, oy) + px(staffX + 1, orbY, pal.acc, ox, oy) + px(staffX, orbY - 1, pal.acc, ox, oy) + // Sparkle when attacking/special + if ((special || atk) && frame % 2 === 0) { + px(staffX - 1, orbY - 1, '#ffffff', ox, oy) + px(staffX + 1, orbY - 1, '#ffff00', ox, oy) + px(staffX, orbY - 2, '#ffffff', ox, oy) + } + } + + // Beard + if (!ko) { + const beardY = hy + Math.floor(hh * 0.65) + for (let by2 = beardY; by2 < beardY + 3 + Math.floor(tier * 0.5); by2++) { + const bWidth = Math.max(1, 3 - (by2 - beardY)) + for (let bbx = cx + hOff - Math.floor(bWidth / 2); bbx <= cx + hOff + Math.floor(bWidth / 2); bbx++) { + px(bbx, by2, '#cccccc', ox, oy) + } + } + } + }, +} diff --git a/frontend/src/game/sprites/constants.ts b/frontend/src/game/sprites/constants.ts new file mode 100644 index 0000000..6111155 --- /dev/null +++ b/frontend/src/game/sprites/constants.ts @@ -0,0 +1,70 @@ +export const FRAME_SIZE = 96 +export const INTERNAL = 48 +export const SCALE = FRAME_SIZE / INTERNAL +export const ANIMATIONS = { + idle: { frames: 4, row: 0 }, + attack: { frames: 6, row: 1 }, + kick: { frames: 5, row: 2 }, + special: { frames: 6, row: 3 }, + hit: { frames: 3, row: 4 }, + knockback: { frames: 5, row: 5 }, + ko: { frames: 5, row: 6 }, + win: { frames: 4, row: 7 }, +} +export const TOTAL_ROWS = Object.keys(ANIMATIONS).length +export const MAX_FRAMES = 6 + +export interface Pal { + body: string; dark: string; light: string + acc: string; accDark: string; accLight: string + out: string; skin: string; skinDark: string +} + +export interface Dimensions { + bw: number; bh: number + hw: number; hh: number + legH: number; legW: number + armW: number; armH: number +} + +export function baseDimensions(tier: number): Dimensions { + return { + bw: 10 + tier * 2, + bh: 8 + tier, + hw: 10 + tier, + hh: 9 + tier, + legH: 6 + tier, + legW: 3 + Math.floor(tier * 0.5), + armW: 3, + armH: 5 + tier, + } +} + +export interface ArchetypeParams { + px: (x: number, y: number, color: string, ox: number, oy: number) => void + box: (x: number, y: number, w: number, h: number, fillColor: string, ox: number, oy: number) => void + fill: (x: number, y: number, w: number, h: number, color: string, ox: number, oy: number) => void + pal: Pal + ox: number; oy: number + t: number; frame: number; bounce: number + tier: number + specialType: 'fire' | 'electric' + idle: boolean; atk: boolean; kick: boolean; special: boolean + hit: boolean; knockback: boolean; ko: boolean; win: boolean + cx: number; ground: number + hOff: number; vBounce: number; koSlump: number; globalY: number + bx: number; by: number; bw: number; bh: number + hx: number; hy: number; hw: number; hh: number + armLx: number; armRx: number; armAttach: number; armW: number; armH: number + feetY: number; legsTop: number; legH: number; legW: number; ll: number; rl: number +} + +export interface Archetype { + name: string + weight: number + canHaveVisor?: boolean + canHaveMohawk?: boolean + canHaveHorns?: boolean + dimensionOverrides?: (tier: number) => Partial + drawFeatures: (p: ArchetypeParams) => void +} diff --git a/frontend/src/game/sprites.ts b/frontend/src/game/sprites/index.ts similarity index 82% rename from frontend/src/game/sprites.ts rename to frontend/src/game/sprites/index.ts index 6ab1d67..1f9d3a0 100644 --- a/frontend/src/game/sprites.ts +++ b/frontend/src/game/sprites/index.ts @@ -1,52 +1,17 @@ -// Pixel-art sprite sheet generator -// 48x48 internal resolution scaled to 96x96 frames -// Many animation states for rich fighting +import { FRAME_SIZE, INTERNAL, SCALE, ANIMATIONS, TOTAL_ROWS, MAX_FRAMES, baseDimensions } from './constants' +import type { Pal, ArchetypeParams } from './constants' +import { makePal } from './palette' +import { rollArchetype, archetypes } from './archetypes' -const FRAME_SIZE = 96 -const INTERNAL = 48 -const SCALE = FRAME_SIZE / INTERNAL -const ANIMATIONS = { - idle: { frames: 4, row: 0 }, - attack: { frames: 6, row: 1 }, - kick: { frames: 5, row: 2 }, - special: { frames: 6, row: 3 }, - hit: { frames: 3, row: 4 }, - knockback: { frames: 5, row: 5 }, - ko: { frames: 5, row: 6 }, - win: { frames: 4, row: 7 }, -} -const TOTAL_ROWS = Object.keys(ANIMATIONS).length -const MAX_FRAMES = 6 - -interface Pal { - body: string; dark: string; light: string - acc: string; accDark: string; accLight: string - out: string; skin: string; skinDark: string -} - -function makePal(primary: string, secondary: string, tier: number): Pal { - const [h, s, l] = parseHSL(primary) - const [h2, s2, l2] = parseHSL(secondary) - return { - body: primary, - dark: `hsl(${h}, ${s}%, ${Math.max(0, l - 20)}%)`, - light: `hsl(${h}, ${Math.min(100, s + 5)}%, ${Math.min(95, l + 15)}%)`, - acc: secondary, - accDark: `hsl(${h2}, ${s2}%, ${Math.max(0, l2 - 20)}%)`, - accLight: `hsl(${h2}, ${Math.min(100, s2)}%, ${Math.min(95, l2 + 15)}%)`, - out: '#0a0a0a', - skin: tier <= 1 ? primary : `hsl(${h}, ${Math.max(20, s - 30)}%, ${Math.min(85, l + 25)}%)`, - skinDark: tier <= 1 ? `hsl(${h}, ${s}%, ${Math.max(0, l - 10)}%)` : `hsl(${h}, ${Math.max(15, s - 35)}%, ${Math.min(75, l + 15)}%)`, - } -} - -function parseHSL(c: string): [number, number, number] { - const m = c.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/) - return m ? [+m[1], +m[2], +m[3]] : [200, 70, 50] -} +export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } from './constants' +export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants' +export { getBotColors } from './palette' +export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge' +export { archetypes, rollArchetype } from './archetypes' export function generateSpriteSheet( seed: string, tier: number, primaryColor: string, secondaryColor: string, + archetypeOverride?: string, ): string { const canvas = document.createElement('canvas') canvas.width = FRAME_SIZE * MAX_FRAMES @@ -61,10 +26,25 @@ export function generateSpriteSheet( const rng = () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 } rng(); rng(); rng() - const hasVisor = rng() > 0.5 && tier >= 2 - const hasMohawk = rng() > 0.5 && tier >= 3 - const hasHorns = rng() > 0.6 && tier >= 4 && !hasMohawk - const specialType = rng() > 0.5 ? 'fire' : 'electric' // determines special attack visuals + // Character archetype -- determined by seed for consistent variety, or forced by override + const archetypeRoll = rng() + const arch = archetypeOverride + ? (archetypes.find(a => a.name === archetypeOverride) || rollArchetype(archetypeRoll)) + : rollArchetype(archetypeRoll) + + // Consume rng in same order as original for determinism + const visorRoll = rng() + const mohawkRoll = rng() + const hornsRoll = rng() + const specialRoll = rng() + + const hasVisor = visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false) + const hasMohawk = mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true) + const hasHorns = hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true) + const specialType: 'fire' | 'electric' = specialRoll > 0.5 ? 'fire' : 'electric' + + // Dimensions: base + archetype overrides + const dims = { ...baseDimensions(tier), ...(arch.dimensionOverrides?.(tier) ?? {}) } function px(x: number, y: number, color: string, ox: number, oy: number) { if (x < 0 || x >= INTERNAL || y < 0 || y >= INTERNAL) return @@ -97,15 +77,7 @@ export function generateSpriteSheet( const ko = pose === 'ko' const win = pose === 'win' - // Dimensions scale with tier - const bw = 10 + tier * 2 // body width - const bh = 8 + tier // body height - const hw = 10 + tier // head width - const hh = 9 + tier // head height - const legH = 6 + tier // leg height - const legW = 3 + Math.floor(tier * 0.5) - const armW = 3 - const armH = 5 + tier + const { bw, bh, hw, hh, legH, legW, armW, armH } = dims // Anchor: center bottom at (24, 42) in 48x48 const cx = 24 @@ -121,7 +93,7 @@ export function generateSpriteSheet( const hOff = hit ? Math.round(t * 3) : knockback ? Math.round(t * 8) : ko ? 2 : 0 const vBounce = idle ? bounce : 0 const koSlump = ko ? Math.round(t * 5) : 0 - const kbLift = knockback ? Math.round(Math.sin(t * Math.PI) * 6) : 0 // arc in the air + const kbLift = knockback ? Math.round(Math.sin(t * Math.PI) * 6) : 0 const globalY = -kbLift // ---- SHADOW ---- @@ -140,17 +112,13 @@ export function generateSpriteSheet( box(ll - 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), pal.dark, ox, oy) box(rl + 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), pal.dark, ox, oy) } else if (kick) { - // Standing leg box(ll, legsTop + globalY, legW, legH, pal.dark, ox, oy) - // Kicking leg — extends horizontally const kickExt = Math.round(Math.sin(t * Math.PI) * (legH + tier * 2)) box(rl, legsTop + Math.floor(legH * 0.3) + globalY, kickExt + legW, legW, pal.dark, ox, oy) - // Foot on kick if (kickExt > 2) { box(rl + kickExt + legW, legsTop + Math.floor(legH * 0.3) - 1 + globalY, 3 + tier, 3, pal.acc, ox, oy) } } else if (knockback) { - // Legs trailing behind in arc box(ll + Math.round(t * -3), legsTop + globalY + 2, legW, legH - 2, pal.dark, ox, oy) box(rl + Math.round(t * -2), legsTop + globalY + 3, legW, legH - 3, pal.dark, ox, oy) } else { @@ -210,7 +178,6 @@ export function generateSpriteSheet( const pw = 2 + Math.floor(tier * 0.5) box(bx - pw - 1, sy, pw + 1, 3, pal.acc, ox, oy) box(bx + bw, sy, pw + 1, 3, pal.acc, ox, oy) - // Highlight px(bx - pw, sy, pal.accLight, ox, oy) px(bx + bw + 1, sy, pal.accLight, ox, oy) } @@ -224,20 +191,16 @@ export function generateSpriteSheet( fill(armLx - 3, armAttach + koSlump + 2, armH, armW, pal.body, ox, oy) fill(armRx + 2, armAttach + koSlump + 2, armH, armW, pal.body, ox, oy) } else if (knockback) { - // Arms flailing behind box(armLx - Math.round(t * 4), armAttach + globalY - 2, armW, armH + 1, pal.body, ox, oy) box(armRx - Math.round(t * 3), armAttach + globalY - 1, armW, armH, pal.body, ox, oy) } else if (atk) { - // Guard left arm box(armLx, armAttach + 2, armW, armH - 2, pal.body, ox, oy) - // Punch right arm const reach = Math.round(Math.sin(t * Math.PI) * (armH + tier * 2)) if (reach > 0) { box(armRx, armAttach - 1, reach + armW, armW + 1, pal.body, ox, oy) const fS = 3 + Math.floor(tier * 0.5) const fC = tier >= 5 ? '#ffd700' : tier >= 3 ? '#ff3333' : pal.body box(armRx + reach + armW, armAttach - 2, fS, fS + 1, fC, ox, oy) - // Impact if (tier >= 2 && t > 0.3 && t < 0.7) { const ix = armRx + reach + armW + fS + 1 px(ix, armAttach - 2, '#ffff00', ox, oy) @@ -247,13 +210,11 @@ export function generateSpriteSheet( px(ix + 2, armAttach + 1, '#ffaa00', ox, oy) } } - // Left glove if (tier >= 3) { const gs = 3 + Math.floor(tier * 0.3) box(armLx - 1, armAttach + armH - 1, gs, gs, tier >= 5 ? '#ffd700' : '#ff3333', ox, oy) } } else if (kick) { - // Both arms in guard box(armLx, armAttach, armW, armH - 1, pal.body, ox, oy) box(armRx, armAttach, armW, armH - 1, pal.body, ox, oy) if (tier >= 3) { @@ -263,49 +224,40 @@ export function generateSpriteSheet( box(armRx, armAttach + armH - 1, gs, gs, gc, ox, oy) } } else if (special) { - // Left arm forward, channeling box(armLx, armAttach, armW, armH, pal.body, ox, oy) - // Right arm extended, casting const ext = Math.round(Math.sin(t * Math.PI) * (armH + 2)) box(armRx, armAttach - 2, ext + armW + 2, armW, pal.body, ox, oy) - // Projectile effect if (t > 0.3) { const projX = armRx + ext + armW + 3 + Math.round(t * 8) const projY = armAttach - 2 if (specialType === 'fire') { - // Fireball px(projX, projY, '#ff4400', ox, oy) px(projX + 1, projY, '#ff6600', ox, oy) px(projX, projY + 1, '#ff8800', ox, oy) px(projX + 1, projY + 1, '#ffaa00', ox, oy) px(projX + 2, projY, '#ffcc00', ox, oy) px(projX - 1, projY, '#ff2200', ox, oy) - // Trail px(projX - 2, projY + 1, '#ff440066', ox, oy) px(projX - 3, projY, '#ff220044', ox, oy) } else { - // Electric bolt px(projX, projY, '#00eeff', ox, oy) px(projX + 1, projY - 1, '#44ffff', ox, oy) px(projX + 2, projY + 1, '#00eeff', ox, oy) px(projX + 3, projY, '#88ffff', ox, oy) px(projX + 1, projY + 1, '#0088ff', ox, oy) - // Sparks px(projX - 1, projY - 1, '#44ffff', ox, oy) px(projX + 4, projY - 1, '#ffffff', ox, oy) } } } else if (win) { box(armLx, armAttach + 2, armW, armH - 1, pal.body, ox, oy) - // Raised arm box(armRx, armAttach - armH + bounce, armW, armH, pal.body, ox, oy) if (tier >= 3) { const gs = 3 + Math.floor(tier * 0.3) box(armRx - 1, armAttach - armH + bounce - gs, gs + 1, gs, tier >= 5 ? '#ffd700' : '#ff3333', ox, oy) } } else { - // Idle const sw = idle ? bounce : hit ? 1 : 0 box(armLx, armAttach + sw + globalY, armW, armH, pal.body, ox, oy) box(armRx, armAttach - sw + globalY, armW, armH, pal.body, ox, oy) @@ -324,7 +276,6 @@ export function generateSpriteSheet( if (tier <= 1) { // BOXY ROBOT box(hx, hy, hw, hh, pal.body, ox, oy) - // Shading for (let iy = hy + 1; iy < hy + hh - 1; iy++) px(hx + hw - 1, iy, pal.dark, ox, oy) px(hx + 1, hy + 1, pal.light, ox, oy) @@ -345,7 +296,6 @@ export function generateSpriteSheet( } else { fill(hx + 2, eyeY, 2, 2, '#00ff41', ox, oy) fill(hx + hw - 4, eyeY, 2, 2, '#00ff41', ox, oy) - // Scanline flicker if (frame % 2 === 0) { px(hx + 2, eyeY, '#00cc33', ox, oy) px(hx + hw - 4, eyeY, '#00cc33', ox, oy) @@ -365,9 +315,9 @@ export function generateSpriteSheet( // Claw pincers (tier 0) if (tier === 0) { - const cy = hy + Math.floor(hh / 2) - px(hx - 2, cy, pal.acc, ox, oy); px(hx - 3, cy - 1, pal.acc, ox, oy); px(hx - 3, cy + 1, pal.acc, ox, oy) - px(hx + hw + 1, cy, pal.acc, ox, oy); px(hx + hw + 2, cy - 1, pal.acc, ox, oy); px(hx + hw + 2, cy + 1, pal.acc, ox, oy) + const cy2 = hy + Math.floor(hh / 2) + px(hx - 2, cy2, pal.acc, ox, oy); px(hx - 3, cy2 - 1, pal.acc, ox, oy); px(hx - 3, cy2 + 1, pal.acc, ox, oy) + px(hx + hw + 1, cy2, pal.acc, ox, oy); px(hx + hw + 2, cy2 - 1, pal.acc, ox, oy); px(hx + hw + 2, cy2 + 1, pal.acc, ox, oy) } } else { // ROUNDED HEAD (tier 2+) @@ -376,14 +326,13 @@ export function generateSpriteSheet( px(hx, iy, pal.body, ox, oy); px(hx + hw - 1, iy, pal.body, ox, oy) px(hx - 1, iy, pal.out, ox, oy); px(hx + hw, iy, pal.out, ox, oy) } - // Shading for (let iy = hy + 2; iy < hy + hh - 2; iy++) { px(hx + hw - 1, iy, pal.dark, ox, oy) px(hx + hw - 2, iy, pal.dark, ox, oy) } px(hx + 2, hy + 1, pal.light, ox, oy); px(hx + 3, hy + 1, pal.light, ox, oy) - // Face area (lighter "skin" for tier 2+) + // Face area if (tier >= 2) { const faceTop = hy + Math.floor(hh * 0.25) const faceBot = hy + Math.floor(hh * 0.75) @@ -407,7 +356,6 @@ export function generateSpriteSheet( px(reX, eyeY, '#ff0000', ox, oy); px(reX + 1, eyeY + 1, '#ff0000', ox, oy) px(reX + 1, eyeY, '#880000', ox, oy); px(reX, eyeY + 1, '#880000', ox, oy) } else if (knockback) { - // Wide shock eyes fill(leX - 1, eyeY, ew + 2, 3, '#ffffff', ox, oy) fill(reX - 1, eyeY, ew + 2, 3, '#ffffff', ox, oy) px(leX, eyeY + 1, '#000000', ox, oy) @@ -419,7 +367,6 @@ export function generateSpriteSheet( px(leX + ps, eyeY + 1, '#000000', ox, oy) px(reX + ps, eyeY + 1, '#000000', ox, oy) - // Eye glow (tier 4+) if (tier >= 4) { px(leX, eyeY, pal.acc, ox, oy) px(reX + ew - 1, eyeY, pal.acc, ox, oy) @@ -429,7 +376,6 @@ export function generateSpriteSheet( } } - // Angry brows when attacking if (atk || kick || special) { px(leX, eyeY - 1, pal.out, ox, oy); px(leX + 1, eyeY - 1, pal.out, ox, oy) px(reX, eyeY - 1, pal.out, ox, oy); px(reX + 1, eyeY - 1, pal.out, ox, oy) @@ -439,7 +385,6 @@ export function generateSpriteSheet( // Mouth const mY = hy + Math.floor(hh * 0.65) if (win) { - // Big grin px(cx + hOff - 2, mY, pal.out, ox, oy) fill(cx + hOff - 1, mY, 3, 1, '#ffffff', ox, oy) px(cx + hOff + 2, mY, pal.out, ox, oy) @@ -447,13 +392,11 @@ export function generateSpriteSheet( px(cx + hOff, mY + 1, pal.out, ox, oy) px(cx + hOff + 1, mY + 1, pal.out, ox, oy) } else if (ko || knockback) { - // Open mouth shock box(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy) } else if (hit) { px(cx + hOff, mY, pal.out, ox, oy) px(cx + hOff + 1, mY, pal.out, ox, oy) } else if (atk || kick || special) { - // Battle yell fill(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy) px(cx + hOff - 1, mY, pal.out, ox, oy) px(cx + hOff + 1, mY, pal.out, ox, oy) @@ -466,7 +409,7 @@ export function generateSpriteSheet( if (hasVisor) { const vY = eyeY - 1 for (let vx = hx + 1; vx < hx + hw - 1; vx++) px(vx, vY, pal.accDark, ox, oy) - px(hx + 1, vY, pal.accLight, ox, oy) // highlight + px(hx + 1, vY, pal.accLight, ox, oy) } // Headband (tier 4+) @@ -507,6 +450,17 @@ export function generateSpriteSheet( } } + // ---- ARCHETYPE-SPECIFIC FEATURES ---- + const archParams: ArchetypeParams = { + px, box, fill, pal, ox, oy, t, frame, bounce, tier, specialType, + idle, atk, kick, special, hit, knockback, ko, win, + cx, ground, hOff, vBounce, koSlump, globalY, + bx, by, bw, bh, hx, hy, hw, hh, + armLx, armRx, armAttach, armW, armH, + feetY, legsTop, legH, legW, ll, rl, + } + arch.drawFeatures(archParams) + // ---- AURA (tier 4+) ---- if (tier >= 4 && !ko) { const aCx = cx + hOff @@ -522,9 +476,9 @@ export function generateSpriteSheet( if (tier >= 5) { for (let p = 0; p < 4; p++) { const pt = (t + p * 0.25) % 1 - const py = ground - Math.round(pt * (ground - hy + 4)) - const ppx = aCx + Math.round(Math.sin(py * 0.4 + p) * 3) - px(ppx, py, p % 2 === 0 ? pal.acc : '#ffd700', ox, oy) + const py2 = ground - Math.round(pt * (ground - hy + 4)) + const ppx = aCx + Math.round(Math.sin(py2 * 0.4 + p) * 3) + px(ppx, py2, p % 2 === 0 ? pal.acc : '#ffd700', ox, oy) } } } @@ -572,15 +526,3 @@ export function generateSpriteSheet( return canvas.toDataURL() } - -export function getBotColors(seed: string): { primary: string; secondary: string } { - let h = 0 - for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 - const hue = Math.abs(h % 360) - return { - primary: `hsl(${hue}, 70%, 50%)`, - secondary: `hsl(${(hue + 140) % 360}, 80%, 60%)`, - } -} - -export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } diff --git a/frontend/src/game/sprites/judge.ts b/frontend/src/game/sprites/judge.ts new file mode 100644 index 0000000..0c2fcde --- /dev/null +++ b/frontend/src/game/sprites/judge.ts @@ -0,0 +1,174 @@ +import { FRAME_SIZE, INTERNAL, SCALE } from './constants' + +export const JUDGE_ANIMATIONS = { + idle: { frames: 4, row: 0 }, + call_left: { frames: 4, row: 1 }, + call_right: { frames: 4, row: 2 }, + shocked: { frames: 4, row: 3 }, +} +export const JUDGE_ROWS = Object.keys(JUDGE_ANIMATIONS).length +export const JUDGE_MAX_FRAMES = 6 + +export function generateJudgeSpriteSheet(): string { + const canvas = document.createElement('canvas') + canvas.width = FRAME_SIZE * JUDGE_MAX_FRAMES + canvas.height = FRAME_SIZE * JUDGE_ROWS + const ctx = canvas.getContext('2d')! + ctx.imageSmoothingEnabled = false + + const red = '#cc2222' + const dkRed = '#881111' + const ltRed = '#ee4444' + const out = '#0a0a0a' + const stripeB = '#111111' + const stripeW = '#eeeeee' + const cardGreen = '#22cc44' + + function px(x: number, y: number, color: string, ox: number, oy: number) { + if (x < 0 || x >= INTERNAL || y < 0 || y >= INTERNAL) return + ctx.fillStyle = color + ctx.fillRect(ox + x * SCALE, oy + y * SCALE, SCALE, SCALE) + } + + function box(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) { + for (let i = x - 1; i <= x + w; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) } + for (let i = y; i < y + h; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) } + for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, fc, ox, oy) + } + + function fill(x: number, y: number, w: number, h: number, c: string, ox: number, oy: number) { + for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, c, ox, oy) + } + + function drawJudge(fx: number, fy: number, pose: string, frame: number, total: number) { + const ox = fx * FRAME_SIZE + const oy = fy * FRAME_SIZE + const t = frame / Math.max(1, total - 1) + const bounce = Math.round(Math.sin(t * Math.PI * 2)) + + const cx = 24 + const baseY = 40 + const bodyW = 14 + const bodyH = 10 + const headW = 12 + const headH = 7 + + const idle = pose === 'idle' + const callL = pose === 'call_left' + const callR = pose === 'call_right' + const shocked = pose === 'shocked' + + const yOff = idle ? bounce : shocked ? -Math.round(Math.abs(Math.sin(t * Math.PI)) * 3) : 0 + const bodyTop = baseY - 16 + yOff + const bx = cx - Math.floor(bodyW / 2) + const headTop = bodyTop - headH - 1 + const hx = cx - Math.floor(headW / 2) + + // === TAIL SEGMENTS === + for (let s = 0; s < 3; s++) { + const sw = bodyW - 2 - s * 2 + const sx = cx - Math.floor(sw / 2) + const sy = bodyTop + bodyH + s * 2 + fill(sx, sy, sw, 2, s % 2 === 0 ? red : dkRed, ox, oy) + px(sx - 1, sy, out, ox, oy); px(sx + sw, sy, out, ox, oy) + px(sx - 1, sy + 1, out, ox, oy); px(sx + sw, sy + 1, out, ox, oy) + } + // Tail fan + const fanY = bodyTop + bodyH + 6 + for (let f = -3; f <= 3; f++) px(cx + f, fanY, Math.abs(f) > 2 ? dkRed : red, ox, oy) + px(cx - 3, fanY + 1, dkRed, ox, oy); px(cx + 3, fanY + 1, dkRed, ox, oy) + + // === BODY (referee striped shirt) === + for (let i = bx - 1; i <= bx + bodyW; i++) { px(i, bodyTop - 1, out, ox, oy); px(i, bodyTop + bodyH, out, ox, oy) } + for (let i = bodyTop; i < bodyTop + bodyH; i++) { px(bx - 1, i, out, ox, oy); px(bx + bodyW, i, out, ox, oy) } + for (let iy = bodyTop; iy < bodyTop + bodyH; iy++) { + for (let ix = bx; ix < bx + bodyW; ix++) { + px(ix, iy, Math.floor((ix - bx) / 2) % 2 === 0 ? stripeB : stripeW, ox, oy) + } + } + // V-neck showing red body + px(cx - 1, bodyTop, red, ox, oy); px(cx, bodyTop, red, ox, oy); px(cx + 1, bodyTop, red, ox, oy) + px(cx, bodyTop + 1, red, ox, oy) + // Whistle + px(cx + 2, bodyTop + 2, '#aaaaaa', ox, oy) + px(cx + 3, bodyTop + 3, '#888888', ox, oy) + + // === HEAD === + box(hx, headTop, headW, headH, red, ox, oy) + for (let iy = headTop + 1; iy < headTop + headH - 1; iy++) { + px(hx + headW - 1, iy, dkRed, ox, oy) + px(hx + 1, iy, ltRed, ox, oy) + } + // Mouth + if (shocked) { + fill(cx - 1, headTop + headH - 2, 3, 2, '#000000', ox, oy) + } else { + px(cx - 1, headTop + headH - 2, out, ox, oy); px(cx, headTop + headH - 2, out, ox, oy) + } + + // === EYE STALKS === + const eyeExt = shocked ? 3 : 1 + // Left stalk + eye + px(hx + 2, headTop - 1, dkRed, ox, oy); px(hx + 1, headTop - 2, dkRed, ox, oy) + for (let e = 0; e < eyeExt; e++) px(hx, headTop - 3 - e, dkRed, ox, oy) + fill(hx - 2, headTop - 3 - eyeExt, 3, 2, '#ffffff', ox, oy) + px(hx - 1, headTop - 2 - eyeExt, '#000000', ox, oy) + // Right stalk + eye + px(hx + headW - 3, headTop - 1, dkRed, ox, oy); px(hx + headW - 2, headTop - 2, dkRed, ox, oy) + for (let e = 0; e < eyeExt; e++) px(hx + headW - 1, headTop - 3 - e, dkRed, ox, oy) + fill(hx + headW - 1, headTop - 3 - eyeExt, 3, 2, '#ffffff', ox, oy) + px(hx + headW, headTop - 2 - eyeExt, '#000000', ox, oy) + + // === ANTENNAE === + const antBase = headTop - 3 - eyeExt + for (let a = 1; a <= 5; a++) { + const wobble = idle ? Math.round(Math.sin(t * Math.PI * 2 + a * 0.8)) : 0 + px(hx - 1, antBase - a + wobble, red, ox, oy) + px(hx + headW, antBase - a - wobble, red, ox, oy) + } + px(hx - 2, antBase - 5, ltRed, ox, oy); px(hx + headW + 1, antBase - 5, ltRed, ox, oy) + + // === LEFT CLAW === + const leftRaised = callL || shocked + if (leftRaised) { + const armTopY = bodyTop - 8 - Math.round(Math.abs(Math.sin(t * Math.PI)) * 2) + fill(bx - 3, armTopY, 2, bodyTop + 3 - armTopY, red, ox, oy) + for (let c = 0; c < 5; c++) px(bx - 5 - c, armTopY, red, ox, oy) + for (let c = 0; c < 5; c++) px(bx - 5 - c, armTopY + 2, red, ox, oy) + px(bx - 5, armTopY + 1, dkRed, ox, oy) + if (callL) box(bx - 12, armTopY - 4, 4, 6, cardGreen, ox, oy) + } else { + const cly = bodyTop + 5 + (idle ? bounce : 0) + fill(bx - 3, bodyTop + 2, 2, cly - bodyTop - 2, red, ox, oy) + for (let c = 0; c < 4; c++) px(bx - 5 - c, cly, red, ox, oy) + for (let c = 0; c < 4; c++) px(bx - 5 - c, cly + 2, red, ox, oy) + px(bx - 5, cly + 1, dkRed, ox, oy) + } + + // === RIGHT CLAW (mirror) === + const rightRaised = callR || shocked + if (rightRaised) { + const armTopY = bodyTop - 8 - Math.round(Math.abs(Math.sin(t * Math.PI)) * 2) + fill(bx + bodyW + 1, armTopY, 2, bodyTop + 3 - armTopY, red, ox, oy) + for (let c = 0; c < 5; c++) px(bx + bodyW + 4 + c, armTopY, red, ox, oy) + for (let c = 0; c < 5; c++) px(bx + bodyW + 4 + c, armTopY + 2, red, ox, oy) + px(bx + bodyW + 4, armTopY + 1, dkRed, ox, oy) + if (callR) box(bx + bodyW + 7, armTopY - 4, 4, 6, cardGreen, ox, oy) + } else { + const cry = bodyTop + 5 + (idle ? -bounce : 0) + fill(bx + bodyW + 1, bodyTop + 2, 2, cry - bodyTop - 2, red, ox, oy) + for (let c = 0; c < 4; c++) px(bx + bodyW + 4 + c, cry, red, ox, oy) + for (let c = 0; c < 4; c++) px(bx + bodyW + 4 + c, cry + 2, red, ox, oy) + px(bx + bodyW + 4, cry + 1, dkRed, ox, oy) + } + } + + const entries = Object.entries(JUDGE_ANIMATIONS) as [string, { frames: number; row: number }][] + for (let row = 0; row < entries.length; row++) { + const [pose, cfg] = entries[row] + for (let f = 0; f < cfg.frames; f++) drawJudge(f, row, pose, f, cfg.frames) + for (let f = cfg.frames; f < JUDGE_MAX_FRAMES; f++) drawJudge(f, row, pose, cfg.frames - 1, cfg.frames) + } + + return canvas.toDataURL() +} diff --git a/frontend/src/game/sprites/palette.ts b/frontend/src/game/sprites/palette.ts new file mode 100644 index 0000000..85b4b99 --- /dev/null +++ b/frontend/src/game/sprites/palette.ts @@ -0,0 +1,32 @@ +import type { Pal } from './constants' + +export function parseHSL(c: string): [number, number, number] { + const m = c.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/) + return m ? [+m[1], +m[2], +m[3]] : [200, 70, 50] +} + +export function makePal(primary: string, secondary: string, tier: number): Pal { + const [h, s, l] = parseHSL(primary) + const [h2, s2, l2] = parseHSL(secondary) + return { + body: primary, + dark: `hsl(${h}, ${s}%, ${Math.max(0, l - 20)}%)`, + light: `hsl(${h}, ${Math.min(100, s + 5)}%, ${Math.min(95, l + 15)}%)`, + acc: secondary, + accDark: `hsl(${h2}, ${s2}%, ${Math.max(0, l2 - 20)}%)`, + accLight: `hsl(${h2}, ${Math.min(100, s2)}%, ${Math.min(95, l2 + 15)}%)`, + out: '#0a0a0a', + skin: tier <= 1 ? primary : `hsl(${h}, ${Math.max(20, s - 30)}%, ${Math.min(85, l + 25)}%)`, + skinDark: tier <= 1 ? `hsl(${h}, ${s}%, ${Math.max(0, l - 10)}%)` : `hsl(${h}, ${Math.max(15, s - 35)}%, ${Math.min(75, l + 15)}%)`, + } +} + +export function getBotColors(seed: string): { primary: string; secondary: string } { + let h = 0 + for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 + const hue = Math.abs(h % 360) + return { + primary: `hsl(${hue}, 70%, 50%)`, + secondary: `hsl(${(hue + 140) % 360}, 80%, 60%)`, + } +} diff --git a/frontend/src/pages/ArenaPage.vue b/frontend/src/pages/ArenaPage.vue index 0f86429..c93ac81 100644 --- a/frontend/src/pages/ArenaPage.vue +++ b/frontend/src/pages/ArenaPage.vue @@ -1,6 +1,8 @@