Three modes, picked up front — and CLASSIC TENNIS is one of them

The setup screen picks WHAT before WHO and WHERE. Three rows out of a table,
in the court row's own markup:

CLASSIC TENNIS is not the opener with the turn switched off and left there —
it is the mode the other two are built on, so it gets real match scoring: six
games with a two-game lead is a set, best of TUNE.classicSets is the match,
the board shows the sets column (hidden until now outside splatter) and the
bar under it says which set this is. The CPU stops rubber-banding and flubs
less: aiFlubLoss exists to get a new player PAST the opener, and a mode whose
whole promise is a real match cannot be won by an opponent that falls apart
the moment he goes ahead. No tiebreak — 6-6 plays on to 8-6. Marked ponytail.

That needed a second ENDING, the one with no corpse in it: G.done, the same
summary and gated tap as the death screen, its own words (GAME, SET, MATCH. /
BEATEN.) and the man still standing. The three endings now share one block
rather than three copies of it.

Caught by the first classic test: stats banked only the FIRST classic run of a
session. `banked` was cleared by the next KILL, and a classic match has no
kills in it at all — every later match was swallowed by the double-bank guard.
beginRun() is the honest reset and restartRun calls it.

ZOMBIE TENNIS from the menu is one line; the back room keeps its door.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 09:27:49 +02:00
1 parent 66fdad3d74
commit c89d4a3ff9
31 files changed
+472 -123

No files matched your search

+23 -1
View File
@@ -31,6 +31,12 @@ export const TUNE = {
// faces an opponent that visibly starts falling apart rather than a wall.
aiQualityMin: 0.45, aiQualityMax: 0.72, aiFlub: 0.18,
aiFlubLoss: 0.08,
// ...and in CLASSIC TENNIS the same CPU has to last a whole match rather than one game, so the
// rubber band is turned off there (aiFlubLoss would have him in pieces by the second set) and the
// cap is raised: a mode whose whole promise is "real tennis" cannot be won by attrition.
classicSets: 2, // sets to win the match — best of three
classicQualityMax: 0.86,
classicFlub: 0.10,
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
// ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which
@@ -485,6 +491,9 @@ export const APPLAUSE_T = 4.2;
// It is deliberately long. Oliver: "the movies surround and bite them, blood spills, and then the
// person basically drowns in the zombies" — that cannot happen in half a second.
export const EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6;
// ...and the same two beats for the ending with no corpse in it (a classic match played out). It is
// shorter because there is no animation to sit through — only the crowd finishing, and the summary.
export const DONE_RESTART = 2.0;
export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs around the body
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
@@ -539,6 +548,19 @@ export const COURTS = [
// dx/apex are optional and override BOUNCE_IDLE's pair: she reaches further out and higher than he
// does, so the pre-serve ball has to meet a different palm. Same rule as BOUNCE_IDLE — measured off
// the drawn hand by eye, not calculated.
// THE THREE MODES, picked before a run and nowhere else. Pure DATA, like TOURS and COURTS: adding
// one is a row here plus its dispatch line in main.start(), never markup.
// classic — real tennis, best of TUNE.classicSets sets, nothing ever turns
// zombie — the exhibition match (beats.startMatch): one game per opponent, blood only
// gore — the original: the opening match TURNS and you survive what walks on
// `tile` is an existing sprite rather than new art on purpose — each one is the thing you will be
// looking at for the whole mode, which is a better advert than a drawn icon of it.
export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', tile: 'o_swing1' },
{ id: 'zombie', name: 'ZOMBIE TENNIS', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', tile: 'z_r1' },
{ id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', tile: 'z_boss1' },
];
export const TOURS = [
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78 },
@@ -666,7 +688,7 @@ export const ENEMIES = {
// per run, and he is NOT the racket zombie below — that one keeps coming back. Missing this row
// is what put a second "SHAMBLER" on the screen: an unmapped key falls back to the shambler art.
c: { name: 'THE OPPONENT', spr: 'z_c1', sub: 'HE TURNED MID-MATCH' },
r: { name: 'THE CLUB PRO', spr: 'z_c1', sub: 'STILL HAS A BACKHAND' },
r: { name: 'THE CLUB PRO', spr: 'z_r1', sub: 'STILL HAS A BACKHAND' },
m: { name: 'THE BALL MACHINE', spr: 'z_mach1', sub: 'FIRES BACK · FURNITURE' },
};
for (let i = 0; i < BOSS_ART.length; i++) // the chapter bosses, named where they already are
+61 -4
View File
@@ -6,6 +6,7 @@ import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zom
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
@@ -485,10 +486,13 @@ function pointTo(side, reason) {
if (side === 'O') aww(0.8); // you dropped the point — the crowd sighs
if (reason) voice(reason);
if (gameOver) {
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned) return startTransition();
// ...unless this is CLASSIC TENNIS, where nothing turns and the first game you win is just the
// first game you win. That check is the whole of the mode's isolation from the gore path.
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned && !G.classic) return startTransition();
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
cheer(2.8, 0.6);
score.server = other(score.server);
if (G.classic) { G.runGames++; if (classicGame(side)) return; } // ...then a set, then the match
} else {
voice(scoreKey(), 0.7);
if (side === 'P') cheer(1.2, 0.3);
@@ -496,6 +500,44 @@ function pointTo(side, reason) {
G.serveNum = 1;
}
// CLASSIC TENNIS: a game has just been banked by advancePoint — is it also a set, and is the set
// also the match? Returns true when it ended the match, so pointTo stops handing the point flow on.
// Six games AND a two-game lead, the real rule; no tiebreak, so 6-6 simply plays on to 8-6. That is
// the one simplification here and it is deliberate — a tiebreak is a different scoring mode bolted
// onto a mode that is not yet proven, and 7-5 sets are not what anybody is complaining about.
// ponytail: add the tiebreak when a real match actually stalls at 6-6.
// Exported for the suite: the whole of a mode's scoring rule is worth pinning down, and the only
// other way in is winning twelve games through real rallies.
export function classicGame(side) {
const i = side === 'P' ? 0 : 1, j = 1 - i;
if (score.games[i] < 6 || score.games[i] - score.games[j] < 2) return false;
G.sets[i]++; score.games = [0, 0];
const mine = i === 0;
if (G.sets[i] >= TUNE.classicSets) { endClassic(mine); return true; }
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), sub1: mine ? 'THE SET IS YOURS' : 'SET TO THEM',
color: mine ? UI.pick : UI.hot, t: TUNE.setCardT };
G.overT = Math.max(G.overT, TUNE.setCardT);
return false;
}
// The match is over and NOBODY DIED — the one end-of-run state that is not a corpse. It reuses the
// death screen's whole shape (a held summary, then a gated tap that restarts) because that is the
// only thing about the two endings that is the same; the words, the colour and the still-standing
// player are all its own. update() freezes on G.done, exactly as it does on G.mode === 'eaten'.
export function endClassic(won) {
if (G.done) return;
G.done = { won, t: 0 };
ball.active = false; G.flash = null; G.modal = null; G.card = null;
if (won) { cheer(4, 1); applause(1, 0.2); voice('unbelievable', 0.4); }
else { aww(1); voice('game_opponent', 0.3); }
// g = GAMES PLAYED, s = SETS YOU WON — this mode's two headline numbers in the record's two slots.
// `dead` carries the RESULT: a match you lost is the run ending badly, a match you won is not,
// so the stats screen's DEATHS column reads as matches lost and (runs − deaths) as matches won,
// with no extra field to keep in step.
const b = endRun(G.runGames, G.sets[0], G.splatT, !won, MODE_CLASSIC);
G.best = b; G.newBest = b.newBest;
}
// splatter-mode scoring: points land mid-flight without interrupting play.
// quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point.
// The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde,
@@ -752,6 +794,7 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2);
// back themselves, so nothing here has to know the shape of any of it.
export function restartRun() {
resetZombies(); resetInput(); resetState(); setupPoint();
beginRun(); // ...and the stats bag, which is the only state that does not live on G
}
export function forceZombies() {
@@ -1230,6 +1273,16 @@ function mopRound(b, dt) {
// ---------------------------------------------------------------- update ----
export function update(dt) {
if (G.hitstop > 0) { G.hitstop -= dt; return; }
// THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the
// crowd is still clapping it down — and the only thing running is the clock the summary and its
// restart tap are gated on. Same shape as the block below, without a corpse in it.
if (G.done) {
G.idleT += dt; G.done.t += dt;
if (G.done.t >= EATEN_SUMMARY && !G.done.handed) { G.done.handed = true; menuDuck(false); }
crowd.cheer = Math.max(0, crowd.cheer - dt);
if (G.card && (G.card.t -= dt) <= 0) G.card = null;
return;
}
if (G.mode === 'eaten') { // the match is over in the truest sense
G.idleT += dt;
const was = G.eatenT; G.eatenT += dt;
@@ -1470,11 +1523,15 @@ export function update(dt) {
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
if (near) {
// rubber band: every game HE has taken makes him flub more — the opening match must not
// be a wall a new player grinds against for rounds (see aiFlubLoss in config)
if (Math.random() < TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss) G.aiSwingCool = 0.5; // whiff: hesitates and misses
// be a wall a new player grinds against for rounds (see aiFlubLoss in config).
// CLASSIC TENNIS turns the band OFF and flubs less: the band exists to get a new player
// PAST the opener, and a mode whose whole promise is a real match cannot be won by an
// opponent that falls apart the moment he goes ahead.
const flub = G.classic ? TUNE.classicFlub : TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss;
if (Math.random() < flub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
else {
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
trySwing(O, true); O.swingT = -1;
trySwing(O, true, G.classic ? TUNE.classicQualityMax : TUNE.aiQualityMax); O.swingT = -1;
G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12;
}
}
+39 -5
View File
@@ -3,7 +3,7 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js';
import { TUNE, COURTS, TOURS, MODES, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch } from './beats.js';
@@ -72,6 +72,26 @@ let picked = remembered('court') || COURTS[0].id;
let pickedTour = remembered('tour') || TOURS[0].id;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// --- MODE tiles, built from config.MODES. The setup screen now picks WHAT, WHO and WHERE, and this
// is the first of the three because it is the one that changes the other two's meaning. Same
// "tap selects, SERVE UP starts" rule as everything else on the screen; the row markup is the court
// row's, reused wholesale (see the shared selectors in index.html).
let pickedMode = remembered('mode') || MODES[0].id;
const modeList = document.getElementById('modeList');
for (const m of MODES) {
const b = document.createElement('button');
b.className = 'court' + (m.id === pickedMode ? ' on' : '');
b.innerHTML = `<img src="assets/${m.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${m.name}</b><i>${m.sub}</i></span>`;
b.addEventListener('click', () => {
pickedMode = m.id;
try { localStorage.setItem('mode', m.id); } catch {} // come back to what you were playing
for (const el of modeList.children) el.classList.toggle('on', el === b);
summary();
});
modeList.appendChild(b);
}
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
// the setup screen picks WHO and WHERE, and nothing on it commits you.
const tourList = document.getElementById('tours');
@@ -120,10 +140,16 @@ for (const c of COURTS) {
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const c = COURTS.find(x => x.id === picked) || COURTS[0];
const best = stats.total().best;
const m = MODES.find(x => x.id === pickedMode) || MODES[0];
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
// thing — see the record-shape note in stats.endRun
const mode = m.id === 'zombie' ? stats.MODE_MATCH : m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
const best = stats.read('all', undefined, mode).longest;
const unit = m.id === 'zombie' ? (best === 1 ? ' FELLED' : ' FELLED')
: best === 1 ? ' GAME' : ' GAMES';
document.getElementById('sSum').innerHTML =
`<span>${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + (best === 1 ? ' GAME' : ' GAMES') + ' TO BEAT' : 'FIRST RUN'}</span>`;
`<span>${m.name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
@@ -141,6 +167,12 @@ function start() {
selectCourt(picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
// ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes
// differ by at this point: gore is the game as it always was, classic is the same tennis with the
// turn switched off, and zombie tennis is the match taking the court. Everything else about a mode
// lives in the code that reads G.classic / G.match, never here.
G.classic = pickedMode === 'classic';
if (pickedMode === 'zombie') { intoSplatter(); startMatch(); }
}
let stPeriod = 'all';
const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); };
@@ -330,7 +362,9 @@ addEventListener('pagehide', () => {
// the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the
// bed that has been playing under the death summary simply carries on into the title screen.
const maybeRestart = () => {
if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return;
// ...and the classic ending taps out the same way, on its own shorter clock (G.done, no corpse)
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
if (G.modal || !ready) return;
restartRun();
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
+86 -37
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
@@ -195,7 +195,7 @@ export function proj(x, y, z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 91;
export const SPRV = 92;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
@@ -238,6 +238,11 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'z_s1','z_s2','z_s3','z_s4','z_sdown', // the chair spectator: 4-frame shuffle + the staggered pose
'z_c3','z_c4', // ...and the turned CPU walks on four frames too
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
// THE MATCH OPPONENT, drawn to play tennis rather than to shamble: ready stance,
// shuffle pair, forehand — and the same three poses again at each wound stage
// (…s is the swing). Its own family, so the turned CPU above keeps its shamble.
'z_r1','z_r2','z_r3','z_r4',
'z_rw1','z_rw1b','z_rw1s','z_rw2','z_rw2b','z_rw2s',
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel
'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS
@@ -256,6 +261,8 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'o_idle1_f','o_idle2_f','o_bounce_f','o_bounce2_f','o_toss_f','o_smash_f',
'o_run1_f','o_run2_f','o_swing1_f','o_swing2_f',
'z_c1_f','z_c2_f','z_c3_f','z_c4_f','z_cw1_f','z_cw2_f','z_cw1b_f','z_cw2b_f',
'z_r1_f','z_r2_f','z_r3_f','z_r4_f',
'z_rw1_f','z_rw1b_f','z_rw1s_f','z_rw2_f','z_rw2b_f','z_rw2s_f',
'scoreboard']) {
const img = new Image();
// ?v=SPRV, for the same reason index.html versions main.js. It is also what buys the year-long
@@ -561,14 +568,25 @@ function drawZombie(zb) {
if (zb.variant === 'o') return drawBarrel(zb);
if (zb.variant === 'm') return drawMachine(zb);
const rk = zb.variant === 'r';
const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket)
// THE MATCH OPPONENT HAS ITS OWN SPRITES NOW. It used to borrow the turned CPU's ('c') — a body
// drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing
// tennis points against you. The 'r' family is drawn off the LIVING opponent's poses instead (a
// ready stance, a shuffle pair and a real forehand, at every wound stage). 'c' keeps its shamble
// untouched, because in the gore mode shambling is exactly what the turned CPU should be doing.
const v = rk ? (SPR.z_r1 ? 'r' : 'c') : zb.variant; // 'c' until the new sheets have landed
const rNew = rk && v === 'r';
let N = ZFRAMES[v] || 2;
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
// The racket zombie holds a position and only moves when the ball makes him, so a time-driven
// cycle had him flicking between two frames on the spot. His stride steps off ground covered like
// the brute's — standing still is standing still — and his swing keeps its own frame.
let frame = rk && zb.pose === 'swing' ? 2
// The 'r' family's own cycle: 1 = the ready stance, held whenever racketAI is not running him;
// 2/3 = the shuffle; 4 = the forehand. `pose` is racketAI's own signal, so the picture and the
// behaviour cannot drift.
let frame = rNew ? (zb.pose === 'swing' ? 4
: zb.pose === 'run' ? stepFrame((zb.dist || 0) / TUNE.racketStride, 2) + 2 : 1)
: rk && zb.pose === 'swing' ? 2
: rk ? stepFrame((zb.dist || 0) / TUNE.racketStride, N) + 1
: ZSTRIDE[v] ? stepFrame(zb.z / (ZSTRIDE[v] * 2 / N), N) + 1 // heaviest bodies: step off DISTANCE
// WALKED, like their wound lurch below,
@@ -608,10 +626,22 @@ function drawZombie(zb) {
// boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors.
else if (rk) {
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1';
const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding
if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 5.2, 2)) name = b;
const st = f <= 0.34 ? 2 : f <= 0.67 ? 1 : 0; // 0 unhurt, 1 arm off, 2 headless
if (st === 2) h = 1.52; // headless: a head shorter, so the shoulders land where a whole zombie's do
if (st && rNew) {
// every wound stage carries the SAME three poses as the healthy body, so a one-armed
// opponent still plays tennis rather than reverting to a shamble the moment you wound it
const base = 'z_rw' + st;
const want = zb.pose === 'swing' ? base + 's'
: zb.pose === 'run' && SPR[base + 'b'] && stepFrame(zb.walkT * 5.2, 2) ? base + 'b'
: base;
if (SPR[want]) name = want; else if (SPR[base]) name = base;
} else if (st) { // the old borrowed 'c' skins, while they are all there is
if (st === 2 && SPR.z_cw2) name = 'z_cw2';
else if (st === 1 && SPR.z_cw1) name = 'z_cw1';
const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding
if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 5.2, 2)) name = b;
}
}
// The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
@@ -2052,11 +2082,13 @@ export function render() {
ctx.textAlign = 'left'; ctx.font = F(8, svr); ctx.fillStyle = svr ? bd.cream : bd.dim;
ctx.fillText(names[i], 18, cy);
ctx.textAlign = 'center';
if (splat) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } // sets
// ...and CLASSIC TENNIS shows them too: it is the one mode where a set is the thing you are
// actually playing for, and hiding the column left a match-scoring mode reading 0-0 all match.
if (splat || G.classic) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } // sets
ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games
ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot
}
if (splat) {
if (splat || G.classic) {
// This row used to be bare text on the court. That was fine over one dark green lawn and is
// unreadable over a sunlit clay crowd or a floodlit blue one, so it gets its own panel — the
// same trick the scoreboard art above it already relies on.
@@ -2071,12 +2103,15 @@ export function render() {
// ...and in the MATCH the run is not measured in games at all (blood-only scoring): the ladder
// rung you are on and the bodies behind you are what the player is playing for, so this slot
// prints those instead. Same slot, same size — the mode changes the words, not the furniture.
// ...and in CLASSIC it says how far through the MATCH you are, which is the one thing the
// board itself cannot: which set this is, and how many it takes to win.
ctx.fillText(G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}`
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
}
// ...and NOT during a match: nothing on that court is killed for points, so the pips were both a
// lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel.
if (splat && !G.match && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
if (splat && !G.match && !G.classic && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
for (let k = 0; k < TUNE.killsPerPoint; k++) {
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
@@ -2090,34 +2125,48 @@ export function render() {
if (G.mode === 'splatter' && !G.interlude && !G.drill) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
drawDrillHud(); // the triage readout — no-op unless a drill is running
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
// phase C: the summary fades UP over the death animation instead of cutting on top of it
if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) {
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
ctx.save(); ctx.globalAlpha = fade;
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
// The announcement face, at 26 rather than the card's 30: this is the longest string in the game
// and at 30 it ran edge to edge on a 270px-wide portrait canvas.
// The match bled to death rather than being eaten, and it counts a different thing — see the
// matchBest block in killPlayer. Same three lines, its own words: the measure is bodies felled.
// THE END OF A RUN, whichever way it ended. Three modes end three different ways and they share
// one screen — a wash, a headline, the run's own two numbers, the mark to beat and the gated tap
// — because only the WORDS differ, and three copies of this drift apart the first time one moves.
// eaten + gore GAME, SET, MASSACRE. n games, n sets
// eaten + match BLED OUT. n opponents felled, n games
// done + classic MATCH. / BEATEN. the set score
// phase C for the death: the summary fades UP over the animation instead of cutting on top of it.
{
const m = G.match && G.match.felled !== undefined ? G.match : null;
ctx.font = B(26);
shadowText(m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad);
ctx.font = F(9);
shadowText(m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S')
+ ' · ' + m.games + ' GAMES'
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14, UI.cream);
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4));
ctx.font = B(20);
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse));
} else if (m ? m.best > 0 : (G.best && G.best.games > 0)) {
ctx.font = F(8);
shadowText(m ? 'BEST ' + m.best + ' FELLED'
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
const dead = G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY, done = G.done && G.done.t > 0.5;
if (dead || done) {
const clk = dead ? G.eatenT - EATEN_SUMMARY : G.done.t - 0.5;
const fade = Math.min(1, clk / 0.6);
const won = !dead && G.done.won;
ctx.save(); ctx.globalAlpha = fade;
ctx.fillStyle = won ? 'rgba(6,10,8,0.72)' : 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
// The announcement face, at 26 rather than the card's 30: this is the longest string in the
// game and at 30 it ran edge to edge on a 270px-wide portrait canvas.
const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') : won ? 'GAME, SET, MATCH.' : 'BEATEN.';
const line = dead
? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES'
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS')
: G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + G.runGames + ' GAMES';
ctx.font = B(26); shadowText(title, W / 2, H / 2 - 44, won ? UI.pick : UI.bad);
ctx.font = F(9); shadowText(line, W / 2, H / 2 - 14, UI.cream);
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(clk * 4));
ctx.font = B(20);
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse));
} else if (G.best && G.best.games > 0) {
ctx.font = F(8);
shadowText(m ? 'BEST ' + G.best.games + ' FELLED'
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
}
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) {
ctx.font = F(8);
shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm);
}
ctx.restore();
}
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
if (G.eatenT > EATEN_RESTART) { ctx.font = F(8); shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm); }
ctx.restore();
}
// Timed freezes swallow every input until they run out, which reads as the game having hung
// ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is
+7
View File
@@ -108,6 +108,13 @@ export const G = {
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,
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.
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
+6 -2
View File
@@ -26,8 +26,12 @@ let run = bag();
// count lives on G and survives the flush, so a second endRun would sail past a "nothing happened"
// guard and bank a duplicate with zero kills. `pagehide` really does fire more than once — the
// bfcache dismisses and restores the same page — which is exactly how this was caught.
// Cleared by the next kill, so a fresh run re-arms itself with no reset call to forget.
// Cleared by the next kill — which was the whole of the reset until CLASSIC TENNIS arrived, a mode
// with NO KILLS IN IT AT ALL. There, nothing ever cleared the flag, so the second and every later
// match of a session was silently swallowed by the guard below. beginRun() is the honest reset and
// gameplay.restartRun (the one "a new run starts here" moment there is) calls it.
let banked = false;
export function beginRun() { run = bag(); banked = false; }
// ---------------------------------------------------------------- storage ----
// Private-mode Safari throws on both read and write. Stats are a nicety; a menu that will not open
@@ -88,7 +92,7 @@ export function multiKill() { run.mk++; }
// opponents felled and has exactly one kill per opponent, by definition, always credited to 'bomb'
// (matchGameWon's explode). Un-tagged records predate the split and ARE gore runs, which is why the
// default is 'g' everywhere and no migration is needed.
export const MODE_GORE = 'g', MODE_MATCH = 'z';
export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c';
const modeOf = r => r.m || MODE_GORE;
export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {