From 2f1419b1f20abb5a8dd78adabffe463a0429522d Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 12 Jul 2026 11:50:18 +0200 Subject: [PATCH] Feedback round + Phase 13: the duel, free-aim, honest stakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oliver's six complaints, addressed: 1. Opponent never came — a racket zombie now spawns on EVERY game you win (if none alive); endHorde no longer double-spawns 2. No wave-cleared feeling — sweepCourt: at game end the surviving wave collapses, every game gets a real beat (gameBeatT 1.6 / changeover 4.5), and 'GAME N' announces the next wave when play re-arms 3. Serve-posture lockup during machine gun — root cause: any 'over' beat expiring into setupPoint() -> pretoss during auto-fire (no movement outside rally, no serving during horde/GUN). The over-expiry now returns auto-fire modes to 'rally', never the serve loop 4. Forced bomb spending — specials commit ONLY on deliberate serves: flick (or arrow-aimed keyboard serve) fires the tin, plain tap keeps it; serve prompt teaches it ('FLICK FIRES THE BOMB - TAP KEEPS IT') 5. Auto-aim — free-aim: the flick vector maps to a landing point (length = depth, up to 22m), zombies within zSnap 1.4 magnet the shot, otherwise the ball lands exactly where flicked; taps keep the old auto-target assist; flicks now also work at the toss apex 6. Zombies walking through you — option (a): touch is honest. eatenR 0.9 (brute +0.2) matches the visual sprite overlap Phase 13 — SUDDEN DEATH interlude: court clear + un-dueled racket zombie + about to serve -> 'Quiet, please. Play will resume.' / 'Sudden death.' One point under REAL tennis rules (line calls live, the boss plays every ball, spawns paused). Win (line call, double bounce, or regicide by any weapon) = the next tin delivered on the spot; lose (out, or its return passes you) = a game on their scoreboard + an instant 5-shambler surge. One duel per opponent. New lines: play_resume.m4a, sudden_death.m4a. Verified in browser: horde 'over' beat -> rally (lockup dead); game win -> sweep + boss + beat -> SUDDEN DEATH chains naturally; duel lose path (GAME. THEM., surge 5, scoreboard), no re-duel, fresh-boss duel, bomb regicide -> tin on the spot; tap serve keeps stock, real touch-flick consumes + lands at the flicked point, magnet snaps when near; eaten at 0.85m; tennis 11/12; no console errors. PLAN.md/HANDOFF updated: plan v2 complete. Co-Authored-By: Claude Fable 5 --- HANDOFF.md | 15 +- PLAN.md | 285 +++++----------------------------- assets/audio/play_resume.m4a | Bin 0 -> 16096 bytes assets/audio/sudden_death.m4a | Bin 0 -> 8905 bytes config.js | 3 + gameplay.js | 127 ++++++++++++--- input.js | 6 +- render.js | 4 +- state.js | 2 + zombies.js | 26 +++- 10 files changed, 183 insertions(+), 285 deletions(-) create mode 100644 assets/audio/play_resume.m4a create mode 100644 assets/audio/sudden_death.m4a diff --git a/HANDOFF.md b/HANDOFF.md index 4dac51b..9e0b785 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -38,13 +38,14 @@ Two runtime-only import cycles (documented in headers): input↔gameplay, zombie Ball boys mop gore they pass. HUD shows THEM instead of CPU. - Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback. -## NEXT UP — PLAN.md (v2) -Phases 1–9 are SHIPPED (1–6: shot loop, fanfare, gore piles, bombs, hordes, racket -zombie; 7: match structure — spawn director/changeovers/sets/permadeath; 8: bomb juice; -9: weapons on the new-balls rule). PLAN.md v2 continues with phase 10 (SPRITE SESSION — -STOP for Oliver's approval), 11 (enemy roster), 12 (weapon ladder), 13 (tennis -interlude). Design decisions at the top of PLAN.md are settled with Oliver — don't -re-litigate. Phase by phase, push after each. +## STATUS — plan v2 COMPLETE (phases 1–13 + feedback round, 2026-07-12) +Everything in PLAN.md is shipped: match structure (games/sets/changeovers, permadeath, +spawn director), bomb juice, new-balls weapon ladder (BOMB→TRIPLE→FIRE→GUN), enemy +roster (runner/brute/shield, sprites in assets), free-aim flicks (flick = aim + commits +the special, tap = keep stock + auto-target), game-end sweep + 'GAME N' theater, an +always-there racket-zombie opponent, and the SUDDEN DEATH interlude (real line calls, +one duel per opponent, win = tin on the spot, lose = surge). PLAN.md records the +decisions and the tuning surface. Next: Oliver playtests and tunes via the T panel. ## PREVIOUS TASK — all four items from Oliver's feedback round are DONE and VERIFIED (serve cadence · flick-aim anywhere incl. over the net · zombie speed ramp · eaten): diff --git a/PLAN.md b/PLAN.md index bf372e3..9620b3b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,255 +1,40 @@ -# PLAN v2 — Match structure, enemies, weapons, interlude (phases 7–13) +# PLAN — all phases shipped (v2 complete, 2026-07-12) -Phases 1–6 (shot loop, fanfare, gore piles, bombs, hordes, racket zombie) are SHIPPED — -see git log. This plan is the next arc, agreed with Oliver 2026-07-12. +Phases 1–13 are implemented, verified, and deployed. See git log for the per-phase +record and HANDOFF.md for the current system map. This file now only records what +was DECIDED, so nobody re-litigates it. -Read HANDOFF.md FIRST (module map, verify recipes, gotchas — especially the stale-module -cache one and "always Read current file state before editing"). Every phase is -independently shippable: implement → verify → commit → push (push = deploy, Oliver tests -on his phone). Do NOT start a phase before the previous one is pushed. +## The shape of the game (as shipped) +- **The scoreboard is the progression.** Waves = games (spawn director scales with + G.runGames), 6 games = a set = a horde, odd games end in changeovers, deliveries + follow the new-balls rule. Permadeath: eaten = run over, best run persisted. +- **Game-end theater**: at every game end the surviving wave collapses (sweepCourt), + a real beat plays (gameBeatT / changeoverT), and 'GAME N' announces the next wave. +- **An opponent, always**: every game you win spawns a racket zombie if none is alive. + With the court clear and an un-dueled opponent waiting, the umpire restores order: + SUDDEN DEATH — one point under real tennis line calls. Win = the next tin on the + spot; lose = a game on their scoreboard + a 5-shambler surge. One duel per opponent. +- **Weapon ladder** (deliveries cycle): BOMB → TRIPLE → FIRE → GUN. One tin at a time. + Specials fire ONLY on deliberate serves: flick = aim + commit, tap = keep the stock. +- **Free-aim**: a flick maps to a landing point (length = depth, zSnap magnetism); + taps keep the auto-target assist. +- **Roster**: runner (set 1 game 3+, shriek, solo), brute (set 2, hp 2, pushback-proof), + shield zombie (set 3, chair blocks front, stagger = kill window). Touch is honest: + eatenR 0.9 sprite-overlap = eaten. -Ground rules (unchanged from v1): -- Zero dependencies, no build step, vanilla ES modules. Match the existing code style - (compact, commented for intent, TUNE knobs for anything Oliver might want to tune). -- The tennis benchmarks in HANDOFF ("Tennis feel — CONFIRMED GOOD") MUST keep holding - after every phase. Run them via window.T (Browser pane, RAF paused, T.step manual). -- New TUNE knobs go in config.js TUNE so they appear in the live T panel. -- Mobile portrait one-handed is the primary target: tap, flick, drag. No new buttons. -- Umpire lines: record with `say -v Daniel` → afconvert → assets/audio/*.m4a (pipeline - and listen-check exactly as v1 Phase 2 did; mapping lives in audio.js). +## Feedback-round decisions (Oliver, 2026-07-12) +- Zombies pass-through: resolved as (a) — touch HURTS (radius honest), not avoidance. +- Interlude frequency: currently every game won (fresh opponent each game). If it + overstays its welcome, gate it to every 2nd opponent — one-line change in the + trigger (gameplay.js, `dueled`). +- Multi-ball stays "one proper serve per fan" (triple serve), never rapid-fire. -## Design decisions (made with Oliver — do not re-litigate) -1. **The scoreboard IS the progression.** Waves = tennis games, escalation = sets, - breathers = changeovers, weapon deliveries = the new-balls rule. All structure comes - from tennis rules — that's the joke and the design. -2. **Permadeath runs.** Eaten = the run is over. Score = games/sets survived, best run - persisted. Runs are 3–5 minutes; restart is one tap (reload, as today). -3. **Enemy roster:** runner, brute, one "weird one" (shield zombie, design checkpoint). - Sprites are approved by Oliver BEFORE any behavior code (Phase 10 is a STOP). -4. **Tennis interlude = one sudden-death point** vs the racket zombie, triggered when - the umpire "restores order". Winner-takes-all, ~15s, then chaos resumes. If playtests - love it, promoting to a full game is just the win condition — do NOT build that now. +## Tuning surface (T panel) +All pacing knobs are in config.js TUNE — spawn director (zSpawnEvery/Min/Step, zMax, +zMaxCap), beats (changeoverT, gameBeatT), deliveries (ballChangeFirst/Every), roster +(runnerSpeed, bruteHp, shieldDown), aim (zSnap), stakes (eatenR). ---- - -## Phase 7 — Match structure: games drive difficulty, sets, changeovers, permadeath - -The root fix. Today spawning is a flat drip (`zSpawnEvery` 2.6s, `zMax` 6, forever) — -that's why waves feel random, easy, and progression-free. Replace the drip with a spawn -director keyed to games won, and give the run a real end. - -- state.js G: add `runGames: 0` (total games finished this run, never resets), - `sets: [0, 0]`, `changeover: false`. Keep `splatT` (the speed ramp stays as-is). -- **Spawn director** (zombies.js, replaces the flat timer in updateZombies): - - `spawnEvery(g) = max(TUNE.zSpawnMin, TUNE.zSpawnEvery - g * TUNE.zSpawnStep)` - with g = G.runGames. New knobs: `zSpawnMin: 1.0`, `zSpawnStep: 0.22`. - - `zMax` becomes dynamic: `min(TUNE.zMaxCap, 3 + G.runGames)`. New knob `zMaxCap: 14`. - (Keep the TUNE.zMax knob as the game-1 floor: `max(TUNE.zMax, ...)` — no, simpler: - replace zMax semantics: `zMax` = base 3, cap knob separate. Pick ONE and comment it.) - - Game 1 of a fresh run must feel lonely: 3 shamblers, ~3s apart. Check the transition's - scripted spawns still make that true. - - **Pause spawning during `G.changeover`** — the breather must actually breathe. - Zombies already on court keep walking (still dangerous, eaten still applies). -- **Changeover** (gameplay.js splatPoint, gameOver branch): after ODD total games - (real rule: 1st, 3rd, 5th… game of a set), set `G.changeover = true`, `G.phase='over'`, - `G.overT = TUNE.changeoverT` (new knob, 4.5). Boys mop (sendBoy a couple of times), - cheer, flash 'CHANGEOVER'. On expiry (setupPoint or a flag check): `changeover=false`, - voice('time') — record: `say -v Daniel "Time."` → time.m4a. Even games keep the short - beat they have today. -- **Sets**: in splatPoint's gameOver branch, `G.runGames++`; `score.games[i] >= 6` → - set won: `G.sets[i]++`, `score.games = [0,0]`, voice('game_set_massacre'), cheer(3, .7), - and **startHorde()** — the horde MOVES from "every game after the 2nd" to SET END. - Delete the `G.splatGames` trigger (state field can go too). Horde stays the set-boss - spike; endHorde still spawns the racket zombie (unchanged). -- **Zombies win a game** (baseline crossings): flash 'GAME. THEM.', and set - `G.shameGame = true` — Phase 9 uses it to skip the next new-balls delivery. No other - penalty; losing games mostly means the player is already drowning. -- **Permadeath screen** (render.js eaten overlay, main.js): the overlay becomes the run - summary — 'GAME, SET, MASSACRE' / 'survived N games, M sets' / best run. Persist best - via `localStorage.splatterBest = JSON.stringify({games, sets})` (read lazily, guard - with try/catch — private mode throws). Tap to restart = reload, as today. -- HUD: show sets alongside games (tennis scoreboard style: SETS · GAMES · PTS). - -Verify (Browser pane, force splatter via umpire triple-tap… which now forces a horde — -use T to set G.mode/score directly, or transition normally): -- Fresh run: ≤ 3 zombies on court in game 1, spawn gap ≈ 3s. -- T-set `G.runGames = 8`: spawn gap ≈ 1s, court fills toward the cap. -- Win game 1 (odd) → changeover: 4.5s beat, NO spawns during it, 'Time.' plays at the end. -- Win 6 games → set on HUD, horde fires, endHorde spawns the racket zombie. -- Get eaten → summary screen shows games/sets + best; reload; best survived the reload. -- Regression: tennis mode scoring/changeover untouched (changeover logic must be - splatter-only); eaten flow benchmark updated to expect the summary screen. - -## Phase 8 — Bomb juice pass (make the explosion FELT) - -Pure render/audio. The bomb works; it just doesn't LAND. All in explode() + render.js. - -- **Screen shake**: `G.shake = {t: 0.35, mag: 6}` set in explode(). render.js: at frame - start, if shake, translate the camera by `(rand±mag, rand±mag) * (t/0.35)` px and - decay t. Restore transform after. (One translate around the whole draw — cheap.) -- **Flash frame**: `G.boomFlash = 0.08` — render fills a white overlay, alpha `t/0.08`. -- **Scorch crater**: explode() pushes a gore entry with `scorch: true` (r 1.6, a 1). - render draws scorch entries near-black/brown instead of red; boys mop them 10× slower - (`d.a -= dt * 0.16`). The court should scar for most of a run. -- **Hitstop**: 0.1 → 0.18 in explode(). -- **Umpire loses composure**: record `say -v Daniel "Order. Order, please."` → - order.m4a; voice('order', 0.6) from explode(), rate-limited like horde scoring so - bomb chains don't spam it. -- boom() louder/longer if it currently underwhelms on phone speakers — Oliver judges. - -Verify: force a bomb (T: `G.bombs=1`, serve into a cluster): shake visible, flash frame, -scorch stays after gore fades, 'Order' plays. Screenshot for Oliver. No transform leaks -(HUD must not shake — draw HUD after restore). - -## Phase 9 — Weapons ride the new-balls rule (kill-meter retired) - -Story fix: weapons aren't loot, they're DELIVERED — because tennis rules say new balls -come after 7 games. The ball boys are the armorers. (In real tennis: first change after -7 games, then every 9. Too sparse for 3–5 min runs — knobs, commented with the real rule.) - -- Delete the kill-meter path in zombies.js splatter() (`G.killMeter`, `TUNE.bombEvery` - gone from state/config). -- gameplay.js splatPoint gameOver branch: if `G.runGames === TUNE.ballChangeFirst` (3) or - `(G.runGames - ballChangeFirst) % TUNE.ballChangeEvery === 0` (4) → schedule a delivery - at the next changeover beat: sendBoy(), voice('new_balls', 0.5), flash 'NEW BALLS', - `G.bombs = TUNE.bombMax` (a fresh tin of three). -- **Shame rule**: if `G.shameGame` (zombies took a game since the last delivery) → skip - it: flash 'NO NEW BALLS', clear the flag. The umpire punishes sloppy play. -- HUD bomb pips already exist — unchanged. -- Phase 12 will extend the delivery ladder to new weapon types; here it's bombs only. - -Verify: T-run to game 3 → tin delivered at changeover, bombs = 3 on HUD; let a zombie -cross before game 7 → 'NO NEW BALLS' at that delivery, next one works. killMeter is -gone from state (T.G.killMeter === undefined). - -## Phase 10 — SPRITE SESSION (STOP: Oliver approves every sheet before Phase 11) - -The only asset phase. Pipeline gotchas in HANDOFF (generate-image skill, OPENROUTER_API_KEY -in .env, magenta bg, scratchpad slice.py — recreate venv, IDLE_TARGET_H=112, watch for -the generator ignoring magenta or drawing near-identical frames). - -Sheets to produce (match the existing zombie sheets' style/palette): -1. **Runner** — gaunt, sprinting, arms trailing. Poses: 2-frame sprint (STRONGLY - contrasted stride), idle/lurch. Visibly leaner than shamblers. -2. **Brute** — huge, slow. Oliver-pleasing absurdity: it dragged a net post along and - uses it as a club. Poses: 2-frame trudge, idle. Roughly 1.5× shambler height. -3. **Shield zombie** — carries a line judge's chair as a shield, held forward. Poses: - 2-frame walk with chair up, one 'staggered' pose with the chair dropped/lowered - (that pose IS the vulnerability window, it must read at a glance). -4. **Weapon tin** — a "new balls" ball can, matte black, small skull where the brand - goes. One still; the boy carries it on delivery runs (small, ~24px). - -Deliverable: sliced sheets committed + one composite preview image sent to Oliver. -STOP for approval. Rejected sheets get re-prompted, not hand-fixed. - -## Phase 11 — Enemy roster wired in (one new type per set) - -Behaviors only after sprites are approved. Introduce one type per set so each gets its -moment. Spawn mix lives in one table in zombies.js: -`const MIX = set => ...` → weights for a/b, 'f' (runner), 'g' (brute), 's' (shield); -runner from set 1 game 3, brute from set 2, shield from set 3. - -- **Runner ('f')**: `v ×= TUNE.runnerSpeed` (3.2). Spawns SOLO with a warning shriek — - new synth sfx `shriek()` in audio.js (style of moan(), higher/harsher). Cap: at most - 1 runner alive (`zombies.some(z => z.variant==='f')` gate). It punishes tunnel vision; - the shriek is the fairness contract. -- **Brute ('g')**: `hp: TUNE.bruteHp` (2) — reuse the racket-zombie hp pattern in the - ball-contact branch (splat-quality hit decrements, killing blow → splatter()). - `v ×= 0.55`, contact radius 0.75 (vs 0.55), immune to pushback (staggers don't move - it), pellets take 3 hits (`hp` respected in the pellet loop too — currently pellets - one-shot everything). Kill = double particles, size-2 pile. -- **Shield ('s')**: blocks balls arriving from the front (`ball.vz < 0` at contact) — - clank sound (pock variant), ball spent (`lastHit='X'`), zombie unharmed. A STAGGER - (any front hit) drops the chair for `TUNE.shieldDown` (1.5s) — during that window any - splat-quality ball kills it. Also always dies to: bombs, melee, pellets, and balls - hitting it from BEHIND (`ball.vz > 0` — the flick-down-behind-it shot, which the - free-aim system already allows). Render the dropped-chair pose during the window. -- render.js drawChar: wire the three sheets; brute scale ×1.5 off SPRITE_H. -- Eaten radius: brute 0.9 (it's bigger). Runner eaten radius unchanged (speed is enough). - -Verify (per type, T.spawnZombie grows a variant arg — it already has one): -- Runner: shriek on spawn, crosses the court ~3× faster, only ever one alive. -- Brute: survives one splat-quality hit, dies on the second, ignores pushback, eats - 3 pellets. -- Shield: front ball clanks off; stagger → chair-down pose → kill lands; a ball aimed - behind it kills outright; bomb kills it. -- Mix: T-set G.sets/runGames per set tier, watch 20 spawns, composition matches MIX. -- Full regression ladder after wiring: tennis benchmarks + phases 7–9 verifies. - -## Phase 12 — Weapon ladder on the delivery rail - -Extends Phase 9's deliveries beyond bombs. Every weapon stays one-thumb: it's a serve -modifier, auto-consumed, zero new inputs (exactly like ball.bomb today). - -Delivery ladder by delivery index (1st, 2nd, 3rd… of the run): -1. Bomb tin (as shipped in Phase 9) -2. **Triple serve** (`G.triples = 3` stock): the serve spawns 2 extra balls fanned ±6°. - Extra balls: check git history first — a multi-ball array existed pre-revert - (killZombie's comment still references "extra (multi-serve) balls"); reuse that - shape if salvageable, else a lightweight `extras[]` in zombies.js (ballistic clones - carrying pow, killing via killZombie, no scoring double-dip beyond it). -3. **Flame ball** (`G.flames = 3`): `ball.flame` flag — kills on ANY contact regardless - of pow (the real power: quality-free kills), full pierce (no zPierce slowdown), fire - trail (orange/yellow parts each frame, cheap), renders orange-hot. -4. **Machine gun, 8s** (`G.mgT = TUNE.mgPickupLen`): horde auto-fire machinery reused - outside hordes — firePellet + drag-to-steer for 8s, then back to serving. (Flag - interactions: serve prompt suppressed while mgT > 0, same gate as horde.) -5th+: cycle 1→4. -- HUD: one small icon row (reuse bomb pip drawing, one glyph per type). -- Flash text on delivery names the weapon ('NEW BALLS: FIRE'). -- Precedence: one special stock at a time — a delivery replaces any unspent stock - (simpler than stacking; comment it). - -Verify: T-walk the ladder (set runGames): each delivery arms the right stock, each -weapon kills as specced, mg mode enters/exits cleanly, no leakage into tennis mode. -Triple/flame respect brute hp and shield-block rules from Phase 11. - -## Phase 13 — The interlude: order is restored (one sudden-death point) - -The absurdity peak: tennis rules never stopped applying. Trigger — a racket zombie is -alive (post-horde) AND the court is otherwise clear AND no horde/changeover: the umpire -restores order and ONE real point is played. - -- Record: `say -v Daniel "Quiet, please. Play will resume."` → play_resume.m4a, and - `say -v Daniel "Sudden death."` → sudden_death.m4a. -- Trigger check in gameplay.update (splatter, ball dead, conditions above, once per - racket zombie — flag on the zombie): `G.interlude = true`, spawning paused, voices, - flash 'SUDDEN DEATH'. -- During the interlude the point is played under TENNIS rules: onBounce's line-call - branch runs (out/net = you lose the point — the umpire is watching again). You serve - (pretoss as normal); the racket zombie returns via its existing racketAI. NOTE: its - AI only chases balls with pow < zSplatQ — during the interlude it must chase ANY ball - (it's playing tennis, not dodging); killing it with a splat ball mid-interlude is - allowed and counts as winning the point (crowd goes wild). -- Resolution: your point → weapon delivery on the spot (next rung of the Phase 12 - ladder) + cheer(3, .7). Its point (return passes you, or your ball out/net) → - 'GAME. THEM.' on the scoreboard AND an instant surge: 5 shamblers spawn at once. - Either way `G.interlude = false`, normal spawning resumes. -- Zombie spectators: any shamblers that spawn-raced the trigger freeze in place for the - duration (v = 0 while G.interlude — one gate in the walk loop). They watch. -- Keep it ONE point. Full-game promotion is a possible follow-up, not this phase. - -Verify: survive a horde (or T-spawn a racket zombie + clear court), let the ball die → -interlude triggers once: voices, tennis line rules live (hit one out → THEM + surge of -5), replay → win the point → delivery fires. Racket zombie chases soft AND hard balls -during the interlude, only soft ones outside it. Eaten still works if the surge gets you. - ---- - -## New TUNE knobs (summary) -zSpawnMin 1.0 · zSpawnStep 0.22 · zMaxCap 14 · changeoverT 4.5 · -ballChangeFirst 3 · ballChangeEvery 4 · runnerSpeed 3.2 · bruteHp 2 · shieldDown 1.5 · -mgPickupLen 8 -Retired: bombEvery (kill meter gone). - -## Umpire lines to record (pipeline in v1 Phase 2 / audio.js) -time.m4a "Time." · order.m4a "Order. Order, please." · -play_resume.m4a "Quiet, please. Play will resume." · sudden_death.m4a "Sudden death." - -## Benchmarks that must stay green after EVERY phase (scripts in HANDOFF) -- Tennis: CPU returns ~11/12 serves; human-like tap sim = all hits, no whiffs; - feet-bounce tap sweep ≈ 19/30. -- Splatter: serve→serve ≤ 20 frames with immediate flicks; eaten flow now ends in the - RUN SUMMARY screen (games/sets/best) → tap restarts. -- Game 1 of a fresh run: ≤ 3 zombies, ~3s spawn gaps (the "starts slow" contract). -- No console errors after each phase's full verify run. +## If a next arc happens, candidates in rough order of value +1. Balance pass from phone playtests (Oliver tunes, nothing to build). +2. Boss variety: a second duel opponent type (sprite session first, as before). +3. Meta between runs: unlock/records screen beyond best-run. diff --git a/assets/audio/play_resume.m4a b/assets/audio/play_resume.m4a new file mode 100644 index 0000000000000000000000000000000000000000..d054547dd6275b80ed7e1f0d3afdbc61807bc564 GIT binary patch literal 16096 zcmeIZWmH_v)-Jjl8fYZYI3xu3;2t2PAq0m&f=h6B*FfX$!AWp;hoHe-LVyG(xLa^L z&AY#SzVn?s?yvjf{@HWzjAz!IC9~!#2355d005AgIJw)&^N3Oc2zg{{J031`M_X$E z0E=1M+PWajR@N?NMvwjtcRaO60Dvkr02~1T=%1fQ*S`c&{vYyxm0|zG`Tr;&0RYlZ zCkK5?gh$)S@}Du${(H>F(H;dc?*AeG82_(v{$2Cm)nNZt{$V6LYa?@ggi2{`^#3xi zf7PcpGqQ5{cT-1OXPbZP|7Ik5YjYbDL<#C>{jZ+>%R_7QFFTo$gYl#PQ66K&a(1wy z{Fe^eada}ULg+4zPLBU%_g{9v1n~qOqkEM9o);1TeKb7g9U8y+ zOaLPQNDz929sqm-P!JX*{18+Gfe?uWa2ySs{uxG^lMhiOaddL}rw4@oIEpZ$4+H^> zh-eHS6@)xCd-P%c7o#E=ChK=9} z1R@YR&420H2yR8727%8AJjQf@@XI0i2Lg|6yAa4npbcRsNAP1GBM3AhP=>(&*}fA| zjzeG!QTH+SO$4S9s6`+Lfk}iN9l`Ag{6gsL2wp|t(LaDd3Ib^eJjQd4z%c@M2q5DB z$M)z?M}QXrM6L{N5&S5K+&n%Y5=1OiF#y1XsLxdi074rGkE`LorQ6)f(Fsv(VMPdn$*hg_|5<^Cw$^NqwT;=#m7Q&jA2UW^qi=2e z*!0ozL0(Y|p#~V~|1;WS763|)|B`$i@&9f3|EM4qPO6E6 z@jr1r#_%8cPX_*zf&XOSKN2L6+Q|774l8Td~I{*!_Ke=@+fWsNu<0OK$;U@v3i z=VVw!QR9!vXE8qn2^q(Qbzma1S1EsIPH(i^-yLc>A3u}0!0l>%)%oW!S}=s-@uUXS z_OS;b@3r47lgEw&UWQX09K%T0cG$tkQ0$CtfC9}35PI^p6mg9F<9u-sM>N3lA-TB^ zX{!Wq9{T2&9DU9odjZIGCpF>b3g+~xvTv?V46q0Lpanm6G=u9GH8?X+D@6l>OW>cX zufxM>dig!>8|dA-^YcfvPuJ{sstv`T$DW5ALa!h2>+?}M5U01!&76D5y^U=js{Mo( z)j+C3|8VnGa+`s~2LH$+JUyE~C?0Cod$mR)Di1gJCAw1BxJlcW5+}%T5P8Nz>YHlC z(4d6SCPumTvfbU0YrDhtzS66{nRpqG9ZGBGCi-Ts63o`m@A;pYncc1E@4edR7?Wo? zi71V8D_R#td3J6xxTIiN!eW=QAaTK&mQ#U!Nh_OhilaV`-8Eu=g7f^KJySapNA~nO zX1xS%Xh<~cvQ}O@>$;)``RQW@!p90DNquK5Nqh3eg>xwoU-1=h!_*mx-h8r}?mYP) zF#flU(l|+wt3Xc&(wW>XV~wtb5Em=zPvXMA{v*>j3AMJmJ9plo)#k)sSoW2LnXgU+ zS|`c14Oa{9pF8HbH}neh8SWS!gM$3wAlOWdOA;a2|BWNZvZ@3m099WvIffdFJLf?f z$s1@oZMP+@pYAGwy;O;j^G$p!hlpEEsx3Jl=sG@#5>{6^6ZsT}l`+Lca&LH=W|)nH zMbPybsHnMeGoU5e)gum$-s+i(ha%}sGd@)_QVdEc{Y&ncczTN^M&@Mvb}j@dZa&Vl zf;V4$!Aaefa5?>S9xlNZ3eL0uYUpLP=p*N|Z=T`qSY>8cR z_9ExbyId@H`Q0x19B-4nF%$%n_WfS<^WnwRZ3)R!RB450~0qMld$uOoBUHLg@+?K&k6!`TDhZH7+?p25$7xNh!b>zp&g2H_z;Nas!oz` z!*@YVE>(@QvR+gHje_gHK?UZu+!x-f$k_fmp4)RIqfs+9m8o< z;1sEDW7+Bfp7<^0SFLd35}eH?z9kX^;{IY*uH}$9Cdy9kE&bX0xxXB>TM0mp%DlM{oZJQ=B-wY;omJ>;jGsea)&*(1j7(wVky6LUQE43cTmh6U4x z9k&woc&9PTAYtMxJt7R!013kom>&@aSj-m+kJ|d4(uhy~(Rj7nTzAL>`6VtpTFo}f zsLm@N3*OUmo>DAJr=OpS3n<&U)^ zr-8~?)19d=avbD-YO_c>9_Rtu1#V+~LYWRg;HlA!AD*MmZHUV^l=iLRUi$Pl>ugsk z#sOkvXZ$kRvL5k-t)|*qj~V0Pz58(b1xyubJCHbWsxlvSC<;g)l1xbi2>@r|0a509 zRuriAybT4t(|5NA^{sH=>MD?>*jRQgHFUOwU82_9ziIEqi#p1l?m(EeV;ZddkL8!I zZ$6);cNHc*n;TQsX-nhVR9E7L(P&70H%M#^nV+06gBLGLBJ(Pe@-6%Y-k^|F$FT+d zc=24;+!U`$7kJyi8{nw>4v8G=TTQ$$8(11l{LCm8((7u}zN`QRelQGz*ecA}f+%!}b?h_$DE(3n3s?QshQ)j!K;PbJv(pRfjbpMmX}(%%)6^p4`0y(# zo;Frp9xHgk)K?*X9yJ)t!~n4DFBuRd(tY!A^(OY!^N=!CMtx_No#^5pxa|6p=4sTV zx#6}}@l8a}2g80d^f8$g_E%RVe7BWTHY{amsf?;n4SHT!|6VhecI5B48G#2fK^O`( z8sbqW@RU5kMFvw=2I%2Iu=cq*Jqn zI5^NhES!<_{=p4E>+13h#&ToJmLO$3kJ~0XpA7EEzy4HSgA|M+YgmX8aK?9-%9%Y) zAOI3jgLC}khN2*ea`-}UNw5wIEd2|l>+o)v9$pO*pl0-toa8WznkvoXQ_$@Ql*U1m zX#?n8F+y$2oETZyFKG+D^WIV%9+g~-x0y#1paOOpykv@2Cf_1HOwc}9$g+M;Ek58~ zSZtxKK!0(f{PArBRyywdS6tWPZ>8lZ^gOxJZhmys^xyy(unc0%c$LtYsKogmC0C0H zC^BFQn=sODZSQ^X**R-(_cHOGVUTVu&?)wv>-*^7#hRf8k_;OWTKQ?w;C@mW;<%w_ zXh1mw)mO!E+YwtDa!gF_-3azhx2U)T&UiCL{ByZOX4cyr?e@Lr1j z{HtcYMrm^0Cu^*;>Qx46Ed7m2VcNnDIi zylv@j09U8@#=1T?U_E`iQ1PB;a(*)MiA-ke_m>|?=SLqbU2jcx_v(BO$-7qVE~yBy zwPm_Xa5H{3vLuMtrU_THyB}}31EBi@4?x?x_=*qN7 z@}UmNyq3uL1Vg8ZFlwn(zpywRc}rXVI$DyC?z=@slOcGHW`ts07g zFOPnzpo;?p;)WTT6Fjq3suk>m0L2W4{vDJ%NIFtD{YR7q#u+QDw{FJCs(!2>IzZ*^ zvF&mhJ{5LQ+!+NVgn?sK-g3|qX;<}5jY5d%rlziBk~W>asyeqE{w2oWg6G;)A#rv{ zUXQkIwby8N#^k(-32dKz3j&DYF`s3z6u-mZ;*_|YVDPKm1kt1)dPs&|<}VZK>fOfu zJ47KE12?p}Uw@7!L~zqiOc=+>+Nm+gz5i|Y;T;-O6&k+6kk)nafQfdnuS{u08Sp1g2|L5Fe@4^Xg!56e{Zs9XClhO3Qi|w7h^p0scn4^NfaCOR<|$&| z8irR267bXD`vcq!Zjb#uHHEY{((^!9kg zNg9#i-Z*XiA(nP`N+yt9p05ZZGu#r;XH_wvqA_svJt;eEu@+RHlv?vg$JGLyf?{;$ z8&=Z+93g`D(?fws5>Ud#7+#8-^{azY^kE&C+>|fnzkTK?Afb|DC9D@Zo?NkbKtpS& zIUd8aewZ=XLXB?@dL_uFNy!uEoHfhwk&ogu_GjFxs3KvYYL$+Ji|x~IHOEW1bl&Tz z%oX`X*D-t~pgj|^}-h^By zE7sk-w@-cD*B8F$_4zwry;A9}VV=2X=Fl>5KnyR;PA)=1f_@Ll%7W_!s`we*bc|@- z9^~y>5WX58aiPj6kHRtye0lg1+Zhmp?1JB_#pO8YzG;Mt5QAe$S=MA^lSx zg-*SMEbh@@hMiUktgJ}LtBR2E$F07GhJs46y$?LXw@ENKwPncws7Sru%3>qOkaO;^ zVL>MsfyV%ZSsXy7N=%w70FD3}>~N@U?B1Go0M{7<*AV8LkX$+AknRRk)p47v#dxEc zH*G%qUzL`UI>#nN+b9TUy!NcDtn`lw>DwwkzTwMnY;Sp3ra* zgdx9skIYDBeqwo_+34m6OUf=pb@JgHPz&WHw=&rn^lx}$7%Pp@R&$-W=hI?U=Zh8J z3{8HLgfxAP^Lk;2u>=0g4xiLYjzPEix*spr=%W<_-ul+jLP|I3B7?2i-JkQ~v;(g> zmvxWdeMpSfR1J+`b!8z5--l(L(25JC!{j28)i|(~-NP&%MuDdn2}m>AQcszwuegJ4 z-<y zx$!f^4lo7=FK*VRu!G;KXVpDablsAZxi?h2av;r~dHZ8v)6*@UrH%dMp@K|5YUazi zh&)W7W^vb2GFm&W*c0Z@sz9>7Emdh(RXk_yEc~sDIi!uW^Fo%K>FKNLa&@j@!ihs7 znPsQQFRCnPl-8?Jc9F6^oj&pN;LLz>=eIPrsSLoYoP3RRyv(--#A$+3n+-O|3ixLi zg%Y#9Y+1Gd%6B@t-`2r#-79QWdY%ci6?(JQZDbpz>YNK~yrpOS1anUOzrxt}lzaAM zM_J~JUx1#{Hcn&Lq@OrAlzbAldyf*^?wRdu$dC(tTb8H~=y@phgeonV+4Jx)J*hO| zA0e@p96eb-v^D>EyDy9h>INQQ(BOApE~&V+5$Ayz<=hu2=x7EIcmNd!SRBd2YQ0F) z;s}SWWSSqh@38Iwt}W)KlG!1?;HzlrEBU?(Jd%`bdKLrxYSNB@TgxY`_Z?<~ok zMQGFM%8=edw?qHzL!0|*j>eZ?6<*No-v@_t*PgGo_4H(8sWjL`Jm6BY(Q+q$4%|$K zf>+Emn|iKnOFEh22V|6^8I5oq)Txm{00Dkk2Z$nINw1aR{Bz{Whw-Q9Y#u2vFc!as z%x78+y{gS!%BjDb`42jR-sz1L?I@={Vh+lPqx^f~0L3^%Mhq%g$T*Dhw@`!TCxGt= z_nvp860e$ZSuA2)v1vtxvF~l7&G0XzDC^l*Bgy>tE+nv&=J#{3=IOl-f=vD$FLDJH z9(VVx2G%O7-$N??#FvWEgg-Z!gJ|BsxMNaSf-Zz8N*eA=Cd87hpe=~ETJfcXdE%M zNPx*ETZ1=9ff&x=XucrtR&%VeO z*QutC99$aWaV}qEwM*pjN(?e(DnYKinhlO=QT=%B;!FUG4M)fK zzxaWhyJzE})jwwae$ccJ)l`mj$!Z0x$(~1sVvL=MC+{Z{5^WWXq$VrpA9jG0i36OB zNI9`P5mN}EfM>W(xS>kA-FjAf#DhY$49FASO>OF9F zxkdgt6fZMYI*2r&mv6pUGHb5!ZWBOjDyu%tuI@@OuCd7SB5m6``^8S*eUMkO?e3Co z1NHD{?g*k-ZT0W$%#5K*a4X>evLZv;0$8Se>B#whzV*uDvVSJ-C?*e7J7e&hP8e5CuBfErepHW^WfFcQ)@Iio ztEeg1x054u54VWbF|2N{r!YwS9j9_~%Pry=Ai`N5EDc9Q&zJEYkD2;66|E7btVL$F zGDr~I;rGm_Cu!_%c?NmiX^=Vh!JgxlqPM-%#T#w)_CdXX z^f*(mLgqdcu{wsJy(N>tu(FVvm^u!)hEbU^!z?*J10JET7>5h){)E^NE8 zgA^1S7e*=)93Q+u)Wj(v`G^Vm;l_-^7_^u9D`i9p z&|r$GF5cy_E7!3AP-(bSkAKY%5MAmUN&6rhC9Sc2M=LCKdEGxW;cpKu86`M$V*`&` zEI;T05Z(aZf!Zr8hj)!A?)~u>NIfS>DqjRkV`RKK4?gb^@U>Ek=}W4e<&TkBCvYHP zehxv(7t_!Q3XGvWJu`p6#wv1P;MP1LKILLP(0!J(>JQA>|UM7qdinz9+ zfx$Dp0)@!f*sOrnHaBg9Ib}1dWzap~aip!`JCc%;lID6!6Y8>jP?&b$F8Oyck%~p7 z46YGi>92kgSIyRbOb9WqKgoEyfrldk5^trU(r#Rtl(hbk9O}QIhoZjve4$}sZu-li zj2Ww3kH(hf6aR)x%s6!bB}6IqeZKXseAK<__ zVk5u)l=8hVZj2&~ro*ao_2szO^0a&z|1TH|Sx;`SZ+z`m+rC@sOFuq6_zATTDSoD< z+TVgs8&MBO_K?zChYHK#P){aoZ_h#Y;^Nv8IKexccMR-Vvo_Hc`f5wKO=f^X1eE_&_fIn?o{e z*fn!OqiV8u#3YEdVYzK-z9paSP(JsW`Tz|wdUg%`@d#C#%S0ss z(D)g4J7upYD{-csdWWP28Vq5`GA==j^V(g|7x#S^wjZ3?-_P7q!%;kBNz=ba_z{U< z;5BeU3=iqlgh;%>g9eyE@j6K0z(O*tV*ovpBoe+87#pjL(~tN=A8`3MeC}iWi_Tc* z!DHscRN3}&y4R)Rr9?*!$|1Xw&${pIl0dh(AKYie?ef~vd);4laJ~#@6oX}AI8}iQ zOaCO;cQgzVhKJHRWtq<^R1s!9=}#;G|3s$-hXI(xiH=Bd&96g#IWY#rECYMQ@X8=f z0x3~E+|QJhB#bCPJU~K_N@Ak$b6?Ooub<_-xtLbMIB+IBifLhQr;v2QrYQ9dl9^Cm z8ir!}gpxtgmtSeZ+nWgn`R3WH@pQ#9G_pGK%5~Fkw{$@rKEuZu0;!`9`+wwZl(IkQ zyyfo$X+|j3M_>(wuq9dh-$wUI3Ysw-$(?>`iPy)=Si}wzsY&0oCC!mV)>{K)hKFV> z)cwjb=m#WBf53(tqj9o(Hc1;k@g5$QM7Su3kQ8DACu;CeTxKjq(BnCZ7PhwBjUh4R zxyqtP7}5Uyc2y&0dA)~1!MJf-V57{{WpMA6)4dzzB&5N2xKbK5zHQ2;8r?JtOb@)D z;pqp@)!rG#Xu`{ju3Tw?3e_W(HW)fWO1Gol-$Z*1j?UuS=4Nff4t%{B@$*pBz}eCS z$aZv23VFREo0}HMI9Drc%NLixLB{cTAALvohfg)xChID4A-<;kCInH3r*K78n$2kG<~m8 zzgg9HQV`zoCP}4LS%Kdu$l!p^YNT)VO7|85ekE10yl>`aQm^U&B~^Z2D&d_G-hk&i zxyQ;cOS_KtiocR2J2wllS%YMhPf}TZ{!$}f`B|cv9*GH(bo8~Ny{p(hBcso{Dw@}| z?M^VF+i8nL+uVbdn0n0mLu9pWw6vI35^KY`8g7KjP>vLkY#{7Fm^KB(CFeF|UVaZP zIq&EC;&;mTr^CbR2}aK16`AJ3Ck-!wP9M+nix6hu1#1j$X*aLe#FqKbls2};*e04E z{3WZw!S_`5{^eYkjn2yH9 zLRxIyM&;PUOL2(1jCl7Wk})0U0G@zyq{T4D_~yo@jI(Wc+0W|kE>SEIoi@DonVD`E z;d0$GeOIn`wxCvIznE8hphoZ`MTfb#e67M|;YfRtZv%Ba(S>ZUL|yaaoOX68J~8c! z-zkGb1MW}RPyC%rnEP%`TzOM_V=qs$M}wP?H!kJ7vHOwo6KGKPxoY3r66g|W-5dFE zehErTom)(eq^F+dmKJ4?z!Z%U#GneoKUxeP^Qy9eB7s%=u?LE~U3BK#chk0H_Tgjs zs47DfkO&?>Qx-j;=~Ao_c_9v>PN|HFe!Hg4$z}qQ5ZO&T(93N z0|%TaMdOz`$sXrpPqtAhyJQRM-+W;|UbO+pSTnPMG_C*|dNn-v&xQFvNK2G_3-_z( zClU>{soA!!yQvIgL6ZPfqsr$f~uC9BW!Z}p;hj-^+v0-vV9TXd3 zQKh$Swq`8EN43WMYxG^CHT>nN=d18Fi5xf&6F>fzZo-e2{v*}57JfBv&>(-g9Xx|^ zJK2YH78g8`>)RQ4YMIh571aTj!BzXZqIs|45WK&g5(hf{&!5ZemVA+c6Z6QoT7Nnn zbmpG=OqJKVljN6(_&jxV?rK~MS2-M@x?E(p3+Z-bT5+{Rb`n+kvc z09u067dEHt?_b={3GHe2;>lC`+c?9z(M-ax0E=I^CB6jNSo~@EuA>oe$9o0i9>U*j zO8YC>Pqv0x69%a6lwhksIe8nt3RDX%7<765b{nEkFM%_#XjGKsh{Vr6nAc=Er+SW% z9eqy*xVl_xE=yaMIv>W4ov<9W5g`f^=Lgl32B3=Rg{UKTXy<#}6*_jy67JQbP65~4 z5qb3@Uzc7wC~X5*l3B#74;R~*GciSk(It8qw#Ae)3U2yKlv_@EqeAZ}j~pKeq6mBLJ_^=4e&RSVqI)fxv8 zU%W2v^9f4z6u|whGFpSH^gd6Ezm^j5Yc5D!xJ-f@huV#sr5-~CK@Sjxb_7t;Y(yv9 zwV^?rr@4v0{Si4>bkA%&9;WzaAA=+C{JtBn&_qkMors$NgP2>c{^aH!v$urZpWuz& zpJH6pES3n^6s^(Wu+rWb*lh{2!jwm4TGq2!Y{A%8Y&1!KUT=X@&AY<-jC~=(VruYi zB-^?wzi6DWB4MDnuWq=hx~{T4nu+8)mtVHbv*HDlKI>U21<(-hx^rvopFMfnDGGFl;UPA$p2@TV zR8MzMNGx~xraOmM4^;#*g)EZLdaQzs-cym*un9-BksK8Cm%;z8285xOobnn~GHYOJ_ z&lx_<99h_2NdleVm6%fdp!R236ROdCPZVNiw(bj}%Acp^8-6nmIFt165gCMG=y}_1~n+;S(+l-cE&<0@rb@LuQ1hFO#j3LPsWuhkt+w1BWRj_n79u>-YF4__m@3V`JpA#wYxliKPPvE= z{Y%EOnoE%ohVw0*8m(M^fx61o8*v?xrAot1Al{k9QE0`9iPilb3Qt(>UJRCG<+S`` zC;36Gq@A3Lk+O@BfX6t(O-QlH=~4y-{mbhpd9rI7+#nxer%VZKOWnh4yuo#dD9U>y z3*j!$4Vb2onCDGhlVm@?n^}?si`TH|3zYbeGB$>>>$JI3OP`cf14kEp1W1kAq_xxg z(g&f*_sEwwsMIxi79u8Tl&3bagDa&d))-bN3rhwR$V9KBBii`Z;+ELl|Elip$)*W&%e>+_7O=e4e5n9&!;(j2~c$Ncf9qmDllNS>-6}`sWrKRJh`aZ-CSa${Mg~%xNeSta1qV@6hrq;s2}q8k zcDY0?EU>C8qItbirnwP(<-<-Y)v=RE`Q&-OzK#f%9vE5MjplndWbEqLXS1m|nXx1& zu<4+eYY%Vy&dZkUGpfS(>30QvY$7m$Joizg?ho&Fx8=UFoD(NwggKomv#us6d?HF$ z?1n=oiMg`n5@Cv*@I+*6Wnw&Jr~OTF#y2T_3sCAMN&!eAkyUIb8-qI0#l$Mz1ohZ1fmS>lX%csuziBsJ=YN)P?geDs_%a!B| z%@h4-w{ax^$T%>*pu|mmQN=*V`77vm5S5yUyJKDHj7GE6Rbn!F;Ol#QRuYLyM21y~ zKM(8SQuze|l>wCc)L=0ryoQpVrFrCqs%g{PVUv!{S&zHwQt-X*7unx~d6mH^+sCm) zzgib+lj?8qK`I4xQY_yYlCZq?o@qTO<6P$7X}#qkaPBdISiclF?!uwb4|bTL$7^l@ z3{E($SR-mHwVq9^9J1FPpA{6OG7Z=ZzsHk5h;w=3F?yh!N?$JwCPSPa z;rD?+0&(vvE<=rp2#o}3$q|d=dr!u?x(miykJ^C4AWSEYZ!UwWGC>hpy^+yTi&74| zu>mXGB>*#m<u+I0#KtaO~srz+gw=IFAc}q_@MbB*|W*{i-6vbq<4Hit(lZ3SwR| z-Bz*IPmP!;R8{+CDY9qxabBZ}JJdd`u~$oeFO1aE&BxE4`1E38MhrHcI~4=|Ac#+< zV!70|Hl4ryjup@Fo;1(h_i??Z|8yXre2c8Fo=N$E6vNLK^qi0Xwx{#^k_|6=p6Lv| z_e&IOwWi1AvWa7Zam_yOLrfWyl933I--4CEleN+xa@B@XT-(Y*-TRL> ze@iZLQ$=D=U}I3?@8eB*|0%fqZRF5&3dzo_rXo^Jktc9P4$<;nTk!KkJX z@l!+Y1PPchG%ua?24mi|;CX|7D}})Kt8T}n63)4O8aj)i&4xTeL5OQ{6g~f$vT`a4 ziZ~%==fS}(V&~7Wx`*xgz880Hy z`L4#bQ96rsTgEpXF>T=bO*BnSx;hO9>n8iVhl|Y@(d{wbt`oO9IRg~fKQHZUgN>OC zk&6=M^Q32)GyXnYv4ZBW0mxB8~3fp`WKXRUFx!^Vs%qb`^ zdX-K)Z(_KJH27V{?Qk)pZ42-ZoZoY{2OB&D>z?ch-MbZI<_C|vztPMqnSynL=v8n3 z7{9m|a&@PRpmjyE1GGAc3n{hL0S0V>4F6XrT@#@) z6U-NHY*wOGC~#^wD>NBYNzUg?LOHl;dN~N}^!Vst05)RRRK{Upfq{yI7ZX4!DZiK) zEGx|RajnR5$5y=?4hn{lB~nLgV@G=uF1^F}=ux$MgM9sR$csT<3h$evdg}Wf%*+Gn zRVOGJi^89k0vYs0?063W1CR zCzn)mX%bZy<(W9xzk&saOlZ7=j2R`(Nn!?q&QI8n7esqrs~#{%(6ob5KXH(*mrDHD zJxbJEO&Dr%q8Vj_`b5pYi6>|J~Vgo$e?48*#+s#EHu+ z_@1K&W%=U*Vs=bxbm5i1VtyenInrrrsp#(xS$`<+la zRr%&|R5(0uc049F&e)Bn_2_sMD9dY7tQn8xVTH6oqf6uH|0_1(frJUF{I()(k1cn=vVIQD3?75c@nnLoo{J=|K2z-r~LHnLyG@ zm7W!0vUxlLh|xD~o=OjvoUbmj{`gg~aYM?*iUx(2Dr<|js@|&M+$%IYI4Tk-Ifp}` z!Cr6>4>~A;&Zh3wbMpLNL&nP17GwQXmmTA(U+!DJ*eyc6co&$otR%%+#&D`U()!A= zpr84yQewA16Cv{F6VE2~h#SmEiH-`dE33;vG= z6ab)bZ%=C!7%}%o{mM!4ubjkeL;>^u7y0GBj2mG>-2DF{CH3;EoyZ>cf zzx#7J*|~cD8S3SZb^C+=ScoUu+06lL!MxDFXZj~{-tM=b)6UbL7$*v`Mp~?=EBrTx zNPBtPxPsWn%iHT0yTAQ_1Godk>WK2MbwL3b(Lv-L6bXATJ1-(2!QpU37b$32kSqW@ zh&8eSAQ&J6Jy7OIa?n6PVgUv(8}DCfu8B#5O?EGD?_V>3n3&}RI0t9|POutVA^?Tx zCo0W9))kBefcTGqV}1=HfZfZ@mH-;cUiw9#jK-&*$GN{|2PJ=#K z&<=yz1Zwj?n8*rPpRGG+i2{~STs((2__mL=^R@=5a8QuH?vlezkzAz}~7E0SXW>G*#F@Y2zC@U;I`7qN!F@|S_X4E$x_F9UxW z_{+dw2L3Yemw~?w{AJ+($^g?48hoSy29XrNcGmpRy7pD06!nkX?+_gI;_pPcZzPn? zoEzwy&BA9MV4r9!vNp+lS7NYw(4Tb{ue}wHIsO5N01P^__ol3IN^$G}vpVSmH zeBxx$f9km0KFP}33;-7yNYZpvGy$m)e;gp?QjP;i)_<;n4}K?A_SyI6^Ku{-pLbtN z%fExZZu@8|K_TC(w0xG{+ajEkq2}|xDs1)^&70kU7@VnaC}TWts#W?o0&|Xt$`I{= z(tX|K5;erdm2BFiqW%Km(_j8Os|=Ury$3X=NhMiOGB+5 zP3q*9FWR16&z{ofGle(U4BRFVaI$PNJ%pwX?q!Y^7^ypSyIi2M(5XP=nHv}O^zv}N z5Aseh-t8jV+J;9>pvY$cWl+)fF7{CDLAXHmyk504Yg_aCk>qpUE%^JoE+4tnIG%`R zUZ0s5T6}*oy~b>pkK|(Zf=^IhV5N|4apfyG0BMGtA4|P1&HgaVF1t9HONud;biKcJ zw)?%@NrmSV9jI>`Di44_i|Oe;+OC>>risR%6!h|vq{aL=EprXkbrFlY(X~kG48;3L z9fgVOz+8QCO)F__+x2W}CiY#MLxn7_Rlw(a--Xr>r+(&1N>JGN>&5vgh=-)_yPNeT zcmmOkZx4b)zF3V$^nCof?O9jO0ll&oO8Olj5isuK*j@twq~S(f@WGhn>$AaY(qe^D zj|NFijl+8nGIbU_7I)%Gv_E9Xq$;=Kgf#jO(l(bvAaJ&_K@=yQYA6szdyB{Ff&A`) z^X9rolZhf;uEm|xL`g1f_-njHc-ytaK%I|NkLHDKyVZlak4WQ>L$U@1?`@49@anw$ ziOItGZO{uFac{wzY7ouU?IJyJ#;?>{=CJSuw`<=lTXnMCqIx`J$`^MnWeIW+ZG@#= zokpruZ1Id60vbxKdp3qrQzZy{WiK#8vu$FRrfX?0|A%m9N6dnMUGck0h`MsNa(XTu z#j@1hnkPv|63ik2kty@*JshpMK~1hT8U3{1d%PY1E?Z^*wU*rNE=t-)ErOF)fltDU=RIF$OM*Ld=K#NS~G`JuEgd=4-i;+L4BD6UeKyJXrR{_sFQv z2W+(i4#rKQHOUm(9yUF(9SQ9Ax^_`BNd^itGN&7Z)+q~w7Um#VxDYYY<^%kqcKKMw zHoWB=M3pUn@D2+soJ1Kmt`6f%B=t3|0m3gC1XdjEu+(-T;!`e{PZAa}`C;5KE?-;o zJzrg_6-b}ggJ-7{5F{~O1#-FRbKN3$Vn_((jmS{br@r;kb7?v34uxI2mgjm@<`BL2 z9V8x78qdka$F@0z8eY{YyB~1+%TU;K&Z{;4Hx^TmPpVZBCdVr`f*<22`uf0Mr%0cw zL$h^(Imkt}0##$C)0$k!NE{c8iYzP?R$<5mV_a{=&hM3_R#0wY;!dphA8BOQjf^z5 zt)#cyc0bI#if_0*cl1(ONrYelLHh2x)~=CikV_iFc1c>2IU z2>&7E%fNfJ<@qgup-+CJc!{|&YYUYu8fo_`V_W#R_(UcekqmQ<%-s%06UpGokEO6%1IJqu z0bi{BZl6ly%9r>^%N4j#;iBH=llrOpqT6_C8(l$>E64iIepl-t}J zNL*%4+vlTcJ?_DMg-q(5-NgZuCgY}(Kk`iM>+SeQ`IFF3X}=zi_6pDnVU{YSZmm4 z4<)3>SOkS2me@Tl%y@2-EgcGMnPx;aYmn$P4C7Z6Jtfbg&?kZ85m{lxXo_1!ory0$HFZwm3!cW!5Xw2c zRZyGg=loRuI>P~kJYjo-gQk-$z(jg^Q>KGu3R-@5UQf#De6VbOVFYD^3Y}=SprRWL z2cG;2wuI%{ohFEqNxY#0;xa9iF*}@>5k|ENyBUME53gbKee&s(jonSV2~I95YDdS* z+ZTkA@?8p%YC#GPJkM{*zso%S?Bur5v-g%?(PL_Kyj4LjV`}E;RW*GuMb#8?_PjBW z`*?G6bH&QFiKn3C-j3@06`rT_8h7V?f(uCRC>jsdhiU>@$m8fxJ#?K$h^CYd4F-Uq-fKf7Gx;9|kSCa7R#NJX zvQHV^NF=%Tk~duQ{M^nSRVymu_T*0`nWs$Cgwk0Ww@TSgy-jy7twQ$^A`WW2 zv^G26W?}+YEI9A-Zz+t?mM6qvU)Y+rQc?)w{SJSOUQLNW516s`o^PZa5zP=G=YERw zeQ{tz8K;iCcB>iMhqsQEAFx<0^aPwPXOlAx^Q=hg<5bqwh63IFMpk^N>I%E2O9hWV zrNB8(q>}X*sz3$&3%G>m1s?CBeVTPQFMn7lMNo}HAMiaJkB>OsD{&h@AYta>KY|o| zngL$IrK*-}erpf92f2=KT~s3AbC9cJIh{TnbkWu%l&1XL)ZwICmXqtYy`oUa@#kn7 zN=!R*&L?dqW;3RON7V!RPGg$GW%0d*$(CC;qucVl>8~gGCp|Z8=cDH8OBJ5~io$xB z1VPB?P)9Z9Z`_VOnws{9oCzV&8hu+JoRl2?>;%nFrg`xbVHIxk@U#JRHvqlZ7s^|} zbgNJ>&qBO)fidktL1nL#L?&=$)U2#7z!&CXzQ0C3G zT>ck2606Hw)z*@!Z~7fs(l@~yFm1M!;v@kUdOKt!QYG``7S*#8UR}+O2Qv#e2!RRe z)5T2QAm)I%Q>!&L_H5$>0maiPMC)YVZCZRsy;p6RZBfJuRlXb(3y{W7i}4P1KCPm` zh{du`46?&xfxGUV2^JJAFFu63!2y7GwR*8DE@9oHK1;;Soy>fqTM9bIl)CxdDv4Sz zD#61%U+R^{i+k?p=x`YDSi(uZ#ZpB;VK`Mdf|@K0fh$I8d-tx2Y3H8C8{9v$y1Zaz z*>pmb1EU;teeA^@9Zr|95Bsf^_KFFX-N#uLGh#1BgR9!dU7`!7v!+8*ZakhFsn)2y z$lXfW8GEI)5U5xy-z~CATpv}?2f9fSsD}Y}CUO&}1k7jYjCoe8mrpyWWp$cotriGU zz65f`<&A`%X%Vy*C)2-GwV#UVCAnHV2f$Rq9N!{UZaOu-p}PuIuVE_Y1KPTqLc*9-o^2A(rG z$!DCLcT|vrBZ6=yQYZq5j2C32gbg?_8sNSZ_2f7BH5{8;g#=Uc+4h7pAIctHs$(y6 zxmsv?xr)Gj?&~Xgi3PnSnvfQUsTcGrbtn=S6ib>oRducKqeKb*XmJNBj#k_4xJUtV z`lUF#?Xh6p3GtQtbc)?*v(VbhhH{bx`pK*3g*OfR1sv2SuPHu6+KJFm^|1^$rHg-c z_8Vd{L{PW9BuUVPV{K%Mca&MkwCqwlZFnxdJEK;!@BpSBx04p}#o^v@{^8X7$twOr z<211vMEiZ>;$*Xo`G?h2L!Yj7vuUP>x><^elNBTtk4#v-m&X2BUSBT%(ork*{iyHf z^1$&~_PJQ8?Zu|2>$8s>vyPQ*C715r`gu;eq=$FSc0pVJwbi74NR)x2ebR);Gkw+h z+ws}K<<=CJhCZ(!IW}hwUMg0M#@|sJCZ~k^yo?+Cp!wv@-p&A9=?lFuk*3|${royic!|)+u9CFtc%Dt2Ks5suD>r!m z0g$t0Q1nOA0{{hte=H@kkTECR2Ki8zi39ADlecnnEBx2M6&bh3kIv$6mN^SQ6|%PJHsH9rrdVyvC#cvtsl0JE=r;L=CF-ts`2N>{3zjv@vzfe zwhwcSUtNus;3kt#%lSElQ;c5X4&><(6%gJWVTd3ridFTQJ)T)AA8|3RqKM5_Lzf5e zcb#QUss|Ri8f&*EV#fmgpWoM|Vk|o|M*C$8A(SqbOjF)m- zm@_)X|8C*=;Hv9yOJCN1jB^j@&B+8N;hJ4~%An^uoGel|?ic|PCDRg(o6){?UUFE-BEv!gaC_c}gN_}2JEm|5XiP|HV2Q~xw`n!Y^ks_W=G&YAjlB<$h+b?P$xfIi%%TQ$trYc_UhpXA5%mh!Q` zU#+5VId^Q?G$Ep$4iCm1U%7sr;G3TzR|}PVNbJU^hGBeH%Ze%kY!P`QK meters of reach + const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R)); + const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z) + let zt = null, bd = TUNE.zSnap; + for (const zb of zombies) { + const d = Math.hypot(zb.x - tx, zb.z - tz); + if (d < bd) { bd = d; zt = zb; } + } + return { tx, tz, zt }; +} + +// splatter tap/lane aiming (no flick): the shot locks onto the best-matching zombie +// anywhere on the court — including your own side of the net. function zombieTarget(aim) { if (!zombies.length) return null; const none = !aim || (!aim.ax && !aim.az); @@ -49,11 +64,15 @@ function doHit(ch, quality, aim) { const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); let tx, tz; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; - const zt = G.mode !== 'tennis' && ch.side === 'P' ? zombieTarget(aim) : null; - if (zt) { + const ft = G.mode !== 'tennis' && ch.side === 'P' + ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null; + if (ft && ft.zt) { + const zt = ft.zt; tx = zt.x + err() * 0.4; const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; tz = Math.min(23, zt.z + zt.v * T0) + err() * 0.3; // lead the shambler's walk + } else if (ft && ft.tx !== undefined) { + tx = ft.tx + err() * 0.4; tz = ft.tz + err() * 0.3; // no magnet: the ball goes where you flicked } else { tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; @@ -103,11 +122,15 @@ function hitServe(sv, quality, aim = null) { const toFar = sv.side === 'P'; const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; let boxX, tzs; - const zt = G.mode !== 'tennis' ? zombieTarget(aim) : null; - if (zt) { // splatter serves auto-track the most advanced zombie + const ft = G.mode !== 'tennis' + ? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null; + if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie + const zt = ft.zt; boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8; const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; tzs = Math.min(23, zt.z + zt.v * T0); + } else if (ft && ft.tx !== undefined) { // free-aim, no magnet: serve to the flicked spot + boxX = ft.tx; tzs = ft.tz; } else { // target: diagonal service box. Serving from deuce side → target box is across. boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality); @@ -121,12 +144,14 @@ function hitServe(sv, quality, aim = null) { ball.lastHit = sv.side; ball.bounces = 0; ball.pow = quality; ball.kills = 0; // fresh serve: multi-kill streak restarts - // the armed tin auto-loads the serve — one-thumb rule, no weapon-select input - ball.bomb = G.mode === 'splatter' && G.bombs > 0; + // specials ride only DELIBERATE serves: a flick (or arrow-aimed keyboard serve) commits the + // tin, a plain tap keeps it in stock — so bombs wait for the enemy you mean them for + const armed = G.mode === 'splatter' && !!aim; + ball.bomb = armed && G.bombs > 0; if (ball.bomb) G.bombs--; - ball.flame = G.mode === 'splatter' && !ball.bomb && G.flames > 0; + ball.flame = armed && !ball.bomb && G.flames > 0; if (ball.flame) G.flames--; - if (G.mode === 'splatter' && G.triples > 0) { // one serve, three balls: clones fanned ±6° + if (armed && G.triples > 0) { // one serve, three balls: clones fanned ±6° G.triples--; for (const deg of [-6, 6]) { const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r); @@ -204,11 +229,17 @@ export function splatPoint(side, quiet = false) { G.tinDue = true; voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak cheer(2.2, 0.5); + sweepCourt(); // the wave is OVER: survivors collapse where they stand — a game ends visibly + ball.active = false; // the beat owns the court + G.pendingGame = true; // 'GAME N' announces the next wave when play resumes const i = side === 'P' ? 0 : 1; if (score.games[i] >= 6) { // set to the winner — and the court answers with a horde G.sets[i]++; score.games = [0, 0]; startHorde(); } else if ((score.games[0] + score.games[1]) % 2 === 1) startChangeover(); // real rule: after odd games + else { G.phase = 'over'; G.overT = TUNE.gameBeatT; } // even games get a real beat too, not a 0.1s blink + if (side === 'P' && !zombies.some(z => z.variant === 'r')) + spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it) } else if (!quiet || G.idleT - G.lastScoreT > 2) { voice(scoreKey(), 0.45); G.lastScoreT = G.idleT; if (side === 'P' && !quiet) cheer(0.7, 0.25); @@ -242,6 +273,25 @@ function deliverTin() { G.flash = { text: 'NEW BALLS: ' + ['BOMB', 'TRIPLE', 'FIRE', 'GUN'][w], t: 1.6 }; } +// ---- the interlude: order is restored, one sudden-death point vs the racket zombie. +// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching. +export function resolveInterlude(side) { + if (!G.interlude) return; + G.interlude = false; + ball.active = false; G.phase = 'over'; G.overT = 2.2; + if (side === 'P') { + cheer(3, 0.7); + deliverTin(); // win the point, win the next tin — on the spot + } else { + voice('game_opponent', 0.4); + score.games[1]++; G.runGames++; G.shameGame = true; // it goes on the scoreboard + if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; } + G.flash = { text: 'GAME. THEM.', t: 1.6 }; + for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge + spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b'); + } +} + // retire the splatter ball; a running changeover's long beat must not be clobbered short export function spentBall() { ball.active = false; @@ -253,6 +303,7 @@ export function spentBall() { function startHorde() { if (G.horde) return; G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked + G.pendingGame = false; // the horde IS the announcement G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 }; ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose @@ -307,6 +358,13 @@ function onBounce() { explode(ball.x, ball.z); ball.bomb = false; spentBall(); return; } + if (G.mode === 'splatter' && G.interlude) { // sudden death: the lines are LIVE again + if (ball.bounces === 1) { + const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; + if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; resolveInterlude(other(ball.lastHit)); voice('out'); } + } else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); } + return; + } if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns if (ball.bounces === 1) { ball.firstLand = { x: ball.x, z: ball.z }; @@ -436,7 +494,8 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { function racketAI(rz, dt) { expirePose(rz, dt); rz.swingCool -= dt; - const incoming = ball.active && ball.lastHit === 'P' && (ball.pow || 0) < TUNE.zSplatQ && ball.vz < 0; + const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0 + && (G.interlude || (ball.pow || 0) < TUNE.zSplatQ); // in the duel it plays EVERY ball — it's tennis now if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; } const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); @@ -481,7 +540,10 @@ export function keyPressed(code) { let q = Math.max(0.05, 1 - dy / 0.9); if (dy < 0.12) q = 1; G.wasServe = true; - hitServe(P, q); + // a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special + const flick = G.mode === 'splatter' ? takeFlickAim() : null; + const kb = { ax: axisX(), az: axisZ() }; + hitServe(P, q, flick || (kb.ax || kb.az ? kb : null)); return; } if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; } @@ -557,6 +619,16 @@ export function update(dt) { if (G.mode !== 'tennis') updateZombies(dt); if (G.mode === 'transition') transition(dt); + // --- interlude trigger: the court is clear, an opponent holds the far court, you're about + // to serve — the umpire recognizes a legal match and restores order. One point. Sudden death. + if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) + && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) { + zombies[0].dueled = true; // one duel per opponent — after that it just rallies + G.interlude = true; + voice('play_resume', 0.2); voice('sudden_death', 2.4); + G.flash = { text: 'SUDDEN DEATH', t: 1.8 }; + } + // --- AI (the opponent only exists in tennis mode) if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') { G.aiServeT -= dt; @@ -621,7 +693,10 @@ export function update(dt) { onBounce(); if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball ball.active = false; - if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming + if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter + if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel + else resolveInterlude(ball.lastHit); + } else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming spentBall(); sendBoy(); if (Math.random() < 0.12) voice('new_balls', 0.3); } else if (ball.bounces === 1) pointTo(ball.lastHit, null); @@ -629,16 +704,19 @@ export function update(dt) { } // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { - if (!G.horde) splatPoint('O'); // may start a changeover — spentBall respects its beat - spentBall(); + ball.active = false; + if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel + else { if (!G.horde) splatPoint('O'); spentBall(); } } // splatter: retire spent or hopeless balls fast so the next serve comes right up. // z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26) // heads to the far court (vz<0), so it must not read as "out the back" on frame one. + // (during the interlude only spent balls retire early — flying balls must reach their + // bounce so the line call can happen; the umpire is watching again) if (G.mode === 'splatter' && ball.active && ((ball.lastHit === 'X' && ball.bounces >= 1) - || (ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) - || Math.abs(ball.x) > 7.5)) { + || (!G.interlude && + ((ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) || Math.abs(ball.x) > 7.5)))) { spentBall(); } if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss @@ -664,10 +742,19 @@ export function update(dt) { G.overT -= dt; if (G.overT <= 0) { if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes - if (G.mgT > 0) { // a GUN tin was delivered: no serving, the racket fires itself, drag steers + // auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into + // setupPoint() -> pretoss left the player locked in the serve posture while the gun + // fired (no movement outside 'rally', no serving during auto-fire). Back to rally. + if (G.horde || G.mgT > 0) { G.phase = 'rally'; ball.active = false; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; - } else setupPoint(); + } else { + setupPoint(); + if (G.pendingGame && G.mode === 'splatter') { // the next wave is announced as it arms + G.pendingGame = false; + G.flash = { text: 'GAME ' + (G.runGames + 1), t: 1.1 }; + } + } } } } diff --git a/input.js b/input.js index 7c05d6b..9536e00 100644 --- a/input.js +++ b/input.js @@ -29,7 +29,7 @@ const DEAD = 14; // gameplay consumes the flick at contact; returns null if none or expired export function takeFlickAim(maxAgeMs = 450) { if (flickAim && performance.now() - flickAim.t < maxAgeMs) { - const aim = { ax: flickAim.ax, az: flickAim.az }; + const aim = flickAim; // carries ax/az (lanes, tennis) AND fx/fy/mag (splatter free-aim) flickAim = null; return aim; } @@ -65,7 +65,9 @@ const endTouch = e => { if (!stick.moved && dt < 250) keyPressed('Space'); // tap else if (dt < 300 && dist > 28) { // flick = aimed swing flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, - az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, t: performance.now() }; + az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, + fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim + t: performance.now() }; keyPressed('Space'); } stick = null; touchAxis.x = 0; touchAxis.z = 0; diff --git a/render.js b/render.js index 8a6597d..0a97f6e 100644 --- a/render.js +++ b/render.js @@ -390,8 +390,10 @@ export function render() { if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; const touch = matchMedia('(pointer: coarse)').matches; + const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null; const txt = G.mode === 'splatter' - ? (touch ? 'TAP TO SERVE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE SERVES · ARROWS AIM') + ? (stock ? `FLICK FIRES THE ${stock} · TAP KEEPS IT` + : touch ? 'TAP TO SERVE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE SERVES · ARROWS AIM') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); ctx.fillText(txt, W / 2, H - 16); diff --git a/state.js b/state.js index 977ba84..bbe8648 100644 --- a/state.js +++ b/state.js @@ -39,6 +39,8 @@ export const G = { mgCool: 0, // s until the next pickup-mode pellet deliveries: 0, // tins delivered this run — indexes the weapon ladder tinDue: false, // the rules called for new balls — deliver at the next changeover + pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms + interlude: false, // sudden-death point vs the racket zombie: tennis rules apply, shamblers spectate horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving runGames: 0, // games finished this run (either side) — the difficulty director sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde diff --git a/zombies.js b/zombies.js index 8075086..55ceccf 100644 --- a/zombies.js +++ b/zombies.js @@ -6,7 +6,7 @@ import { TUNE, COURT } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, voice, boom, swish, shriek } from './audio.js'; -import { splatPoint, cheer, spentBall } from './gameplay.js'; +import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer @@ -43,6 +43,7 @@ function killZombie(i, quiet) { const zb = zombies[i]; const big = zb.variant === 'g'; // a brute goes down like a building — twice the gore zombies.splice(i, 1); + if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts gore.push({ x: zb.x, z: zb.z, r: (big ? 0.8 : 0.45) + Math.random() * 0.35, a: 0.85 }); if (gore.length > 40) gore.shift(); const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster @@ -60,6 +61,21 @@ function killZombie(i, quiet) { if (!G.horde) splatPoint('P', quiet); // a horde is survival — it doesn't score, so it can't chain into itself } +// game over: the surviving wave collapses where it stands — no points, no piles, just bodies. +// This is what makes a game END visibly instead of bleeding into the next one. +export function sweepCourt() { + let n = 0; + for (let i = zombies.length - 1; i >= 0; i--) { + const zb = zombies[i]; + if (zb.variant === 'r') continue; // the opponent keeps its residence + zombies.splice(i, 1); n++; + for (let k = 0; k < 6; k++) parts.push({ + x: zb.x, y: 0.5, z: zb.z, t: 0.4, dark: k % 2 === 0, + vx: (Math.random() * 2 - 1) * 1.5, vy: 0.4 + Math.random(), vz: (Math.random() * 2 - 1) * 1.5 }); + } + if (n) splat(0.4); +} + // swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off) export function meleeSwipe(x, z, range) { let hit = 0; @@ -91,13 +107,13 @@ function firePellet() { pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 }); pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet } -function spawnRacketZombie() { // one takes up residence on the far court each horde you survive +export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts zombies.push({ x: (Math.random() * 2 - 1) * 2.5, z: 3.5, v: 0, walkT: Math.random() * 9, variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0, hp: TUNE.racketHp }); } function endHorde() { G.horde = null; pellets.length = 0; - spawnRacketZombie(); // survive the wave → a racket zombie stays to rally you + if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up voice('quiet_dark', 0.5, 0.85); } @@ -141,7 +157,7 @@ export function updateZombies(dt) { if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes } - if (!G.horde && !G.changeover) { // changeovers are a real breather — no fresh spawns + if (!G.horde && !G.changeover && !G.interlude) { // breathers and duels get a quiet court spawnT -= dt; // the spawn director: the match's age (games finished) sets both crowd size and tempo const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames); @@ -162,7 +178,7 @@ export function updateZombies(dt) { zb.x += Math.sign(P.x - zb.x) * Math.min(Math.abs(P.x - zb.x), zb.v * 0.7 * dt); else zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble - if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < (zb.variant === 'g' ? 0.9 : 0.7)) { // it got you + if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) { // it got you — touch is HONEST now: sprite overlap = eaten gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); for (let k = 0; k < 30; k++) parts.push({ x: P.x, y: 1.1, z: P.z,