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>
217 lines
15 KiB
JavaScript
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 };
|
|
}
|