Add POLISH-PLAN.md — implementation plan for the polish + bugfix round
Fourteen items from Oliver's phone-test list, written so a fresh session can execute them. Every root cause was verified against the code rather than guessed; the two "bugs" both turned out to be real and both have a named line: wCap 3 caps the stock, and weapons leak in through a SECOND delivery path (splatPoint's tinDue -> startChangeover) that has nothing to do with the duel. The serif complaint is also a bug, not taste: only 4 of 15 canvas font strings ask for Silkscreen, the rest say bare "monospace" and land on Courier. Ordered so the three foundations come first — the modal system (five separate popup requirements need the same input-gated pause, which does not exist yet), the UI colour constants, and the font fix — since the later items draw through all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c8990fe900
commit
64b297ce54
1 file changed
+501
+501
@@ -0,0 +1,501 @@
|
||||
# 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.
|
||||
|
||||
**Build:** make winning the duel the ONLY source of weapons.
|
||||
- Delete the `tinDue` scheduling in `splatPoint()` and the `deliverTin()` call in
|
||||
`startChangeover()`. Keep the "CHANGEOVER" flash. `G.tinDue` / `TUNE.ballChangeFirst` /
|
||||
`ballChangeEvery` / `G.shameGame` all become dead — remove them rather than leaving
|
||||
vestigial state.
|
||||
- Keep `resolveInterlude('P') → deliverTin()`.
|
||||
|
||||
**Consequence to check:** duels only trigger when the court is clear and a racket zombie is
|
||||
the last one standing (`gameplay.js:~760`). If that turns out to be rare, weapons become
|
||||
near-unobtainable. **Measure before shipping:** run a 5-minute simulated session and count
|
||||
duels. If it's under ~1 per two games, tell Oliver rather than silently re-adding a second
|
||||
source — the design question (how often should duels happen?) is his call.
|
||||
|
||||
**Acceptance:** simulate a full game where the player loses the duel; `G.bombs` etc. must be
|
||||
unchanged. Win one; exactly one tin arrives.
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
Reference in new issue
Block a user