Five reports from Oliver's second playtest. The parry was too generous — at 6 m of arm with a 0.34 s window the shot flew into reach on its own, so any tap once it was vaguely close worked. 3.4 m / 0.2 s / 1.7 m reach now: a tap at 4.5 m is rejected and costs blood, 3.2 m and in returns. The boss telegraphs in 0.5 s against the machine's 0.9 and throws harder, because a boss should be worse than furniture. His three balls were decoration: fanned ±1.9 m across space, so only the middle one could ever reach you. They are a timed salvo now — three shots 0.42 s apart, each aimed at where you are at that moment. hurtGrace still caps a whole volley at one point of damage, which is what it is for. Every chapter was the same boss. Three designs cycling now, each with its own sheet, stride, throw pose, wrecked skin and wrecked-throw skin: the GROUNDSKEEPER, the LINE JUDGE, the CHAIR UMPIRE with his chair strapped on. The chapter card names them. Designs are data in BOSS_ART, so a fourth is a sheet plus a row. Healing announces itself with a first-aid tin and a red cross instead of a 1.4 s flash. And 14 new umpire lines: ten sarcastic asides on a jittered timer, four hooked to specific beats. He still has not acknowledged that the match became a massacre, so he keeps officiating it. Two traps found and written into the pipeline notes. Sprite loads now carry ?v=SPRV, because python http.server sends no cache headers and the browser caches 404s — a name added before its PNG exists stays permanently missing for that browser. And a scripted replace that silently didn't match left drawZombie on the old hardcoded boss names while everything else moved to BOSS_ART; the built-in fallback hid it perfectly, all four designs rendering as design 1 with no error. Assert your rewiring edits matched. Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
8.7 KiB
JavaScript
126 lines
8.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)
|
|
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
|
|
// ---- 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)
|
|
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.
|
|
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 (G.interlude) return; // the sudden-death duel is clean tennis: nobody mops through it
|
|
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 };
|
|
}
|