Files
splatter-tennis/src/state.js
T
wuwiandClaude Opus 5 4bbdf6357a The opponent can serve and fall, and there is a bench to judge it on
THE MISSING ART, generated by the whole-sheet restyle method: bouncing the ball
at the baseline, the toss with the ball leaving the fingertips, the strike at
full stretch — and the fall, which slice 4 has been waiting for: buckling, down
on the knees, flat on the court face down, then dragging itself back up. One
zombie is now complete as a tennis player rather than as a body that shuffles.
(Healthy only so far; the wound variants are the next sheet.)

THE SPRITE BENCH, in the back room. No ball, no scoring, no AI — one opponent
on an empty court, walking, at the size and speed it is actually played at,
because that is the only place a walk cycle can be judged. A sheet that looks
fine at 224 px on a contact sheet is a different animal at 30 px crossing a
lawn, and every sprite argument this project has had was really about that gap.

It steps through every state including the ones the game cannot reach yet, so
the serve and the fall could be looked at the day they were drawn instead of
the day they get wired up. It prints the FILENAME it is drawing and the hp the
wound stage was read from — the two numbers that separate "the art is wrong"
from "the wiring picked the wrong art" — and it says HEALTHY ONLY where a wound
variant does not exist rather than implying one does. The stride steps off
ground covered, like the real thing: a bench animating on a clock would flatter
exactly the sprites that foot-slide in play.

It owns the court like every other mode and every start* refuses while it runs.

FOUND BY IT, ten seconds in: `start()` applies the mode the setup screen picked,
so a remembered ZOMBIE TENNIS was taking the court before the back room could —
every back-room entry (weapons, bosses, drill, match, bench) had been silently
refusing since the mode select landed this morning. The back room passes null.

And a third calibration lesson: POSE MOVES A GLOBAL COLOUR STATISTIC. A body
face down shows a fraction of the shirt it shows standing, so `redness` fell 14
points across the fall sheet without a colour changing. The measure that
survives a pose change is the shorts HUE — mid-tone neutrals, present in every
frame in the same proportion — which is why it is the one that caught the drift
Oliver actually saw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:25:29 +02:00

217 lines
15 KiB
JavaScript

// All mutable game state in one place, plus the point-reset that re-arms it.
import { HOME, BOY_ENTER, TUNE, COURT, COURTS, TOURS } from './config.js';
import { gore } from './zombies.js'; // runtime-only cycle (zombies imports state): sendBoy reads gore
// to decide whether a mopping round is worth walking out for
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false,
praise: null, // a perfect serve ARMS its praise word here; it pops at the impact, not the release
// which weapon launched the ball in flight — the stats screen files a kill under it long after
// the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free.
wid: 'ball' };
export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd
// The court you picked on the start screen. Everything visual reads `court` directly; the two
// bounce numbers are COPIED INTO TUNE instead, so the T panel still edits the live physics and
// gameplay's three bounce sites stay untouched.
export let court = COURTS[0];
export function selectCourt(id) {
court = COURTS.find(c => c.id === id) || COURTS[0];
TUNE.bounceY = court.bounceY; TUNE.bounceXZ = court.bounceXZ;
try { localStorage.setItem('court', court.id); } catch {} // private mode: a forgotten pick is fine
return court;
}
// The tour you picked on the same screen. Nothing but the SPRITE NAME depends on it — see SK() in
// render.js. A tour is a skin: same physics, same AI, same scoring.
export let tour = TOURS[0];
export function selectTour(id) {
tour = TOURS.find(t => t.id === id) || TOURS[0];
try { localStorage.setItem('tour', tour.id); } catch {}
return tour;
}
// everything that used to be loose top-level lets
export const G = {
mode: 'tennis', // tennis | transition | splatter
transT: 0, // transition timeline clock
tFlags: null, // one-shot markers within the transition
hideO: false, // the opponent has... left the match
everTransitioned: false,
phase: 'pretoss', // pretoss | toss | rally | over
serveNum: 1,
overT: 0,
hitstop: 0,
flash: null, // {text, t}
shake: null, // {t, mag} — camera shake, decays like flash (bomb impact)
boomFlash: 0, // s of white flash frame left (bomb impact)
lastOrderT: -9, // idleT of the last 'Order' call — bomb chains mustn't spam the umpire
idleT: 0,
boy: null, // ball boy sprinting across: {x, z, dir, runT}
aiServeT: 0,
aiSwingCool: 0,
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)
splatT: 0, // time survived in splatter mode — drives the difficulty ramp
// The courtside rack. Slots 1-4 are NOT fixed weapon types anymore: they hold what you have
// GATHERED, in pickup order, so which four you are carrying is a run-by-run thing. Stock still
// ACCUMULATES per weapon (capped at TUNE.wCap). Slot 0 stays BALL — infinite, and how you bank a
// special instead of spending it.
rack: [], // up to RACK_MAX { id, n } — the gathered weapons, in the order they arrived
temp: null, // { id, n } — a FIFTH weapon, riding slot 0 until it is spent. A barrel arms
// itself on the spot, so it always has somewhere to go even with a full rack.
weapon: 0, // selected slot: 0 = BALL (or temp, if one is riding it), 1..4 = G.rack[i-1]
mgT: 0, // s of braced auto-fire left — no serving while it runs (gun/beacon/flamer,
// and it doubles as the window a tap-fired laser or rocket is held for)
mgCool: 0, // s until the next pellet in that burst
gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour
beam: null, // the laser's live wedge {x, z, ux, uz, t} — drawn, then gone. Not a projectile.
testMode: false, // the scoreboard triple-tap sandbox: medium wave, every new weapon on the rack
deliveries: 0, // tins delivered this run — indexes the weapon ladder
duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court
afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves
aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss)
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
// pull gesture demonstrates itself (render.drawPullHint)
meleeT: 0, // s of racket-swish cooldown left
rackGlow: null, // {slot, t} — a freshly gathered barrel weapon pulses its rack tile briefly
// instead of equipping itself (popBarrel keeps your current selection)
tossDrops: 0, // consecutive DROPPED tosses (tennis) — ≥2 shows the ghost-hand apex hint
passStreak: 0, // consecutive rally balls that passed untouched (tennis) — ≥2 shows TAP TO HIT
// ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it
// and is the only way to move in the serve stance (which is where a boss fight is spent).
dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing
dodgeT: 0, // s of the step left
dodgeCd: 0, // s until another is allowed
dodgeI: 0, // s of invulnerability left (checked by hurtPlayer AND the eat test)
// ---- the SECOND damage channel. Only ranged attacks (ball machine, chapter boss) touch this;
// a shambler reaching you is still instant permadeath, deliberately. No HP bar anywhere — the
// state is drawn as blood on the man (drawChar's spatter + bleedPlayer's live trickle).
hp: TUNE.playerHp,
hpMax: TUNE.playerHp,
hurtT: 0, // s of post-hit grace left; also drives the red hit flash
hurtFlash: 0, // s of the impact PULSE left (the persistent damage vignette reads hp directly)
placeMark: null, // {x, z, t} — where your aimed rally shot is going (match): the flick made visible
asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery)
praiseT: 0, // s until the next praise word is allowed (TUNE.praiseEvery)
bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court
bossFight: 0, // chapter number of the boss currently on court, 0 = none. Blocks all other spawns.
slowT: 0, // s of REAL time left running the world in slow motion (a chapter boss dying)
bossBurst: null, // {x, z, t} — the shockwave ring thrown off by that death
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
match: null, // ZOMBIE TENNIS (beats.startMatch): {opp, ending}. One GAME per opponent,
// real scoring — every point you win wounds the racket zombie, every point it
// wins draws your blood. Runs ON TOP of a permanent interlude.
// CLASSIC TENNIS: the mode where nothing turns. `G.mode` stays 'tennis' for the whole run, the
// transition is blocked, and the game is scored as a real match — 6 games with a two-game lead is
// a set, TUNE.classicSets sets is the match. It is the FOUNDATION mode, not a warm-up: if the
// core tennis is good, everything built on it is good.
classic: false,
done: null, // {won, t} — the run ENDED without a corpse (a classic match played out).
// The one end-of-run state that is not 'eaten': same summary, same gated tap.
bench: null, // THE SPRITE BENCH (back room only): {i, stage, dir} — one opponent on an
// empty court so a walk cycle can be judged at the size it is played at.
// Owns the court like every other mode; see beats.startBench.
drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults,
// lost, best, msg}. Owns the court like every other mode — see beats.startDrill.
// One rule: always kill the body that would reach you first. `t` is time
// survived and drives the whole difficulty curve.
killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint)
runGames: 0, // games finished this run (either side) — the difficulty director
sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde
changeover: false, // the odd-game breather: no spawns, no serving, the boys mop
clearing: false, // a game just ended: the surviving wave stays and must be killed by HAND before play advances (no auto-sweep). No new spawns, kills don't score.
afterClear: null, // what happens once the court is clear: 'changeover' | 'game' | 'set'
clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card)
pendingHorde: false,// a set was won; the horde is WARNED after the set card's pause
hordeWarn: 0, // s left of the THEY KEEP COMING breather; the wave lands when it expires (beats.warnHorde)
card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block'
// the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns)
// and only a tap or key clears it, so a popup can be read without being eaten while you read it.
modal: null, // {title, body: [lines], icon, iconH, color, onDismiss}
modalQ: [], // popups waiting behind the open one — see showModal
seen: {}, // one-shot markers so a first-time-only modal fires once per run
zoom: null, // {x, z, k, t, T} — a scripted punch-in on a world point (the transition's turn, a boss death)
netRipple: null, // {x, t} — the ball clipped the tape; the net's top line wobbles there briefly
best: null, // best run {games, sets}, loaded from localStorage when you get eaten
newBest: false, // this run beat the stored best — the eaten summary celebrates it
lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde
eatenT: 0, // time since you were eaten — the death-animation clock, and it gates the restart tap
eatenAt: null, // {x, z} where you fell — the gore mound blooms there
missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog
swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log
rallyNear: null, // closest the incoming ball got this rally (for never-swung passes)
swung: false, // player swung at least once this rally — whiff log supersedes PASSED
lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly
logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump
};
// A pristine deep copy of every mutable bag, taken at module load before anything can touch them.
// This is what replaced `location.reload()` on death: the reload was an honest full reset, but it
// also tore down the AudioContext, so the menu theme restarted from silence every time you died.
// Everything above is plain data — primitives, nulls, small arrays and {} — so a structured clone
// is the whole job.
const PRISTINE = structuredClone({ G, P, O, ball, score });
export function resetState() {
const p = structuredClone(PRISTINE);
Object.assign(G, p.G); Object.assign(P, p.P); Object.assign(O, p.O);
Object.assign(ball, p.ball); Object.assign(score, p.score);
G.hp = G.hpMax = TUNE.playerHp; // re-read: the T panel may have moved it since boot
crowd.cheer = 0;
}
export const other = s => s === 'P' ? 'O' : 'P';
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0;
G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing
// (the MATCH falls through to the tennis branch below:
// real tennis serves from behind the baseline, deuce side
// mirroring and all — serving from mid-court where the
// last rally left you read as a bug, because it was one)
score.server = 'P';
P.pose = 'idle'; P.swingT = -1; P.facing = 1;
return;
}
const sv = score.server === 'P' ? P : O, rc = score.server === 'P' ? O : P;
const sx = deuceSide() ? 1.3 : -1.3;
sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net
sv.z = sv.side === 'P' ? 24.3 : -0.5;
rc.x = -sv.x; rc.z = rc.side === 'P' ? 22.5 : HOME.O.z - 1.3;
P.pose = O.pose = 'idle'; P.swingT = O.swingT = -1;
P.facing = O.facing = 1; // canonical righty stance — bounce/serve sprites must not render mirrored
G.aiServeT = score.server === 'O' ? 1.4 : 0;
}
// fetch a dead ball — or, in splatter, come out to CLEAN: a different sprite sheet and a slower
// walk, because mopping is not sprinting. Mopping is the DEFAULT once the gore starts; the plain
// running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball.
// Is there a stain worth walking out for? Must match mopRound's own target test, or he walks on,
// finds nothing he can reach, and walks straight off again — which is exactly what Oliver saw:
// "the mopboy sometimes moves over the court without anything to mop".
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && d.z >= COURT.netZ - 2);
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
if (G.interlude) return; // the sudden-death duel is clean tennis: nobody mops through it
if (G.drill) return; // ...and nobody walks onto a drill. He is one more body crossing the
// lanes you are reading colours down, and unlike everything else out
// there he is not part of the puzzle OR the threat — pure noise.
if (mop && !mopWorthIt()) return; // no job, no boy
if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go
// ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride,
// which is why he flickered in and out: a kill streak restarted him several times a second.
// A boy already out on the court keeps working — he just adds the new mess to his round.
if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = Math.min(TUNE.boyMaxJobs, (G.boy.jobs || 1) + 1); } return; }
const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
const e = mop ? BOY_ENTER.mop : BOY_ENTER.run;
G.boy = { x: dir > 0 ? -e : e, z: tz, dir, runT: 0, dist: 0, mop, jobs: 1 };
}