The front door now continues the story. An act is its league cup, whose final is the boss's own body (acts II and IV play their band out of ladder order for that), then a ten-game gore chapter on the act's court. The chapter has no duel at all, takes the act's weapon stock into the rack, meets a mid-boss after game 5 (the checkpoint: restock and full blood) and the act boss after game 10, then the act card. Death is not permadeath: it counts the attempt, banks nothing on the boards, and TRY AGAIN restarts from the checkpoint the profile holds. Barrels hold what the act has earned. The chapter plays the arcade's games base+1..base+10 so every director curve keeps its slope. Profiles from before the story start it from the top (league, cups and champion reset; level kept). The server mirror stores the act and checkpoint and only ever moves them forward. Plain cards stand in for the screens (phase 3) and the transformation (phase 2). Six new tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
20 KiB
JavaScript
273 lines
20 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, KITS, SKINS } 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, rx: null, heels: 0, approach: null, retreat: null, smash: false, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false,
|
|
// NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the
|
|
// striker's phone that is mid-roll, a whole court away from the call, and it read as the game
|
|
// loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring
|
|
// consequence below is switched off, and the ball dies where a real one would, by rolling to a
|
|
// stop. Cleared by setupPoint with everything else that arms a point.
|
|
scored: 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
|
|
// `pop` is the SIZE of the moment the stand is reacting to (cheer's own vol, 0..1) and is only
|
|
// meaningful while cheer runs: it drives the wave, the paparazzi rate and how many shutters go off
|
|
// at once. `burst` is that queue of shutters, drained by render.tickFlashes.
|
|
export const crowd = { cheer: 0, pop: 0, burst: 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;
|
|
}
|
|
|
|
// The kit and the skin, picked on the same screen and living by the same rule as the tour: nothing
|
|
// but the DRAWN PIXELS depends on either. No physics, no AI, no scoring, no sprite NAMES — the
|
|
// recolour happens after SK() has already chosen the frame (see tint/PS in render.js).
|
|
export let kit = KITS[0], skin = SKINS[0];
|
|
export function selectKit(id) {
|
|
kit = KITS.find(k => k.id === id) || KITS[0];
|
|
try { localStorage.setItem('kit', kit.id); } catch {}
|
|
return kit;
|
|
}
|
|
export function selectSkin(id) {
|
|
skin = SKINS.find(s => s.id === id) || SKINS[0];
|
|
try { localStorage.setItem('skin', skin.id); } catch {}
|
|
return skin;
|
|
}
|
|
|
|
// 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,
|
|
net: null, // netplay (src/net.js): { ready, dead, side, code, opp } while a 1v1 runs
|
|
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)
|
|
passStreak: 0, // consecutive rally balls that passed untouched (tennis) — ≥2 shows TAP TO HIT
|
|
flatStreak: 0, // consecutive returns hit with NO aim on them — the flick is the whole skill of
|
|
// the mode and nothing on touch ever said so (render.drawFlickHint)
|
|
// ---- 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
|
|
swipe: null, // {fx, fy, mag} — the rally flick WHILE the thumb is still moving, so the width
|
|
// its length is buying is visible before the ball leaves (input.js sets it)
|
|
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,
|
|
classicWon: 0, // games YOU have taken off the CPU this classic match. G.sets is sets and
|
|
// score.games resets every set, so nothing else counted this — and it is what
|
|
// the classic world board ranks (a 12-2 win beats a 12-10 one).
|
|
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)
|
|
league: 1, // THE LEAGUES: the profile's league, stamped by beats.startMatch for the
|
|
// match modes (world = pace tier + band + court). 1 everywhere else, so
|
|
// every non-league mode keeps playing the shipped game (leaguePace 1.0).
|
|
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)
|
|
matchFall: null, // {t, from} while a wounded match opponent collapses and drags itself up (beats.tickMatchFall)
|
|
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
|
|
moveTapT: 0, // the double tap's open window (gameplay.keyPressed, moveCall)
|
|
// THE STORY's run state (beats.startStory). Wiped by restartRun like everything on G — the place in
|
|
// the story lives in the profile (prof.story); this is only what THIS run is doing with it.
|
|
story: null, // {act, part: 'cup'|'chap', base, midDone, fight: 'mid'|'end'|null, winT}
|
|
storyBoss: null, // 'mid' | 'end' — a story boss is due at the next clear (splatPoint arms it)
|
|
ring: null, // the drawn gold ring's steady cue (gameplay update smooths ringCue)
|
|
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 = crowd.pop = crowd.burst = 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.scored = false; ball.rx = null; ball.heels = 0; P.chase = P.goal = P.goalKey = P.post = null; P.running = false; P.rushing = false; ball.approach = null; ball.retreat = null; ball.smash = false;
|
|
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;
|
|
// A RECEIVER STANDS BEHIND HIS BASELINE (Oliver, 2026-08-22: "when the opponent serves the
|
|
// player does not stand at the baseline — but that's how it should be"). Both marks used to sit
|
|
// over a metre INSIDE the court (22.5 near, 1.2 far), which is where you stand to return a second
|
|
// serve, not a first one — and next to a server drawn behind his own line it read as a man who
|
|
// had wandered in. They now mirror the server's depth: 0.4-0.5 m behind the line on both sides.
|
|
rc.x = -sv.x; rc.z = rc.side === 'P' ? 24.2 : -0.43;
|
|
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".
|
|
// ...and in a MATCH the mess is on the FAR side, because that is where the body stands and bleeds
|
|
// through four wound stages. This test was written for the gore mode, where everything dies on the
|
|
// near court, and it silently made the boy impossible in the match: sendBoy was called, found no
|
|
// stain it considered worth walking out for, and returned. The near-side bias is right everywhere
|
|
// else, so the match is the exception rather than the rule being widened.
|
|
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && (G.match || d.z >= COURT.netZ - 2));
|
|
|
|
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
|
|
// The sudden-death duel is clean tennis: nobody mops through it. THE MATCH IS THE EXCEPTION — it
|
|
// runs on a PERMANENT interlude, so this guard silently barred the boy from an entire mode, and a
|
|
// match is where the gore actually accumulates: one body bleeds through four wound stages in the
|
|
// same spot, game after game. He is let out between games only (matchGameWon calls him), never
|
|
// mid-point, which is the same "guaranteed calm court" rule the modals live under.
|
|
if (G.interlude && !G.match) return;
|
|
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 };
|
|
}
|