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

+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