The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so anything that moved the phase mid-burst — a scheduled duel calling setupPoint — stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so no tap could serve again, and the yellow shape over the player's head was drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks in every phase, only the pellets are gated on rally) and semantically: starting a duel cancels any burst, because the duel is tennis. The mop boy now does an actual round: walk to the biggest nearby stain, stand on it and push the mop while the cycle animates in place, scrub it out, move to the next, then leave. Gore fades ~3.5x faster while he is stopped and working than when walking past, so the stain dies under him instead of fading as he drifts by. boyMaxJobs caps the round so a kill streak can't employ him forever. Applause: the clip is 4.16s and always plays in full, but every cheer() passed a shorter beat, so the stand sat down while it was still audible. Floored at the clip length. New, much bolder clap frame — the re-roll came back brighter and bluer, which would have flashed the whole stand, so it is colour-matched to frame one per channel on mean and sd before quantising. Net and umpire were both wrong since forever: netW was the singles-stick width (10.06) against a court drawn to the doubles lines, so the net stopped 0.46m short each side; the chair's near legs stood 0.45m inside the sideline. Both now sit exactly on the doubles line. 26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the wider net (the bar is inside one sigma), 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
7.7 KiB
JavaScript
113 lines
7.7 KiB
JavaScript
// All mutable game state in one place, plus the point-reset that re-arms it.
|
|
|
|
import { HOME, BOY_ENTER, TUNE } from './config.js';
|
|
|
|
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
|
|
|
|
// 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: tins ACCUMULATE (capped at TUNE.wCap), and G.weapon picks which one
|
|
// the next serve throws. Slot 0 = PLAIN (a normal ball — how you bank your specials).
|
|
bombs: 0, // bomb-ball stock — a serve blasts an area (rack slot 1)
|
|
triples: 0, // triple-serve stock — a serve fans into three balls (slot 2)
|
|
flames: 0, // flame-ball stock — kills on any touch, full pierce (slot 3)
|
|
guns: 0, // machine-gun charges — committing one starts an mgT burst (slot 4)
|
|
weapon: 0, // selected rack slot: 0 PLAIN, 1 BOMB, 2 TRIPLE, 3 FIRE, 4 GUN
|
|
mgT: 0, // s of machine-gun burst left — auto-fire, no serving (spent from G.guns)
|
|
mgCool: 0, // s until the next pickup-mode pellet
|
|
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)
|
|
meleeT: 0, // s of racket-swish cooldown left
|
|
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}
|
|
seen: {}, // one-shot markers so a first-time-only modal fires once per run
|
|
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
|
|
};
|
|
|
|
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.
|
|
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
|
|
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 };
|
|
}
|