Feedback round + Phase 13: the duel, free-aim, honest stakes

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 11:50:18 +02:00
1 parent 06bd24e6ea
commit 2f1419b1f2
10 files changed
+183 -285

No files matched your search

+8 -7
View File
@@ -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):
+35 -250
View File
@@ -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.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -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
+107 -20
View File
@@ -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 };
}
}
}
}
}
+4 -2
View File
@@ -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;
+3 -1
View File
@@ -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);
+2
View File
@@ -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
+21 -5
View File
@@ -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,