107 lines
7.8 KiB
Markdown
107 lines
7.8 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.
|
||
|
||
## ACTIVE 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.
|
||
|
||
## 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) → magenta background → scratchpad slice.py (chroma-key, segment, strip ground
|
||
lines/dividers, common-scale per sheet, IDLE_TARGET_H=112). Generator sometimes ignores
|
||
magenta (then: edit-mode the image back to magenta) or draws near-identical animation
|
||
frames (then: re-prompt with strongly contrasted pose descriptions).
|
||
- drawChar mirrors sprites by ch.facing — any stance that must not mirror needs facing reset
|
||
(setupPoint pins facing=1).
|
||
- 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.
|
||
- Commit style: imperative summary + Co-Authored-By Claude line; push = deploy.
|