- The crowd sat in the dark, worst of all in splatter mode where you spend the game. Both crowd frames get an identical gamma lift (means 30/14/39 -> 42/22/56) and the rot tint drops 0.5 -> 0.26. The curve MUST stay identical across the two frames or the clap swap flashes brightness; _sheet_crowd1.png keeps the pre-lift original. - The splatter hint led with the tap when pulling is the whole gesture. It now reads PULL BACK TO AIM - RELEASE TO FIRE, and after 1.5s of sitting in the serve stance untouched a ghost thumb slides back while chevrons grow up-court on a loop. Pull back, shoot forward is an inverse relationship that a line of text cannot carry on its own. - The mop boy has three sheets instead of two: the push cycle now plays ONLY while he is stopped and working, a new carry sheet walks him upright with the mop shouldered, and the run sheet stays for the tennis-mode ball fetch. He was mopping thin air the entire way to the stain. Sheet note: frames 2 and 3 of the carry sheet merged (a mop handle bridging the gap), so it is cut at the true column gaps plus the thinnest column inside the merged pair, then common-scaled — same technique as the brute stride sheet. 26-game soak clean. Serve returns 133/150 = 88.7% +/-2.6%, 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
23 KiB
Markdown
282 lines
23 KiB
Markdown
# HANDOFF — Splatter Tennis (for the next Claude session)
|
||
|
||
Read this first, then `~/.claude/projects/.../memory/splatter-tennis-history.md` (auto-loaded)
|
||
for the long history. This file is the precise mid-task state as of 2026-07-11.
|
||
|
||
## What this is
|
||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter
|
||
game while keeping all tennis aesthetics (umpire voice, 15/30/40 scoring, ball boys, applause).
|
||
Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play
|
||
is the primary target (tap = swing/serve, flick = aimed shot, auto-run assist does positioning).
|
||
|
||
- **Run locally**: `preview_start` name `splatter-tennis` (python http.server :8641, .claude/launch.json)
|
||
- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook
|
||
auto-builds the Dockerfile (nginx, everything served no-cache). Oliver tests on his phone at
|
||
splatter-tennis.wuwi.one within ~30s of a push.
|
||
- **Oliver edits files himself** (tuning values, sometimes sprites/sheets). ALWAYS Read current
|
||
file state before editing; never assume your last version is what's on disk.
|
||
|
||
## Module map (all in repo root)
|
||
`config.js` TUNE (live panel, T key) + constants · `state.js` G object + P/O/ball/score + setupPoint
|
||
· `gameplay.js` ballistics/swings/serve/rules/AI/update + transition + splatPoint · `zombies.js`
|
||
zombie/gore/particle state + updateZombies · `render.js` camera/palette-lerp/sprites/drawing
|
||
· `input.js` keys/touch/flick · `audio.js` sfx synth + recorded umpire (assets/audio/*.m4a,
|
||
made with macOS `say -v Daniel`) · `panel.js` · `main.js` boot, loop, umpire-tap cheat, window.T.
|
||
Two runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay.
|
||
|
||
## Zombie mode — how it works today
|
||
- Trigger: win your first game in tennis mode, OR **triple-tap the umpire** (x=7.1, netZ) within
|
||
1.6s (cheat; listener in main.js → `forceZombies()` in gameplay.js).
|
||
- Transition (`G.mode='transition'`, timeline in gameplay.update via `G.transT`): applause cut,
|
||
palette lerp (PAL in render.js), umpire "Quiet… please" at low playbackRate (voice() has a
|
||
`rate` param), CPU opponent swapped for zombie-CPU (variant 'c'), scripted first spawns,
|
||
then `G.mode='splatter'`.
|
||
- Splatter: you serve at zombies; every ball carries `ball.pow` (= shot quality); hit with
|
||
pow >= TUNE.zSplatQ → splatter (gore decal + particles + splat() sound + splatPoint('P')),
|
||
else stagger/pushback and the ball is spent (`lastHit='X'`). Zombie crossing your baseline →
|
||
splatPoint('O'). Tennis scoring machinery keeps running (deuce, advantage, "Game, player").
|
||
Ball boys mop gore they pass. HUD shows THEM instead of CPU.
|
||
- Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback.
|
||
|
||
## STATUS — plan v2 COMPLETE + three feedback rounds (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), game-end sweep + 'GAME N' theater, an always-there
|
||
racket-zombie opponent, and the SUDDEN DEATH interlude (intro card, tennis-palette
|
||
lerp while the duel runs, real line calls, win = tin on the spot, lose = surge).
|
||
Post-plan rounds added: TWO-STAGE serve aiming (touch = reticle + auto-lock, drag =
|
||
aim, release = fire, drag-back = cancel; aimed serves commit specials, taps keep them),
|
||
the RAF-freeze fix (sweepCourt guts zombies[] under iterating loops → null guards
|
||
everywhere a kill can cascade + RAF rescheduled before update), instant absolute
|
||
machine-gun steering clamped to the visible court, RES 3 + flatter lens (camZ 42,
|
||
camF per orientation in fitViewport), 4-frame walk cycles (z_a/b/f 1–4), tiered gore
|
||
(GORE_MUL in zombies.js), generated stadium (x_crowd.png bounce-columns, x_ads.png
|
||
sponsor boards, umpire at UMP_X 5.6 — 7.1 was one pixel off-screen in portrait),
|
||
runner = glass cannon (any touch kills; runnerVMax cap), battery pass (TUNE.fps knob
|
||
default 60 — 30 was vetoed on feel; cached pile canvases). PLAN.md records all
|
||
decisions + next-arc candidates. 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. **Serve cadence — verified.** Dead splatter ball → 15 frames (0.25s) to next pretoss;
|
||
setupPoint leaves P where he stands; FLICK in pretoss fires an instant quick serve
|
||
(no toss, pow 0.62, aimed), plain TAP still does the toss ritual.
|
||
2. **Aim anywhere — verified.** Flick up targeted a far-side zombie (marker z=5.5 for a
|
||
zombie at z=5, over the net), flick down a near-side one (z=18.1). If the lane system
|
||
still fights Oliver, next step is true free-aim (flick → landing point, splash radius).
|
||
3. **Speed ramp — done.** G.splatT ticks in updateZombies (splatter only); spawnZombie
|
||
multiplies speed by `1 + min(1.5, splatT/70)`. Verified 0.76 m/s fresh vs ~1.5 at splatT=140.
|
||
Spawn interval / zMax do NOT ramp yet — add if Oliver wants more pressure.
|
||
4. **Eaten — done.** updateZombies: zombie within 0.7m of P (splatter only) → gore + 30
|
||
particles + splat(1) + voice('game_set_massacre') + mode 'eaten', ball dead, flash cleared.
|
||
gameplay.update early-exits in 'eaten' (ticks idleT/eatenT, still updates zombies —
|
||
they shamble on; baseline crossings no longer score). render: P not drawn, dark overlay
|
||
EATEN. / games score / TAP TO PLAY AGAIN (restart prompt appears after G.eatenT > 0.7 so
|
||
death-spam taps don't skip the screen). main.js: tap/key while eaten → location.reload().
|
||
|
||
ALSO: tennis volley fix (Oliver's follow-up "impossible to volley when the marker is behind
|
||
me"): pre-bounce player contact widens reach x1.4 and swing window x1.8 (VOLLEY in config.js,
|
||
applied in trySwing + the expiry in update). Root cause was no hard block, just a ~0.28s
|
||
no-cue timing window at full ball pace; now ~0.5s. doHit also clears G.wasServe so volleying
|
||
a serve back doesn't fault-check the return. CPU still returns 11/12 serves (benchmark holds).
|
||
|
||
## Verification recipe (the regression net — use it after the above)
|
||
Drive the game deterministically via `window.T` with javascript_tool: the Browser-pane eval
|
||
context reports `document.visibilityState==='hidden'`, so RAF is paused and `T.step(1/60)`
|
||
gives clean manual stepping. `T.press('Space')` = tap. Dismiss the intro overlay first:
|
||
`document.getElementById('intro')?.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true}))`.
|
||
- Tennis regression: serve → CPU returns most balls → points/games advance.
|
||
- Splatter: `T.G.mode` / force via 3 synthetic pointerdown on the umpire's projected position
|
||
(see main.js listener; coords = proj(7.1,0,11.885), remember logical = cv.width/2 because RES=2).
|
||
`T.zombies`, `T.spawnZombie(x, z, 'a')`, TUNE.zMax=0 to stop spawns during tests.
|
||
- Prior benchmarks that must keep holding: CPU returns ~11/12 serves; assist gets the idle
|
||
player in reach 8/8; constructed perfect corner (s=28, g×1.9) is a WINNER, medium center
|
||
ball is returned (run scenarios ~12x — single runs are flaky due to 9% aiFlub).
|
||
|
||
## Tennis feel — CONFIRMED GOOD by Oliver (2026-07-11), don't regress
|
||
- Volleys: pre-bounce contact gets reach x1.4 / window x1.8 (VOLLEY in config.js).
|
||
- Half-volleys: a RISING ball is hittable at any height; only a dropping ball below
|
||
CONTACT.minY is "past you" (trySwing). This was the big one — the assist parks you on
|
||
the landing spot, so feet-bounces were unhittable before.
|
||
- Miss diagnostics: every tennis swing logs to T.G.missLog (ring of 50); last miss reason
|
||
flashes on the HUD (TUNE.missHud); TAPPING THE SCOREBOARD toggles an on-screen dump of
|
||
the last 9 entries — Oliver screenshots that from his phone to report live situations.
|
||
- Benchmarks that must keep holding: CPU returns 11/12 serves; 30 simulated points with
|
||
human-like tapping (tap when incoming ball d<2.2–3.2) = 50 hits 0 whiffs; feet-bounce
|
||
tap sweep ≈ 19/30 winning frames.
|
||
|
||
## POLISH ROUND — all 14 items of POLISH-PLAN.md are DONE (2026-07-27)
|
||
New foundations you must know before touching anything:
|
||
- **`G.modal`** (state.js) is the only thing that stops the world: `update()` early-returns on it
|
||
after the flash/card decay, so no zombie steps, no ball flight, no spawn timers — only `idleT`
|
||
ticks (for the blink). `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing
|
||
render() draws. Dismissal must SWALLOW the gesture: input.js `touchstart` returns on it, keydown
|
||
returns on it, and a `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch,
|
||
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
|
||
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
|
||
- **`UI` in config.js** is the accent palette (hot/warm/pick/cream/dim/bad/ball). Every HUD colour
|
||
goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.
|
||
- **`F(size, bold)` in render.js** builds every canvas font string. Bare `monospace` resolves to
|
||
Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never write `ctx.font =` by
|
||
hand. Widest string checked: `SERVE FIRES: … TAP THE RACK TO SWITCH` at 259px of 270 portrait.
|
||
- **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`.
|
||
The old game schedule now calls the DUEL instead (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`),
|
||
fired from `proceedAfterClear()` — the one beat where the court is guaranteed empty. The beat it
|
||
interrupts is parked in `G.afterDuel` and resumed when the duel resolves. `G.shameGame` is gone.
|
||
- **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside
|
||
a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST `continue`.
|
||
Flames are drawn procedurally (`drawFlames`), no sprite sheet.
|
||
- **Sprite heights follow the PNG now.** `drawBoy` and the brute wound stages scale by
|
||
`img.height / SPRITE_H`, because `_slice.py` common-scales a sheet: a deep mopping lean and a
|
||
headless brute are genuinely shorter and were being stretched back to full height.
|
||
- Benchmarks re-run and holding: CPU returns 23/24 serves; 30 human-tapped points = **50 hits /
|
||
0 whiffs** — but you MUST set `T.TUNE.autoAssist = 1.15` first, the Browser pane reports
|
||
`pointer: fine` so the assist is off and every ball reads as "never reachable".
|
||
- Brute is 5 hp: worst-case (max ramp) it still needs 22.5s to cross, vs ~7s of serves to kill.
|
||
|
||
## Feedback round on the polish (2026-07-27, same day)
|
||
- **Fire never spread in real play.** The flame ball has full pierce and instant-kill, so it wiped
|
||
its own lane and left nobody to catch. Fixed by `TUNE.burnBallR` — the ball ignites everything it
|
||
FLIES PAST, every frame, whether or not it hits — plus wider `burnIgniteR` 4.5 / `burnSpread` 2.2.
|
||
One flame serve into a realistic scattered 10-wave now kills 9, with 9 burning at once.
|
||
**When testing fire, fire a real ARC**: a flat ball from z=16 hits the net at y≈0.83 (netH 0.914)
|
||
and bounces back at you — the first "fire is broken" reading was my test hitting the tape.
|
||
- **Wounded brutes need their own animation.** Every wound stage is a single frame and the brute
|
||
now spends most of its life wounded, so it slid along like a decal. Rather than 8 more sprites
|
||
they get a procedural lumber in `drawZombie` (translate + squash, no rotation, so pixels stay on
|
||
the grid). Amplitudes MUST scale with `p.s` — at a fixed fraction the whole lurch was sub-pixel
|
||
at range and read as nothing moving.
|
||
- **The scoreboard "flicker" was the crowd.** The cheer bounce was `-Math.random()*2` per column
|
||
per frame, i.e. TV static, showing through the scoreboard art's transparent ornate edges. Now a
|
||
smooth per-column sine on `idleT`. Test for it: render twice WITHOUT stepping — identical pixels
|
||
means nothing in the draw path is rolling dice per frame.
|
||
- Ball boys: `sendBoy`'s `mop` now DEFAULTS to `G.mode === 'splatter'`. The running fetch-boy still
|
||
exists, but only in tennis mode where there is nothing on the grass but a ball. `BOY_H` 1.2 → 1.5.
|
||
- `TUNE.burnSpread` is a T-panel knob if Oliver wants fire hotter still.
|
||
|
||
## Second feedback round — "do it properly" (2026-07-27)
|
||
Oliver rejected the procedural fakes on sight. The rule this round: **animation gets drawn frames.**
|
||
- **Every brute wound stage now has a 2-frame lurch** (`z_gw`/`z_gwb`, `z_gw2`/`z_gw2b`, …). The
|
||
procedural squash-and-sway that replaced them read as fake immediately — "that's where the comedy
|
||
comes from". Generated by handing the generator a composite of the four A-frames
|
||
(`assets/_sheet_gwound_a.png`, built with PIL) and asking for the stride swapped.
|
||
**Gotcha:** the generator gave every B frame a dragged pole, but only stage 1's A frame has one —
|
||
it would have blinked in and out each step. Stripped by keeping the largest connected blob
|
||
(stages 2–3) and, where the pole touched the body (stage 4), by cutting on column density: the
|
||
torso is dense, a dragged pole is a thin diagonal of ~7 px per column.
|
||
- **Frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys,
|
||
`zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk whenever speed and frame rate
|
||
disagree, which is exactly what "he slides faster than the animation suggests" was.
|
||
Height for a wound stage ALWAYS comes from the A frame or the sprite pops size every step.
|
||
- **One boy, one slot.** `sendBoy` no longer replaces a boy who is already out — that was the
|
||
"appears and disappears": every kill teleported him back to the wing, so a kill streak restarted
|
||
him several times a second. New work is appended to his round (`jobs`). One crossing is now 10.5s.
|
||
- **The applauding crowd is a real two-frame animation** (`x_crowd` seated / `x_crowd2` arms up),
|
||
swapped per column on its own phase. The previous "smooth wave" still shifted each column by a
|
||
FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a fractional y re-snaps its
|
||
pixel rows every frame — that boiling beside the HUD was the "scoreboard flicker". The board art
|
||
is 99.9% opaque, so nothing was ever showing *through* it. **Test:** render twice without
|
||
stepping — identical pixels means nothing in the draw path rolls dice; and with the crowd calm
|
||
the frame-to-frame pixel delta must be 0.0%.
|
||
- Editing a full-scene image (`x_crowd` → `x_crowd2`) preserved the layout far better than expected:
|
||
mean difference 3.2/255, railing and tier rows unmoved. Worth reusing for other 2-frame swaps.
|
||
|
||
## Third feedback round (2026-07-27)
|
||
- **THE FREEZE (serving dead, gun racket stuck over your head).** `G.mgT` only counted down inside
|
||
`if (G.mgT > 0 && G.phase === 'rally')`. Anything that moved the phase mid-burst — a scheduled
|
||
duel calling `setupPoint()` — stranded `mgT` above zero *forever*, and `mgT > 0` is one of
|
||
`keyPressed`'s early returns, so no tap could ever serve again. The yellow blob Oliver saw was
|
||
`drawMgRacket`'s muzzle flash still being drawn. Fixed at the root (the clock ticks in every
|
||
phase; only the pellets are gated on 'rally') **and** semantically (`startDuel` cancels any
|
||
burst — the duel is tennis). If another "can't serve" report ever comes in, check these flags
|
||
first: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT` — each one blocks `keyPressed`.
|
||
- **The mop boy has a job now** (`mopRound` in gameplay.js): walk to the biggest nearby stain,
|
||
stand on it for `TUNE.boyMopDwell` while the push cycle animates in place, scrub it out, pick the
|
||
next, then walk off. `TUNE.boyMaxJobs` caps the round so a kill streak can't employ him forever.
|
||
Gore fades ~3.5× faster while he is actually stopped and mopping than when merely walking past.
|
||
A straight-line constant-speed crossing was what read as moonwalking past the mess.
|
||
- **Applause is floored at the audio length.** `assets/audio/tennis-applaus.wav` is 4.16s and always
|
||
plays out in full, but every `cheer()` call passed a shorter beat, so the stand sat down while the
|
||
clap was still audible. `cheer()` now does `Math.max(crowd.cheer, seconds, APPLAUSE_T)`.
|
||
- **Regenerating a crowd frame: colour-match it or don't ship it.** The bolder re-roll came back
|
||
much brighter and bluer; swapping to it would have flashed the whole stand. Fixed by a per-channel
|
||
linear map onto frame one's mean and sd (means now agree to within half a level), then quantised
|
||
to 64 colours. Verify with a mean-difference number before trusting any regenerated frame.
|
||
- **Net and umpire were both wrong since forever.** `COURT.netW` was the singles-stick width 10.06
|
||
while the court is drawn to the doubles lines, so the net stopped 0.46 m short each side — now
|
||
10.97, flush. `UMP_X` 5.6 put the chair's near legs 0.45 m *inside* the doubles line (x_ump is
|
||
42×112 px at UMP_H 3.0 = 1.12 m wide as drawn); 6.05 stands them exactly on the line. Do not push
|
||
past ~6.3 or the chair clips the right edge of the portrait viewport.
|
||
The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape);
|
||
serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma.
|
||
|
||
## Fourth feedback round — feel polish (2026-07-27)
|
||
- **The draw meter is a fixed bar across the bottom** (~90% of W, 10px tall, `METER_Y()` in
|
||
render.js), not a 26×4 stub floating on the apron by the player's feet. The serve hint is
|
||
suppressed while `G.aim` is live because they share that slot. `TUNE.pullFull` 95 → 130: at 95
|
||
every pull maxed out by accident and the meter meant nothing. Don't push much past ~150 — a pull
|
||
that STARTS low on the screen still has to be completable before the finger runs off the glass.
|
||
- **Aim magnet.** `flickTarget` used to take the nearest body *along* the lane, so a close shambler
|
||
a little off-axis stole shots clearly aimed past it. It now picks the best-ALIGNED body
|
||
(normalised offset within the cone), with `TUNE.aimTie` (0..1) of slack in which a nearer body
|
||
still wins — so a straight-ahead lane still eats the nearest one first, which is the settled feel.
|
||
`aimTie: 1` restores the old "nearest always wins" exactly, for A/B.
|
||
- **Bomb**: radius 2.5 → 4.2 (2 kills → 5 in a scattered 10-wave), 120-particle eruption, a ring of
|
||
thrown stains, size-4 pile. The white-out (`rgba(255,255,255,0.85)`) is now a brief warm bloom
|
||
(`rgba(255,150,70,0.3)`) — the flash was hiding the very gore it announced.
|
||
- **Rack touch target**: `rackH` 22 → 30 and `rackHit` pads the touch band by `RACK_GRAB` above and
|
||
runs to the screen edge below. 22 logical px is ~30 CSS px on a phone, well under the ~44 a thumb
|
||
expects; it's now ~62. Rack text is positioned proportionally (`g.h * 0.33` / `0.72`) so the tile
|
||
height stays a free knob.
|
||
|
||
## Fifth feedback round (2026-07-27)
|
||
- **Crowd brightness**: both crowd sheets got an identical gamma-0.8 lift (means 30/14/39 →
|
||
42/22/56) and the splatter tint dropped 0.5 → 0.26. **If you ever touch one crowd frame you must
|
||
apply the exact same curve to the other**, or the clap swap starts flashing brightness.
|
||
`assets/_sheet_crowd1.png` is the pre-lift original.
|
||
- **The splatter serve hint led with the tap** ("TAP TO SERVE · PULL BACK TO AIM") when pulling is
|
||
the entire gesture. Now "PULL BACK TO AIM · RELEASE TO FIRE", plus `drawPullHint()`: after
|
||
`TUNE.pullHintAfter` seconds of sitting in the serve stance untouched (`G.idleHintT`), a ghost
|
||
thumb slides back while chevrons grow up-court, on a loop. It teaches the inverse relationship
|
||
the text can't.
|
||
- **The mop boy has three sheets now**, not two: `x_boymop` is the push cycle used ONLY while
|
||
stopped and working, `x_boycarry` is him walking upright with the mop shouldered, `x_boyrun` is
|
||
the tennis-mode fetch. He was mopping the air the whole way to the stain.
|
||
|
||
## Gotchas learned the hard way
|
||
- Browser-pane verification: python http.server sends no cache headers, so edited ES
|
||
modules often load STALE even after location.reload(). Reliable fix: preview_stop +
|
||
preview_start (a new port = new origin = cold cache). Always sanity-check a just-added
|
||
field exists (e.g. `T.G.newField !== undefined`) before trusting test results.
|
||
- Sprite pipeline: generate via generate-image skill (needs OPENROUTER_API_KEY in repo .env,
|
||
gitignored; run the script with the scratchpad venv python — system python lacks requests)
|
||
→ magenta background → assets/_slice.py (COMMITTED now, no more scratchpad rebuilds:
|
||
chroma-key on the corner color + magenta de-fringe, column-gap segmentation, --largest
|
||
keeps only the main blob, common-scale per sheet, IDLE_TARGET_H=112). Generator sometimes
|
||
ignores magenta (harmless — the key uses the corner color) or draws near-identical
|
||
animation frames (re-prompt with strongly contrasted pose descriptions). If two frames
|
||
touch (a dragged weapon bridging the gap), split the sheet at the midpoint and slice each
|
||
half with --largest. Passing the existing sheet as --input (edit mode) nails the style.
|
||
- drawChar mirrors sprites by ch.facing — any stance that must not mirror needs facing reset
|
||
(setupPoint pins facing=1).
|
||
- Standalone art (logo, popup panels) uses `assets/_key.py` instead of `_slice.py`: chroma-key +
|
||
crop to the content bbox + optional downscale, with NO frame splitting and NO forced sprite
|
||
height. `python3 assets/_key.py sheet.png out.png [target_width]`.
|
||
- If a generated sheet's frames TOUCH (a mop handle bridging the gap), `_slice.py` finds 1 frame
|
||
and there is no column of magenta to split on — re-prompt for "at least 150px of empty magenta
|
||
between frames" and "the prop must stay inside its own frame". That worked; hand-splitting did not.
|
||
- The scratchpad venv/scripts get wiped between sessions — recreate venv (requests+pillow)
|
||
and slice.py lives only in scratchpad; its logic is described above if gone.
|
||
- Don't trust single-sample gameplay sims; randomness (aiFlub 9%, aim error) needs ~12 runs
|
||
(a 9/12 serve-return run is normal flub noise — re-run 24 before suspecting a regression).
|
||
- Synthetic touch tests: input.js only sets stick.moved in touchmove, so a
|
||
touchstart→touchend pair reads as a TAP — always dispatch a touchmove between them.
|
||
- Contact/kill tests must step 1/60: pellets at 26 m/s tunnel past 0.55m radii at
|
||
coarser steps and kills silently don't happen (looks exactly like a bug).
|
||
- preview_start autoPort only yields a fresh origin if the configured port is BUSY —
|
||
hold 8641 with a throwaway socket first, or you get stale cached modules again.
|
||
- Any loop over zombies[] whose body can kill (killZombie/splatter) can cascade into
|
||
sweepCourt and gut the array mid-iteration — keep the `if (!zb) continue` guards.
|
||
- Commit style: imperative summary + Co-Authored-By Claude line; push = deploy.
|