Every animation in this round gets real frames. The procedural squash-and-sway on the wounded brute read as fake on sight, and that gait is where the comedy lives. - Each brute wound stage now has its own 2-frame lurch (z_gw/z_gwb, z_gw2/z_gw2b, ...). The generator gave every B frame a dragged pole that only stage 1 actually carries, so it would have blinked in and out each step — stripped by keeping the largest blob, and where the pole touched the body, by cutting on column density (a torso is dense, a dragged pole is a thin diagonal). - Frames step off DISTANCE COVERED, never elapsed time — boy.dist for ball boys, z/bruteStride for brutes. A time-driven cycle moonwalks the moment speed and frame rate disagree, which is exactly what "he slides faster than the animation suggests" was. Change a speed now and the legs keep up on their own. - One boy, one slot. sendBoy no longer replaces a boy already on court: that was the appearing and disappearing, since every kill teleported him back to the wing mid-stride. New mess is appended to his round instead. He now walks for 10.5s a crossing, and there is a lot of mopping to do. - The applauding crowd is a real two-frame animation (seated / arms up), swapped per column on its own phase. The old "smooth wave" still shifted each column by a FRACTIONAL dy, and nearest-neighbour resampling at a fractional y re-snaps a column's pixel rows every frame — that boiling beside the HUD is what read as the scoreboard flickering. The board art is 99.9% opaque; nothing was ever showing through it. With the crowd calm the frame delta is now 0.0%. Benchmarks: 136/150 serve returns (90.7% ±2.4%, the 11/12 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 } 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 = (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 };
|
|
}
|