Oliver's call: duels have to come round on their own so there is always a shot at a weapon. So the game-count schedule isn't the bug — it's attached to the wrong event. Item 5 now moves it from delivering tins to calling duels: same cadence, same umpire-restores-order framing, but the weapon has to be earned. Fires off proceedAfterClear(), which is the one moment the court is guaranteed empty (the wave was just cleared by hand and nothing has respawned). Clearing is already mandatory before play advances, so the schedule can't stall — a wall-clock timer would need its own wait-for-a-safe-moment logic and could fire mid-swarm. The existing opportunistic trigger stays as a bonus path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
530 lines
26 KiB
Markdown
530 lines
26 KiB
Markdown
# Splatter Tennis — Polish & Bugfix Plan
|
||
|
||
Written 2026-07-27 for a fresh Claude session. Everything below was verified against the
|
||
code at commit `c8990fe`; every "cause" line is something I actually read, not a guess.
|
||
|
||
**Read first:** `HANDOFF.md` (architecture, benchmarks, hard-won gotchas). This plan assumes it.
|
||
|
||
## How to work in this repo
|
||
|
||
- Zero build. Plain ES modules served by `python3 -m http.server` via `.claude/launch.json`
|
||
(`preview_start` with `{name: "splatter-tennis"}`).
|
||
- `config.js` = all tuning (`TUNE` is live-editable in-game via the `T` panel, which
|
||
auto-lists every key — new keys appear for free). `state.js` = all mutable state on `G`.
|
||
`gameplay.js` = rules/physics/update. `render.js` = all drawing. `zombies.js` = the splatter
|
||
sim. `input.js` = keys + touch. `main.js` = boot + the `window.T` test surface.
|
||
- **Stale modules are the #1 time sink here.** After editing, bump `main.js?v=N` in
|
||
`index.html` AND navigate to a fresh page URL (`http://localhost:8641/?cb=<new>`). Then
|
||
sanity-check freshness before trusting any result: `T.TUNE.someNewKey !== undefined`.
|
||
Bumping `?v=` also guarantees Oliver's phone picks up the build.
|
||
- RAF pause state varies per session. Probe it; halt with
|
||
`window.requestAnimationFrame = () => 0` before deterministic `T.step` tests.
|
||
- Synthetic touch needs a `touchmove` between start and end or it reads as a tap. Contact
|
||
tests must step `1/60` (coarser steps tunnel).
|
||
- Verify each item with a runnable check before moving on. Screenshot the visual ones at
|
||
`resize_window` mobile (375×812) — this is a phone game.
|
||
|
||
## Order of work
|
||
|
||
Items 1–3 are **foundations** that later items depend on. Do them first, in order.
|
||
Items 4–13 are independent after that and can be done in any order.
|
||
|
||
---
|
||
|
||
# 1. FOUNDATION: the modal system
|
||
|
||
Five separate requirements ("popup, game only continues after tapping it away") need the
|
||
same thing, which does not exist yet. Build it once.
|
||
|
||
**What exists today:** `G.card` (`state.js:60`) + `drawCard()` (`render.js:326`) draw a
|
||
centred banner, but they are purely cosmetic — the game keeps running underneath on an
|
||
`overT` timer. There is no input-gated pause anywhere.
|
||
|
||
**Build:**
|
||
|
||
`state.js` — add to `G`:
|
||
```js
|
||
modal: null, // {title, body: [lines], icon, color, onDismiss} — blocks play until tapped
|
||
seen: {}, // one-shot markers so a modal only fires the first time this run
|
||
```
|
||
|
||
`gameplay.js` — add near the card helpers:
|
||
```js
|
||
export function showModal(m) { if (!G.modal) G.modal = m; }
|
||
export function dismissModal() {
|
||
const m = G.modal; if (!m) return false;
|
||
G.modal = null; if (m.onDismiss) m.onDismiss();
|
||
return true; // tells the caller the gesture was consumed
|
||
}
|
||
```
|
||
|
||
`gameplay.js update()` — freeze at the very top, after the flash/card decay so nothing
|
||
expires while the player reads:
|
||
```js
|
||
if (G.modal) { G.idleT += dt; return; } // everything else waits
|
||
```
|
||
Put this AFTER the `hitstop` and `eaten` early-returns and AFTER `G.flash`/`G.card` decay,
|
||
but BEFORE the movement block. Rationale: the world must be completely frozen (no zombie
|
||
steps, no ball flight, no spawn timers) or a player reading a popup gets eaten.
|
||
|
||
`input.js` — dismissal must take priority over everything and swallow the gesture:
|
||
- In `touchstart`, first line inside the `for` loop: `if (dismissModal()) return;`
|
||
- In the `keydown` handler: any key dismisses.
|
||
- Guard `canAimServe()` with `!G.modal`.
|
||
|
||
`main.js` — `maybeRestart` must not fire through a modal.
|
||
|
||
`render.js` — a `drawModal()` drawn LAST (after the cards, before nothing). Spec:
|
||
- Full-screen scrim `rgba(8,6,10,0.82)`.
|
||
- Optional icon sprite centred above the title (used by the weapon popup).
|
||
- Title in the UI accent colour, body lines below, `TAP TO CONTINUE` blinking at the bottom.
|
||
- Use the pixel font (see item 3) — this is new text, so do not introduce more `monospace`.
|
||
|
||
**Acceptance:** with a modal open, `T.step(1)` × 60 must leave `zombies[0].z`, `ball.z` and
|
||
`G.splatT` unchanged; a synthetic `touchstart` clears `G.modal` and does NOT serve.
|
||
|
||
---
|
||
|
||
# 2. FOUNDATION: UI colour palette
|
||
|
||
**Requirement:** "Farbpalette etwas absoften (sehr starke rot und gelb töne in schrift und
|
||
interaktiven elementen)".
|
||
|
||
**Cause:** the hot colours are hard-coded as hex literals scattered through `render.js`.
|
||
`grep -o "#[0-9a-fA-F]\{6\}" render.js | sort | uniq -c` gives the inventory. The offenders:
|
||
|
||
| current | where | proposed softer |
|
||
|---|---|---|
|
||
| `#f8e048` | aim reticle, hints, NEW BEST, TAP TO PLAY AGAIN (5×) | `#e0cc72` |
|
||
| `#ff5050` | locked-target reticle, out-marker, SUDDEN DEATH card (3×) | `#d9705f` |
|
||
| `#e6ee3a` | selected rack slot, SET card (4×) | `#cbd06a` |
|
||
| `#e8f03a` | the ball itself (2×) | `#dbe063` |
|
||
| `#c02020` | GAME SET MASSACRE | `#a34a42` |
|
||
| `#e0231a` / `#eef23a` | intro logo CSS in `index.html` | see item 10 (logo art replaces it) |
|
||
|
||
**Do NOT** soften the gore reds (`PAL`/blood particles) — the splatter should stay loud;
|
||
only text and interactive UI were named.
|
||
|
||
**Build:** add a `UI` export to `config.js` next to `TUNE` (a plain object, not inside
|
||
`TUNE` — the T panel only renders numbers):
|
||
```js
|
||
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
|
||
// desaturated from the original arcade primaries: they were shouting over the artwork.
|
||
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
|
||
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
|
||
```
|
||
Then replace the literals in `render.js`. Keep one commit for the mechanical swap so a
|
||
revert is trivial.
|
||
|
||
**Acceptance:** screenshot before/after at mobile size; no remaining `#f8e048` / `#ff5050`
|
||
/ `#e6ee3a` / `#c02020` in `render.js`.
|
||
|
||
---
|
||
|
||
# 3. FOUNDATION: the serif font bug
|
||
|
||
**Requirement:** "Die Serifen-Schrift ist nicht schön. Die sieht so generisch aus."
|
||
|
||
**Cause — this is a real bug, not taste.** `index.html` bundles Silkscreen and `main.js`
|
||
warms it, but only 4 of 15 canvas font strings actually ask for it. Everything else says
|
||
`bold Npx monospace`, which resolves to Courier — a slab **serif**. Confirmed:
|
||
`grep -n "ctx.font = " render.js` → lines 329, 330, 560, 562, 566, 569, 572, 575, 588, 597,
|
||
603 all use bare `monospace`. Lines 314, 316, 534 correctly use `Silkscreen`.
|
||
|
||
**Build:** `render.js:534` already has the helper —
|
||
`const F = (sz, b = 1) => \`${b ? 700 : 400} ${sz}px Silkscreen, monospace\`;`
|
||
Hoist it to module scope (it currently lives inside the scoreboard block) and route **every**
|
||
`ctx.font =` through it. Silkscreen has no lowercase-friendly metrics at odd sizes; keep the
|
||
existing px sizes but expect slightly different widths — re-check the centred strings
|
||
(`GAME, SET, MASSACRE.` at 16px is the widest) still fit at 270 logical px wide (portrait).
|
||
|
||
Also fix the `#hint` line in `index.html` (`font: 12px monospace`) — same serif, and it is
|
||
visible under the canvas on desktop.
|
||
|
||
**Acceptance:** `grep -c "px monospace" render.js` returns 0 except inside `F`'s fallback
|
||
chain. Screenshot the eaten screen and a card in portrait; no serifs, nothing clipped.
|
||
|
||
---
|
||
|
||
# 4. BUG: weapon stock caps at 3
|
||
|
||
**Requirement:** "es können pro waffe immer nur drei gehalten werden. es sollten mehr waffen
|
||
angespart werden können."
|
||
|
||
**Cause:** `config.js:68` `wCap: 3` and `gameplay.js:336`
|
||
`const add = k => Math.min(TUNE.wCap, k);` inside `deliverTin()`.
|
||
|
||
**Build:** raise `wCap` to `9`. Check the rack renderer handles two-digit counts:
|
||
`render.js` draws `'×' + n` in the slot at 6px — at 270px wide with 5 slots each slot is
|
||
~50px, so `×9` is fine but verify `×12` if you go higher. Recommend 9 and stop.
|
||
|
||
While here: `deliverTin()` gives `TUNE.bombMax` (3) bombs but hard-codes `3` triples and
|
||
`3` flames and `1` gun. Pull those into `TUNE` (`tinTriples`, `tinFlames`, `tinGuns`) so the
|
||
economy is tunable from the panel like everything else.
|
||
|
||
**Acceptance:** `T.deliverTin()` four times in a row, then check
|
||
`T.G.bombs > 3`. Rack renders the count without overflowing its slot.
|
||
|
||
---
|
||
|
||
# 5. BUG: weapons arrive whether or not you win the duel
|
||
|
||
**Requirement:** "die waffen bekommt man unabhängig ob man das zombie 1:1 match gewonnen hat
|
||
oder nicht."
|
||
|
||
**Cause — there are two independent delivery paths and only one is gated on the duel:**
|
||
1. `resolveInterlude('P')` → `deliverTin()` (`gameplay.js:355`) — the reward for winning.
|
||
2. `splatPoint()` sets `G.tinDue` on a game schedule (`ballChangeFirst`/`ballChangeEvery`),
|
||
and `startChangeover()` (`gameplay.js:310`) calls `deliverTin()` when `tinDue` — with no
|
||
reference to any duel. This is the leak.
|
||
|
||
Note path 2 has a partial gate already: `G.shameGame` withholds the tin if *they* took a
|
||
game since the last delivery. That is not the same as winning the duel.
|
||
|
||
**The design decision (Oliver, 2026-07-27):** duels must come round on their own after a
|
||
while, "damit es immer die chance auf waffen gibt". So the schedule is not the problem — it
|
||
is attached to the wrong event. **Move it from delivering tins to calling duels.** The
|
||
cadence stays, the reward becomes something you have to earn, and the umpire-restores-order
|
||
framing is unchanged.
|
||
|
||
**Why the duel is rare today:** the trigger at `gameplay.js:~760` requires
|
||
`zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled` — the court has to
|
||
be completely empty apart from one never-yet-dueled racket zombie. Racket zombies only appear
|
||
when you win a game (`splatPoint`) or survive a horde (`endHorde`), and fresh shamblers spawn
|
||
again the moment a clear finishes. So it fires by luck.
|
||
|
||
**Build:**
|
||
- Rename the schedule to what it now schedules: `TUNE.ballChangeFirst`/`ballChangeEvery` →
|
||
`duelFirst`/`duelEvery`, `G.tinDue` → `G.duelDue`. Keep the same arithmetic in
|
||
`splatPoint()`. Suggested start: first duel after game 2, then every 3.
|
||
- Delete the `deliverTin()` call in `startChangeover()` (keep the "CHANGEOVER" flash).
|
||
`G.shameGame` — which withheld a tin if they took a game — has nothing left to gate;
|
||
remove it rather than leaving vestigial state.
|
||
- Keep `resolveInterlude('P') → deliverTin()` as the only weapon source.
|
||
- **Fire the scheduled duel off the existing clear beat.** `proceedAfterClear()`
|
||
(`gameplay.js:290`) already runs at the one moment the court is guaranteed empty — the
|
||
player just cleared the wave by hand, and no new shamblers have spawned yet. That is the
|
||
natural slot. There: if `G.duelDue`, consume it, ensure a racket zombie exists
|
||
(`spawnRacketZombie()` if none, and clear `dueled` on the existing one so a veteran can be
|
||
challenged again), and start the interlude instead of the changeover/game beat. The
|
||
changeover follows after the duel resolves.
|
||
- Leave the existing opportunistic trigger in place. The schedule is a floor, not a ceiling —
|
||
a lucky clear can still produce a bonus duel, which is pure upside for weapon supply.
|
||
|
||
**Why this slot and not a timer:** clearing the wave is already mandatory before play
|
||
advances (`G.clearing`, a settled feel decision Oliver was emphatic about), so the schedule
|
||
can never stall. A wall-clock timer would need its own "wait for a safe moment" logic and
|
||
could fire mid-swarm.
|
||
|
||
**Acceptance:**
|
||
- Simulate ~10 games. A duel must occur at game 2, 5, 8 … regardless of how the waves fall.
|
||
- Lose a duel → `G.bombs`/`triples`/`flames`/`guns` unchanged, and the next duel still
|
||
arrives on schedule.
|
||
- Win one → exactly one tin, and the item-8 modal names it.
|
||
- No path reaches `deliverTin()` except `resolveInterlude('P')`
|
||
(`grep -n "deliverTin" *.js` should show the definition, that one call, and the `window.T`
|
||
test export — nothing else).
|
||
|
||
---
|
||
|
||
# 6. POPUP: entering splatter mode from tennis
|
||
|
||
**Requirement:** popup with an **illustration** explaining the new mechanic, stays until
|
||
tapped.
|
||
|
||
**Trigger:** `gameplay.js:410`, inside `startTransition()`'s timeline —
|
||
`once('go', 8.2, () => { G.mode = 'splatter'; ... })`. Fire the modal there, after
|
||
`setupPoint()`.
|
||
|
||
**Content:**
|
||
- Title: `THE MATCH HAS CHANGED`
|
||
- Body: pull back to aim · the further you pull, the harder it hits · a weak shot only
|
||
staggers them
|
||
- Icon: a generated illustration (see Appendix A). Ask for a pixel-art panel showing the
|
||
player pulling the slingshot aim back with a chevron trail toward a shambler — it should
|
||
teach the gesture, not just decorate.
|
||
|
||
Because this is the mechanic tutorial, it is worth the biggest illustration of the set.
|
||
Consider a wider modal layout for this one (`icon` sized ~120×80 logical px).
|
||
|
||
**Acceptance:** enter splatter; the world is frozen and no zombie moves until tapped.
|
||
|
||
---
|
||
|
||
# 7. POPUP: entering the 1:1 duel (first time)
|
||
|
||
**Requirement:** popup saying it is a normal tennis game again and that winning earns a
|
||
weapon. "momentan gibt es schon ein popup aber auf dem steht auch anderer unnötiger kram."
|
||
|
||
**Cause:** `render.js:593` draws
|
||
`drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050')`
|
||
on a 2.4s timer (`G.interlude.introT`, set at `gameplay.js:764`). It is not dismissible, it
|
||
disappears on its own, and "THE LINES ARE LIVE" is the unnecessary cruft.
|
||
|
||
**Build:** replace the timed card with a real modal, gated on `G.seen.duel`:
|
||
- Title: `SUDDEN DEATH`
|
||
- Body: `IT'S TENNIS AGAIN — ONE POINT` / `WIN IT AND THEY HAND YOU A WEAPON`
|
||
- On dismiss: start the point.
|
||
- Delete the `introT` card path in `render.js` and the `introT` countdown in `update()` if
|
||
the modal fully replaces the beat. Keep `G.interlude.introT` only if the umpire voice line
|
||
still needs the pause — check `voice('sudden_death', 2.4)` timing at `gameplay.js:~766`.
|
||
- Subsequent duels in the same run: no modal, keep a short flash.
|
||
|
||
**Acceptance:** first duel opens a modal that blocks serving; second duel does not.
|
||
|
||
---
|
||
|
||
# 8. POPUP: weapon received
|
||
|
||
**Requirement:** shows and describes the weapon; game continues only after tapping.
|
||
|
||
**Trigger:** inside `deliverTin()` (`gameplay.js:335`), replacing the
|
||
`G.flash = { text: 'NEW BALLS: ' + SLOTS[w+1] }` line.
|
||
|
||
**Content:** icon = the existing rack sprite (`w_bomb` / `w_triple` / `w_fire` / `w_gun` —
|
||
already loaded in `render.js`'s `SPR`), title = the weapon name, one line of description:
|
||
|
||
| slot | name | line |
|
||
|---|---|---|
|
||
| 1 | BOMB | detonates on contact — clears a crowd |
|
||
| 2 | TRIPLE | one serve, three balls |
|
||
| 3 | FIRE | kills on any touch and **sets the others alight** (item 12) |
|
||
| 4 | GUN | hold the line and spray — a burst, not a serve |
|
||
|
||
**Careful:** `deliverTin()` is also called from `resolveInterlude()` which is itself called
|
||
from `killZombie()` mid-iteration over `zombies[]` (`zombies.js:51`). Opening a modal there
|
||
is safe (it only sets state) but do NOT make `showModal` do anything that touches the
|
||
zombie array.
|
||
|
||
**Acceptance:** win a duel → modal with the right icon; play is frozen until tapped.
|
||
|
||
---
|
||
|
||
# 9. POPUP: the horde after a won set
|
||
|
||
**Requirement:** popup explaining you now have to shoot with the gun; blocks until tapped.
|
||
|
||
**Trigger:** `proceedAfterClear()` sets `G.pendingHorde` and shows the SET card
|
||
(`gameplay.js:290-302`); the horde starts when the `overT` beat expires (in `update()`'s
|
||
`over` branch → `startHorde()`).
|
||
|
||
**Build:** keep the SET card (it is the milestone beat and Oliver asked for it originally —
|
||
see the memory note on milestone cards), then show the modal instead of auto-starting:
|
||
- Title: `THEY KEEP COMING`
|
||
- Body: `THE RACKET FIRES ON ITS OWN` / `SLIDE TO AIM · HOLD THE BASELINE`
|
||
- On dismiss: `startHorde()`.
|
||
|
||
Note `startHorde()` already pins `P.z` to the baseline and switches input to absolute
|
||
finger-steering (`touchPX()`), which is a genuinely different control mode — that is exactly
|
||
why the popup is warranted.
|
||
|
||
**Acceptance:** win a set → SET card → modal → horde only begins after the tap.
|
||
|
||
---
|
||
|
||
# 10. Start-screen logo
|
||
|
||
**Requirement:** "ein schöneres Splatter Tennis Logo für den Startscreen generieren."
|
||
|
||
**Cause:** the current logo is CSS text (`index.html`, `#intro .l1` / `.l2`) — a hard red
|
||
`#e0231a` over hard yellow `#eef23a` with stacked text-shadows. Generic.
|
||
|
||
**Build:** generate a pixel-art logo PNG (Appendix A), drop it at `assets/logo.png`, replace
|
||
the two `<span>`s with `<img>`. Keep the `drop` animation and the blinking `TAP TO START`.
|
||
Prompt direction: bold pixel lettering, tennis-ball yellow-green and dried-blood red, a
|
||
dripping-gore underline, transparent background, roughly 640×280.
|
||
|
||
Soften the surrounding CSS colours to match item 2 while you are in there.
|
||
|
||
**Acceptance:** screenshot the intro at mobile and desktop; the logo must not overflow
|
||
`max-width: 92vw` and must stay crisp (`image-rendering: pixelated` on the `img`).
|
||
|
||
---
|
||
|
||
# 11. The brute takes 5 hits, with more wound states
|
||
|
||
**Requirement:** big zombie harder to kill (5 hits), correspondingly more wound states,
|
||
sprites to be generated.
|
||
|
||
**Cause:** `config.js:80` `bruteHp: 2`. `render.js:160` has exactly one wound skin:
|
||
`if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw';`
|
||
|
||
**Build:**
|
||
- `bruteHp: 5`.
|
||
- Generate `z_gw2`, `z_gw3`, `z_gw4` (Appendix A) as a progression: 4/5 hp = one arm torn
|
||
off (that is today's `z_gw`), 3/5 = both arms, 2/5 = jaw/half the head gone, 1/5 = barely
|
||
standing, dragging itself. Register them in `render.js`'s `SPR` preload list (line ~75).
|
||
- Select by hp:
|
||
```js
|
||
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp)
|
||
name = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, zb.hp - 1)] || 'z_gw';
|
||
```
|
||
(guard each with an `SPR[...]` existence check like the current code does, so a missing
|
||
asset degrades to the walk frame instead of drawing nothing.)
|
||
- `bleed()` (`zombies.js:158`) picks wound anchors per variant — extend the brute branch so
|
||
later stages bleed from more points. Keep it sparse; the settled feel note is explicit
|
||
that blood must stay thin (see `HANDOFF.md` and the feel memory).
|
||
|
||
**Balance check:** the brute already soaks machine-gun pellets one per hit
|
||
(`zombies.js` pellet loop). At 5 hp it costs 5 pellets — fine during a horde, but verify a
|
||
brute arriving during normal play is still killable with the ~1.4s serve cycle before it
|
||
reaches the baseline. Measure `time-to-kill vs time-to-baseline`; if it is unwinnable, say
|
||
so rather than quietly lowering the hp Oliver asked for.
|
||
|
||
---
|
||
|
||
# 12. Fire spreads
|
||
|
||
**Requirement:** the fireball kills but **ignites** surrounding zombies; a burning zombie
|
||
that walks near another sets that one alight too; needs a dynamic fire animation.
|
||
|
||
**Cause:** today `ball.flame` (`zombies.js:245`) is instant-kill-on-touch with full pierce,
|
||
and nothing else. The only fire visual is the ball's ember trail
|
||
(`gameplay.js` pushes `parts` with `fire: true`; drawn at `render.js:496`).
|
||
|
||
**Build — new state on the zombie:**
|
||
```js
|
||
zb.burnT // seconds of burning left; undefined = not on fire
|
||
```
|
||
|
||
`config.js`:
|
||
```js
|
||
burnTime: 3.0, // s a zombie burns before it collapses
|
||
burnDps: 1.0, // hp lost per second while burning (so burnTime*burnDps must exceed hp)
|
||
burnSpread: 1.3, // m — a burning body ignites anything this close
|
||
burnIgniteR: 2.6, // m — the flame ball's initial ignition radius around the kill
|
||
```
|
||
|
||
`zombies.js`:
|
||
- On a flame-ball contact: kill the struck zombie as now, then ignite every zombie within
|
||
`burnIgniteR` (`zb.burnT = TUNE.burnTime`).
|
||
- In `updateZombies`'s per-zombie loop: if `burnT > 0`, decrement it, apply `burnDps * dt`
|
||
damage (kill via the existing `killZombie` path so gore/scoring/ball-boy all still fire),
|
||
emit 1–2 `fire: true` particles per frame from the body, and ignite any un-burning zombie
|
||
within `burnSpread`.
|
||
- **Chain-reaction hazard:** `killZombie` splices the array and `splatPoint` can cascade.
|
||
The existing loops guard with `if (!zb) continue;` — the ignition pass must do the same and
|
||
must iterate backwards. `HANDOFF.md` calls this out explicitly; a fire chain is the worst
|
||
case for it, so write the loop defensively and test with 15 zombies packed together.
|
||
- Burning zombies should walk **faster** briefly (panic) — a nice touch and it makes fire a
|
||
double-edged weapon. Optional; flag it to Oliver.
|
||
|
||
`render.js` — the dynamic animation:
|
||
- Draw flames on a burning body procedurally rather than as sprites: 3–5 vertically-stretched
|
||
flame quads per zombie, offsets driven by `Math.sin(G.idleT * f + zb.walkT)` so each body
|
||
flickers on its own phase, colours `#ffc030` → `#ff8010` → dark smoke as `burnT` runs out.
|
||
This is cheaper than a sprite sheet and reads better at 4 different burn stages.
|
||
- Tint the zombie sprite toward orange while burning (`ctx.globalAlpha` + a second
|
||
additive-ish pass, or just draw the sprite then a semi-transparent orange rect clipped to
|
||
it — keep it simple at this resolution).
|
||
|
||
**Acceptance:** a flame serve into a line of 6 packed shamblers ignites the neighbours, the
|
||
fire visibly chains outward, and all 6 die within `burnTime`. No exception thrown (this is
|
||
the loop-mutation trap). Frame time stays sane with 14 burning bodies.
|
||
|
||
---
|
||
|
||
# 13. Death animation — the player becomes gore
|
||
|
||
**Requirement:** "Wenn man gefressen wird braucht es eine Endanimation, die zeigt dass man
|
||
selbst zu gore wird."
|
||
|
||
**Cause:** `zombies.js:229` flips `G.mode = 'eaten'` and `render.js:506` simply stops
|
||
drawing the player (`if (G.mode !== 'eaten') drawChar(P)`). There is already a small burst
|
||
(30 particles + one gore decal) at the eat site, but the summary screen appears immediately
|
||
on top of it.
|
||
|
||
**Build:**
|
||
- `G.eatenT` already counts up from 0 (`state.js`). Use it as the animation clock.
|
||
- Phase A (0 → ~0.5s): keep drawing the player, but jerking — a violent shake offset and a
|
||
reddening tint, with the zombies crowding in. Kill the ball, freeze the score.
|
||
- Phase B (~0.5 → 1.4s): the sprite is replaced by an expanding gore burst — reuse
|
||
`killZombie`'s machinery (it is the established look): a big `gore` stain, a size-4 `piles`
|
||
entry at `P.x/P.z`, and ~90 particles with a wide `vy` spread. Add `G.shake` and one
|
||
`boomFlash` frame for the impact.
|
||
- Phase C (from ~1.6s): the existing summary fades in (`render.js:559`). Gate
|
||
`TAP TO PLAY AGAIN` on `G.eatenT > 2.2` instead of `0.7` so the animation is not skipped
|
||
by a panic tap.
|
||
- The pile stays visible under the summary scrim — that is the point: you are the biggest
|
||
gore mound on the court.
|
||
|
||
**Acceptance:** get eaten; screenshot at eatenT ≈ 0.3 / 0.9 / 2.0 and confirm three distinct
|
||
stages. Restart still works.
|
||
|
||
---
|
||
|
||
# 14. Longer changeover, and the ball boys get a mop
|
||
|
||
**Requirement:** "changeover sollte etwas länger dauern. die balljungen, die die reinigung
|
||
machen brauchen einen mop."
|
||
|
||
**Cause:** `config.js:33` `changeoverT: 4.5`. `drawBoy()` (`render.js:136`) uses
|
||
`x_boyrun1..4` — a plain 4-frame run cycle, no prop.
|
||
|
||
**Build:**
|
||
- `changeoverT: 7.0`. The changeover already suppresses spawns and serving
|
||
(`zombies.js` spawn gate, `keyPressed` guard), so a longer one is a real breather. Check
|
||
it does not feel dead — if it does, have more boys cross.
|
||
- Generate `x_boymop1..4` (Appendix A): the same boy, bent over, pushing a mop, 4 frames of
|
||
a push cycle.
|
||
- `drawBoy()` picks the sheet by intent. Today `sendBoy()` is used for BOTH "fetch the dead
|
||
ball" and "mop this splat" (`state.js:95`, called from `killZombie` with the corpse
|
||
position). Add a flag: `sendBoy(tx, tz, mop = false)`, set `mop: true` from `killZombie`
|
||
and from `startChangeover`, and select `x_boymop*` vs `x_boyrun*` accordingly. The mopping
|
||
boy should also move slower — mopping is not sprinting.
|
||
- The gore-cleaning already happens in `updateZombies`'s boy pass (`d.a -= dt * 1.6`); with a
|
||
mop sprite that finally reads as *why* the stain fades.
|
||
|
||
**Acceptance:** trigger a changeover; the boy crosses with the mop sprite, stains visibly
|
||
shrink behind him, and the breather runs 7s.
|
||
|
||
---
|
||
|
||
# Appendix A — generating sprites and illustrations
|
||
|
||
The pipeline is documented in `HANDOFF.md` under "Gotchas learned the hard way"; the short
|
||
version:
|
||
|
||
1. Use the `generate-image` skill. It needs `OPENROUTER_API_KEY`, which is already in the
|
||
repo's gitignored `.env`. Run generation scripts with a scratchpad venv python
|
||
(system python lacks `requests`); the venv is wiped between sessions, so recreate it with
|
||
`requests` + `pillow`.
|
||
2. Ask for a **magenta background** and, for multi-frame sheets, frames in a row separated by
|
||
clear gaps.
|
||
3. Slice with the committed `assets/_slice.py`:
|
||
`python3 assets/_slice.py sheet.png out1.png out2.png ...` (add `--largest` when frames
|
||
touch and stray fragments bridge the gap).
|
||
4. Register new names in `render.js`'s `SPR` preload array (~line 71).
|
||
|
||
Known generator quirks (all from `HANDOFF.md`, all still true):
|
||
- It sometimes ignores the magenta instruction — harmless, the key uses the corner colour.
|
||
- It often draws near-identical animation frames. Re-prompt with strongly contrasted pose
|
||
descriptions.
|
||
- Passing the existing sheet as `--input` (edit mode) is the reliable way to match style.
|
||
**Do this for every asset here** — `_sheet_brute.png` for the brute wounds,
|
||
`_sheet_boyrun.png` for the mop boy — the style must match what is already on court.
|
||
|
||
Assets this plan needs:
|
||
|
||
| file(s) | source sheet to match | item |
|
||
|---|---|---|
|
||
| `z_gw2`, `z_gw3`, `z_gw4` | `_sheet_brute.png` / `_sheet_gwound.png` | 11 |
|
||
| `x_boymop1..4` | `_sheet_boyrun.png` | 14 |
|
||
| `assets/logo.png` | — (new, standalone) | 10 |
|
||
| transition tutorial illustration | — (new, standalone) | 6 |
|
||
|
||
The two popup illustrations and the logo are **not** chroma-keyed sprites — generate them
|
||
with transparent or dark backgrounds sized for their use and skip `_slice.py`.
|
||
|
||
---
|
||
|
||
# Appendix B — things to check before declaring done
|
||
|
||
- `HANDOFF.md`'s benchmarks must still hold: CPU returns 11/12 serves; 30 simulated points
|
||
with human-like tapping = 50 hits / 0 whiffs. Items 2, 3 and 11 can all disturb these.
|
||
- The settled feel decisions are recorded and must not be regressed silently: the two-stage
|
||
serve, the manual wave clear, thin dynamic blood, one-gesture-means-one-thing on touch.
|
||
If any item here would break one, flag it to Oliver instead of deciding.
|
||
- Update `HANDOFF.md` with anything new that bit you.
|
||
- Bump `main.js?v=N` in the final commit so phones get the build.
|