WIP splatter feedback round + HANDOFF.md for session continuation
Done (unverified): faster serve cadence in splatter (overT 0.25, serve from where you stand, flick = instant aimed quick serve), aim-anywhere targeting relative to the player. Not done: zombie speed ramp (G.splatT exists but is never incremented), eaten-on-touch death + restart screen. Full state and plans in HANDOFF.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60c5f72a0a
commit
e7aab295d4
4 files changed
+140
-15
No files matched your search
+111
@@ -0,0 +1,111 @@
|
|||||||
|
# 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 — Oliver's last message (his exact words):
|
||||||
|
> much better!!!
|
||||||
|
> the time between each serve is too long in zombie mode.
|
||||||
|
> right now it auto aims to the closest. i should also be able to aim anywhere includibg over the net.
|
||||||
|
> the speed of zombies should increase over time
|
||||||
|
> when they reach me i should be eaten
|
||||||
|
|
||||||
|
Status per item (working tree runs clean, no console errors, but items 1–2 are UNVERIFIED):
|
||||||
|
|
||||||
|
1. **Serve cadence — code DONE, not verified.**
|
||||||
|
- gameplay.js dead-ball branch: splatter overT 0.7 → 0.25, new_balls voice freq 0.2 → 0.12.
|
||||||
|
- state.js setupPoint splatter branch no longer repositions P (serve from where you stand).
|
||||||
|
- keyPressed pretoss+splatter: a FLICK now fires an instant quick serve (no toss, quality
|
||||||
|
0.62, aimed by the flick via hitServe(P, 0.62, flick)); a plain TAP still does the toss
|
||||||
|
ritual (perfect serves remain possible). hitServe gained an `aim` param.
|
||||||
|
|
||||||
|
2. **Aim anywhere incl. over the net — code DONE, not verified.**
|
||||||
|
- `zombieTarget(aim)` in gameplay.js rewritten: no aim → most advanced zombie (max z);
|
||||||
|
with aim → lane relative to the PLAYER (`P.x + ax*4`), az<0 prefers far zombies,
|
||||||
|
az>0 prefers close ones on your own side. All z candidates included.
|
||||||
|
- NOTE Oliver's complaint was that it "auto aims to the closest" — if the rewrite still
|
||||||
|
feels wrong, the next step is true free-aim: flick direction → landing point directly
|
||||||
|
(no zombie snap), with a generous splash/hit radius. Ask him which feel he wants if
|
||||||
|
the lane system still fights him.
|
||||||
|
|
||||||
|
3. **Zombie speed ramps over time — NOT DONE.**
|
||||||
|
- `G.splatT` field was added to state.js but is NEVER incremented — wire it up in
|
||||||
|
updateZombies (only while `G.mode==='splatter'`).
|
||||||
|
- Planned approach (my edit failed on a stale string, zombies.js current content is in
|
||||||
|
this repo, read it): in `spawnZombie`, multiply speed by
|
||||||
|
`1 + Math.min(1.5, G.splatT / 70)` (ramp at spawn time keeps the shot-lead math exact),
|
||||||
|
and optionally shrink the spawn interval / grow zMax with splatT.
|
||||||
|
|
||||||
|
4. **Eaten when they reach you — NOT DONE.** Plan agreed with Oliver:
|
||||||
|
- In updateZombies: `G.mode==='splatter' && Math.hypot(zb.x-P.x, zb.z-P.z) < 0.7` →
|
||||||
|
eat: gore decal + big particle burst at P, splat(1), voice('game_set_massacre', 0.8),
|
||||||
|
`G.mode='eaten'`, ball.active=false. Zombies keep shambling for ambience (guard the
|
||||||
|
eat check to mode==='splatter' so it fires once).
|
||||||
|
- gameplay.update: early-exit branch for mode 'eaten' (still tick idleT + updateZombies).
|
||||||
|
- render.js: skip drawChar(P) when eaten; dark overlay with 'EATEN.', final games score,
|
||||||
|
'TAP TO PLAY AGAIN'. Also gate the serve prompt on mode!=='eaten'.
|
||||||
|
- main.js: in the cv pointerdown listener (and a keydown), if mode==='eaten' →
|
||||||
|
location.reload() (laziest honest restart; fine for now).
|
||||||
|
- Existing baseline-cross rule (splatPoint('O') at z >= L+0.7) stays: crossing without
|
||||||
|
touching you is a point for THEM, touching you is death.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## Gotchas learned the hard way
|
||||||
|
- 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.
|
||||||
+25
-11
@@ -25,13 +25,16 @@ export function predictLanding() { // first-bounce point, closed form — exact,
|
|||||||
// the best-matching zombie anywhere on the court — including your own side of the net.
|
// the best-matching zombie anywhere on the court — including your own side of the net.
|
||||||
function zombieTarget(aim) {
|
function zombieTarget(aim) {
|
||||||
if (!zombies.length) return null;
|
if (!zombies.length) return null;
|
||||||
const lane = (aim ? aim.ax : 0) * 3.3;
|
const none = !aim || (!aim.ax && !aim.az);
|
||||||
const band = aim && aim.az !== 0 ? (aim.az < 0 ? 4 : 19) : null;
|
|
||||||
let best = null, bs = 1e9;
|
let best = null, bs = 1e9;
|
||||||
for (const zb of zombies) {
|
for (const zb of zombies) {
|
||||||
let s = Math.abs(zb.x - lane);
|
let s;
|
||||||
if (band !== null) s += Math.abs(zb.z - band) * 0.5;
|
if (none) s = COURT.L - zb.z; // no direction: the most advanced one is the most urgent
|
||||||
if (!aim) s = COURT.L - zb.z; // no aim (serves): the nearest one is the most urgent
|
else {
|
||||||
|
s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand
|
||||||
|
if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones
|
||||||
|
if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side
|
||||||
|
}
|
||||||
if (s < bs) { bs = s; best = zb; }
|
if (s < bs) { bs = s; best = zb; }
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
@@ -93,11 +96,11 @@ function tossBall(sv) {
|
|||||||
}
|
}
|
||||||
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
|
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
|
||||||
|
|
||||||
function hitServe(sv, quality) {
|
function hitServe(sv, quality, aim = null) {
|
||||||
const toFar = sv.side === 'P';
|
const toFar = sv.side === 'P';
|
||||||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||||
let boxX, tzs;
|
let boxX, tzs;
|
||||||
const zt = G.mode !== 'tennis' ? zombieTarget(null) : null;
|
const zt = G.mode !== 'tennis' ? zombieTarget(aim) : null;
|
||||||
if (zt) { // splatter serves auto-track the most advanced zombie
|
if (zt) { // splatter serves auto-track the most advanced zombie
|
||||||
boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8;
|
boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8;
|
||||||
const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s;
|
const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s;
|
||||||
@@ -290,7 +293,18 @@ function trySwing(ch, isAI) {
|
|||||||
export function keyPressed(code) {
|
export function keyPressed(code) {
|
||||||
if (code !== 'Space') return;
|
if (code !== 'Space') return;
|
||||||
audio(); // unlock on first gesture
|
audio(); // unlock on first gesture
|
||||||
if (G.phase === 'pretoss' && score.server === 'P') { tossBall(P); return; }
|
if (G.phase === 'pretoss' && score.server === 'P') {
|
||||||
|
if (G.mode === 'splatter') {
|
||||||
|
const flick = takeFlickAim();
|
||||||
|
if (flick) { // quick serve: no toss ritual, modest pace, aimed by the flick
|
||||||
|
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
|
||||||
|
ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
|
||||||
|
hitServe(P, 0.62, flick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tossBall(P); return;
|
||||||
|
}
|
||||||
if (G.phase === 'toss' && score.server === 'P') {
|
if (G.phase === 'toss' && score.server === 'P') {
|
||||||
// serve timing: distance from apex = quality
|
// serve timing: distance from apex = quality
|
||||||
const dy = Math.abs(ball.y - serveApexY());
|
const dy = Math.abs(ball.y - serveApexY());
|
||||||
@@ -412,9 +426,9 @@ export function update(dt) {
|
|||||||
onBounce();
|
onBounce();
|
||||||
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
||||||
ball.active = false;
|
ball.active = false;
|
||||||
if (G.mode === 'splatter') { // hand the server a fresh one
|
if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
|
||||||
G.phase = 'over'; G.overT = 0.7; sendBoy();
|
G.phase = 'over'; G.overT = 0.25; sendBoy();
|
||||||
if (Math.random() < 0.2) voice('new_balls', 0.3);
|
if (Math.random() < 0.12) voice('new_balls', 0.3);
|
||||||
} else if (ball.bounces === 1) pointTo(ball.lastHit, null);
|
} else if (ball.bounces === 1) pointTo(ball.lastHit, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const G = {
|
|||||||
aiSwingCool: 0,
|
aiSwingCool: 0,
|
||||||
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
|
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
|
||||||
wasServe: false, // the ball in flight is a serve (first bounce must hit the box)
|
wasServe: false, // the ball in flight is a serve (first bounce must hit the box)
|
||||||
|
splatT: 0, // time survived in splatter mode — drives the difficulty ramp
|
||||||
};
|
};
|
||||||
|
|
||||||
export const other = s => s === 'P' ? 'O' : 'P';
|
export const other = s => s === 'P' ? 'O' : 'P';
|
||||||
@@ -35,9 +36,8 @@ export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even
|
|||||||
|
|
||||||
export function setupPoint() {
|
export function setupPoint() {
|
||||||
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null;
|
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null;
|
||||||
if (G.mode === 'splatter') { // always your serve; the receiver is... plural now
|
if (G.mode === 'splatter') { // always your serve, from wherever you're standing
|
||||||
score.server = 'P';
|
score.server = 'P';
|
||||||
P.x = deuceSide() ? 1.3 : -1.3; P.z = 24.3;
|
|
||||||
P.pose = 'idle'; P.swingT = -1; P.facing = 1;
|
P.pose = 'idle'; P.swingT = -1; P.facing = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
||||||
|
|
||||||
import { TUNE, COURT } from './config.js';
|
import { TUNE, COURT } from './config.js';
|
||||||
import { G, ball, sendBoy } from './state.js';
|
import { G, P, ball, sendBoy } from './state.js';
|
||||||
import { splat, moan, pock } from './audio.js';
|
import { splat, moan, pock, voice } from './audio.js';
|
||||||
import { splatPoint } from './gameplay.js';
|
import { splatPoint } from './gameplay.js';
|
||||||
|
|
||||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||||
|
|||||||
Reference in new issue
Block a user