256 lines
16 KiB
Markdown
256 lines
16 KiB
Markdown
# PLAN v2 — Match structure, enemies, weapons, interlude (phases 7–13)
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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).
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 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.
|