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 0000000..d054547 Binary files /dev/null and b/assets/audio/play_resume.m4a differ diff --git a/assets/audio/sudden_death.m4a b/assets/audio/sudden_death.m4a new file mode 100644 index 0000000..aef0190 Binary files /dev/null and b/assets/audio/sudden_death.m4a differ diff --git a/config.js b/config.js index a66e201..bbd391e 100644 --- a/config.js +++ b/config.js @@ -24,6 +24,9 @@ export const TUNE = { zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests) zMaxCap: 14, // absolute shambler cap changeoverT: 4.5, // s the changeover breather lasts (after odd games, as the rules demand) + gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games + eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap + zSnap: 1.4, // m free-aim magnetism: a zombie this close to your flick's landing point is the target zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback zPushback: 1.4, // m a staggered zombie is knocked back splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed diff --git a/gameplay.js b/gameplay.js index eea023a..880e638 100644 --- a/gameplay.js +++ b/gameplay.js @@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DE import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { audio, pock, swish, applause, voice } from './audio.js'; import { axisX, axisZ, takeFlickAim } from './input.js'; -import { updateZombies, spawnZombie, zombies, explode, meleeSwipe, extras, parts } from './zombies.js'; +import { updateZombies, spawnZombie, spawnRacketZombie, sweepCourt, zombies, explode, meleeSwipe, extras, parts } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -21,8 +21,23 @@ export function predictLanding() { // first-bounce point, closed form — exact, return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t }; } -// splatter aiming: your flick picks a lane (ax) and depth band (az); the shot locks onto -// the best-matching zombie anywhere on the court — including your own side of the net. +// splatter FREE-AIM: the flick vector maps to a landing point from where you stand — a short +// flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that +// point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked. +function flickTarget(ch, aim) { + const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> 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,