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:
1 parent
06bd24e6ea
commit
2f1419b1f2
10 files changed
+183
-285
No files matched your search
+8
-7
@@ -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):
|
||||
|
||||
@@ -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.
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user