The code moves into src/: index, server and the test harness keep the root
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c122745390
commit
1f636530b9
16 files changed
+16
-13
No files matched your search
+190
@@ -0,0 +1,190 @@
|
||||
// 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 wash left
|
||||
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
|
||||
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, 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') { // 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 };
|
||||
}
|
||||
Reference in new issue
Block a user