Files
splatter-tennis/state.js
T
wuwiandClaude Opus 5 55c0f75c37 Give him a sidestep, and split the messages into two voices
He died at every chapter boss because he could not move, and there were two
independent reasons for it. The movement block is 'rally'-only — a settled feel
decision the six boss AI styles were written around — and a boss is fought from
'pretoss'. On top of that canAimServe() is true through a boss fight, so his one
finger was already spent on the slingshot: there was no gesture left over that
could have meant "move".

So it is a SIDESTEP, not a walk. One committed step on a cooldown, carrying
invulnerability that beats both damage channels — the chip damage in hurtPlayer
and the instant-death contact test. The rule about walking stands untouched.

The gesture is picked by WHICH WAY the drag leaves the deadzone, decided once and
never revisited for that touch: down-ish is the pull the game already teaches,
sideways is the step. No timing window, no invisible mode, so "one gesture means
one thing" still holds. The finger-steer modes are untouched — stick.aimable is
false in a horde, so the drag is the absolute steer it always was. The apron demo
now alternates PULL DOWN and SWIPE TO DODGE, and Marvin has a page for it.

The rest of the playtest list:

- Bangers is bundled beside Silkscreen, and the register IS the category. The
  quiet face keeps the HUD, the rack, the control hints and RESUMING IN; the loud
  one takes SET, CHAPTER, EPIC!, PERFECT and every free-floating flash. Floating
  text gets one shared y and a drop shadow, so it survives crowd, clay and blood.
- Modals queue instead of being dropped. That was why the trainer and the weapon
  had to be crammed into one card after a won duel; the heal gets its own popup
  with the x_cross tin over it — a sprite loaded since that merge and drawn by
  nothing.
- weaponModal's first-sight gate moved inside the function. The barrels gated
  themselves and the tin ladder did not, so four weapons re-explained themselves
  every three games while a barrel weapon explained itself once.
- Ten rack slots. Past five it grows a second row upward rather than shrinking
  ten tiles under the thumb target; slots 0-4 stay welded to the bottom edge.
- The boss win holds for its own 3.6s and says EPIC!, with no GAME n NEXT line —
  the bar under the scoreboard already prints that permanently. That bar went
  from 6px to 7px, which is the smallest type in the game becoming words.
- The turn punches the camera in on him and the ground shakes harder at each beat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:48:24 +02:00

174 lines
12 KiB
JavaScript

// All mutable game state in one place, plus the point-reset that re-arms it.
import { HOME, BOY_ENTER, TUNE, COURT, COURTS } 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, firstLand: null, collected: false, kills: 0, bomb: false, flame: false };
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;
}
// 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,
outMarker: null, // {x, z, out, t} — where your last shot lands
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
pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms
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
// ---- 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 wash left
asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery)
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
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 starts after the set card's pause
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)
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; ball.firstLand = null;
G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter') { // always your serve, from wherever you're standing
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 (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 };
}