THE TOURNAMENT, the pace-lit trail, and brighter opponents

The fourth mode: four rounds up the dead hierarchy (CUP_ROUNDS), each round
drawing a rung from its band, the champion always the final. It rides the
match machinery whole — cupDraw feeds the drawn rung through d.opp so every
ladder dial scales untouched; the body's pool spans the round (need-scaled
pool and divisor, so wounds carry between games); cupGameWon holds the same
body mid-round, kills it on the round, and past the final cupWon banks a
NOT-dead match run under CHAMPION OF THE DEAD CLUB. Blood carries; round one
is a single game; deathmatch stays as the arcade run. Full-run state test.

Sidequests: the ghost trail is pace-qualified (TUNE.trailSpeed) so both
tennis modes streak — pow>0.8 was only ever reached by the gore slingshot;
opponent sprites draw ~18% brighter via a load-time baked copy
(render.brighten, TUNE.oppBright) with no PNG touched; the cup scoreboard
line names the round instead of lying with deathmatch arithmetic; the rack
and the apron demo no longer draw under a trophy screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 22:20:10 +02:00
1 parent 098aefeb63
commit f9b26e5380
8 files changed
+202 -22

No files matched your search

+20
View File
@@ -434,6 +434,26 @@ vetoed (*"the camera movement is distracting"*) and its knobs sit at 0 — see t
(~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these
two before touching anything else.
## Session log: THE TOURNAMENT (2026-08-13, same day — Oliver picked it over the endless ladder)
The fourth mode tile: four rounds up the dead hierarchy (`config.CUP_ROUNDS` — each round DRAWS a
rung from its band, the final is always THE CHAMPION), riding the whole match machinery and
differing in exactly three places: who walks on (`cupDraw` puts the drawn rung in `d.opp`, so every
ladder dial downstream scales untouched), how deep the body's pool is (`matchOpponent` multiplies
by the round's `need` and `matchPoint` divides the same way, so wound stages SPREAD across the
round — the body enters game two missing what game one cost it), and where a won game goes
(`cupGameWon`: mid-round the same body plays on; round won is the kill + mop beat + next draw;
past the final, `cupWon` banks a NOT-dead match run and holds CHAMPION OF THE DEAD CLUB on the
classic win's summary shape). Blood carries; the trainer's heal-per-game is the distance. Round
one is a single game so a run opens fast; deathmatch stays beside it as the arcade mode.
Also this session: the fall ANTICIPATES the pass (a winner past the net with a closed-form
unreachable landing resolves mid-flight — plus the soft-lock the first version shipped: the
decided-latch must die with its rally, see the beats/gameplay comments); the fall table lost its
rise row and tightened its drop; the ghost trail is PACE-qualified so both tennis modes streak
(`trailSpeed`); and opponent sprites draw brighter via a load-time baked copy
(`render.brighten`, `TUNE.oppBright`) — no PNG touched.
## Session log: the serve gets stakes (2026-08-13, same day)
Oliver: *"the golden mark is just very easy to hit and hitting below it does not necessarily make a
+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=222"></script>
<script type="module" src="src/main.js?v=224"></script>
</body>
</html>
+92 -10
View File
@@ -7,7 +7,8 @@
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage } from './config.js';
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
@@ -320,9 +321,14 @@ export function watchdog(dt) {
// the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
export function startMatch() {
export function startMatch(cup = false) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
G.match = { opp: 1, ending: false };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
// by the round's `need`), and where a won game goes (cupGameWon). See config.CUP_ROUNDS.
if (cup) { G.match.cup = { round: 0, g: 0 }; cupDraw(); }
G.matchFall = null;
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
// mode's 4 was "too weak" here, felt not calculated
@@ -335,7 +341,12 @@ export function startMatch() {
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
if (!G.seen.match) {
if (cup && !G.seen.cup) {
G.seen.cup = true;
showModal({ title: 'THE TOURNAMENT', color: UI.hot,
body: ['FOUR ROUNDS UP THE DEAD HIERARCHY',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
} else if (!cup && !G.seen.match) {
G.seen.match = true;
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
// number, so the modal can never drift from the pool it describes.
@@ -346,6 +357,14 @@ export function startMatch() {
return true;
}
// The round's rung, drawn from its band. d.opp carries it so matchFoe, the ladder speed dials and
// the felled arithmetic all read the drawn rung with no cup-awareness of their own.
function cupDraw() {
const cup = G.match.cup, R = CUP_ROUNDS[cup.round];
cup.rung = R.band[Math.floor(Math.random() * R.band.length)];
G.match.opp = cup.rung;
}
// Every point starts like a real one: the server behind his line (setupPoint's tennis branch) and
// THE RECEIVER ON ITS MARK — the zombie is dropped onto the receiver position the tennis branch
// just computed for O, instead of standing wherever the last rally abandoned it. Oliver: "it spawns
@@ -530,12 +549,17 @@ function matchOpponent() {
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS)
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art).
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
// of the player should last longer")
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam };
zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure
// scoreboard (a quarter per point), so a deeper pool would
// change nothing — the ladder escalates in racketAI instead
// (matchSpeedPer / matchFlubKeep)
// of the player should last longer"). In the cup the round names itself on the third line.
const cup = G.match.cup;
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam,
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined };
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
// game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
// matchPoint divides the same way, so the wound stages spread across the whole round instead of
// being spent in game one — the body enters game two already missing what game one cost it.
zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? CUP_ROUNDS[cup.round].need : 1);
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
}
@@ -560,7 +584,8 @@ function matchPoint(side) {
// and only the game-winning point actually fells it, so the body and the scoreboard agree all
// the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.)
const was = racketStage(zb);
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4));
const div = 4 * ((d.cup && CUP_ROUNDS[d.cup.round].need) || 1); // the cup spreads the pool over the round
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / div));
// ONLY when the stage actually moved. A point that chips the pool without crossing a threshold
// has no limb to explain, and a second of collapse for nothing is a beat the player waits out.
if (racketStage(zb) !== was) fellFrom = was;
@@ -592,6 +617,7 @@ function matchPoint(side) {
function matchGameWon(zb, banked = false) {
const d = G.match;
if (d.cup) return cupGameWon(zb, banked);
d.ending = true; // killZombie below re-enters resolveInterlude
if (zb && zombies.includes(zb)) explode(zb.x, zb.z); // the game-winning point IS the kill
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
@@ -638,6 +664,62 @@ function matchGameWon(zb, banked = false) {
G.phase = 'over'; G.overT = d.mopT + 2.2; // ticks down through the beat; reset at the walk-on
}
// THE CUP'S GAME. Mid-round: the same body plays on, wearing what the games have cost it. Round
// won: the kill, the mop beat, and the next round's draw — or, past the final, the trophy.
function cupGameWon(zb, banked = false) {
const d = G.match, cup = d.cup, R = CUP_ROUNDS[cup.round];
d.ending = true;
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++;
const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp;
G.hp += healed;
if (healed > 0)
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
voice('game_player', 0.3); cheer(2.4, 0.6);
cup.g++;
if (cup.g < R.need) {
// the round is still alive: no kill, no mop beat — the body keeps its wounds and its feet
G.flash = { text: 'GAME.', t: 1.4 };
G.card = { title: R.name, sub1: 'YOU LEAD ' + cup.g + ' OF ' + R.need + ' — SAME BODY, FEWER PIECES',
color: UI.pick, t: 3.0 };
score.server = other(score.server);
d.ending = false;
matchReady();
G.phase = 'over'; G.overT = 2.2;
return;
}
// the round is won: the corpse comes apart
if (zb && zombies.includes(zb)) explode(zb.x, zb.z);
cup.round++; cup.g = 0;
if (cup.round >= CUP_ROUNDS.length) return cupWon();
G.flash = { text: R.name + ' — YOURS.', t: 1.8 };
// the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw
sendBoy(0, 4.5, true);
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
G.interlude = { introT: d.mopT + 1.6 };
cupDraw(); // the next round's rung -> d.opp
score.server = other(score.server);
G.phase = 'over'; G.overT = d.mopT + 2.2; // d.ending stays true until the walk-on tick
}
// CHAMPION OF THE DEAD CLUB — the run's other ending, on the classic win's whole shape (a held
// summary over a still court, then the gated restart). Banked through the same endRun as every
// match run, not dead: the stats screen's deaths column keeps meaning what it means.
function cupWon() {
const d = G.match;
d.ending = false; d.mopT = 0;
G.interlude = false;
ball.active = false; G.flash = null; G.modal = null; G.card = null;
cheer(4, 1); voice('unbelievable', 0.4);
const games = score.games[0] + score.games[1];
const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
d.felled = CUP_ROUNDS.length; d.games = games; d.best = b.games;
G.done = { won: true, t: 0, cup: true };
}
function matchGameLost() {
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
// wounds, the scoreboard banks their game, and the match simply plays on.
+19
View File
@@ -46,6 +46,10 @@ export const TUNE = {
faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip).
// The rolled cycles hold a real racket, and an un-earned flip teleports it
// across the body — the "racket flashing left and right" report
trailSpeed: 16, // m/s of horizontal pace at which a rally ball earns the ghost streak in every
// mode (the pow>0.8 gate alone was only ever reached by the gore slingshot)
oppBright: 1.18, // brightness baked into every OPPONENT sprite copy at load (render.brighten) —
// the far bodies read dark against the crowd. Load-time: reload to retune
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
// tape AND come down on a target, so it got throttled back to walking pace.
@@ -630,9 +634,24 @@ export const COURTS = [
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: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', tile: 'z_champ1' },
{ id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', tile: 'z_boss1' },
];
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
// dead hierarchy, each round DRAWING one rung from its band so runs differ, the final always THE
// CHAMPION. `need` is games to take the round — round one is a single game so a run opens fast —
// and the drawn body's pool scales with need (see matchOpponent), so its wound stages spread
// across the whole round instead of being spent in game one. Blood carries between rounds; the
// trainer's heal-per-game is what makes the distance survivable. Deathmatch (id 'zombie') stays
// beside it as the arcade run.
export const CUP_ROUNDS = [
{ name: 'ROUND ONE', band: [1, 2, 3], need: 1 },
{ name: 'QUARTER-FINAL', band: [4, 5, 6], need: 2 },
{ name: 'SEMI-FINAL', band: [7, 8, 9, 10, 11], need: 2 },
{ name: 'THE FINAL', band: [12], need: 2 },
];
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, tossDx: -0.59 },
+2
View File
@@ -177,6 +177,7 @@ function start(modeId = pickedMode) {
// lives in the code that reads G.classic / G.match, never here.
G.classic = modeId === 'classic';
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
if (modeId === 'cup') { intoSplatter(); startMatch(true); }
}
let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
@@ -459,6 +460,7 @@ if (location.hash === '#laser') setTimeout(laserScene, 600);
// #drill — the same door from a URL, so a phone can be sent straight into the mode
if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter(); startDrill(); }, 700);
if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700);
if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700);
if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700);
requestAnimationFrame(frame);
+41 -10
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 { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, 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, HEAD_CROP } from './config.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, 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, HEAD_CROP, CUP_ROUNDS } 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';
@@ -309,11 +309,35 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
// second failure, or one genuinely missing file hangs the boot bar forever.
let retried = false;
LOAD.total++;
img.onload = () => { SPR[n] = img; LOAD.done++; };
img.onload = () => { SPR[n] = brighten(n, img); LOAD.done++; };
img.onerror = () => { if (retried) LOAD.done++; else { retried = true; img.src = url + '&r=1'; } };
img.src = url;
}
// OPPONENT BODIES DRAW BRIGHTER, without touching a single PNG (Oliver: "make the opponent
// sprites brighter in game — filter or something — without changing the spritesheet files").
// Baked ONCE at load into an offscreen copy: an additive self-blend at alpha (b-1) is an exact
// brightness multiply with no per-pixel loop and no per-frame ctx.filter (which iOS Safari's
// canvas doesn't reliably support). Covers every far-court body — the zombie families and the
// tennis CPU — and leaves the player, the boys, the furniture and the HUD art untouched.
// TUNE.oppBright is read at LOAD, so retuning it needs a reload, not just the panel.
function brighten(n, img) {
if (!(n[0] === 'z' && n[1] === '_') && !(n[0] === 'o' && n[1] === '_')) return img;
const b = TUNE.oppBright || 1;
if (b === 1) return img;
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const g = c.getContext('2d');
g.drawImage(img, 0, 0);
g.globalCompositeOperation = 'lighter';
g.globalAlpha = b - 1;
g.drawImage(img, 0, 0);
g.globalCompositeOperation = 'destination-in'; // re-clip: additive blending fattened the alpha
g.globalAlpha = 1;
g.drawImage(img, 0, 0);
return c;
}
// ------------------------------------------------------------------ crowd ----
function buildCrowd() {
crowd.dots.length = 0;
@@ -2109,10 +2133,12 @@ export function render() {
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex);
drawBall(bx, by, sv.z);
}
// hot-ball ghost streak: a rally ball above 0.8 pow drags three fading ghosts behind it. Serves
// already have the erasing arc band; this gives the volley its own read. Render-owned visual
// state (like the crowd dots) — gameplay never sees it.
const hot = ball.active && (ball.pow || 0) > 0.8 && G.phase === 'rally' && !ball.collected;
// hot-ball ghost streak: a fast rally ball drags three fading ghosts behind it. It gated on
// pow > 0.8 alone, which in practice only the gore slingshot ever reached — tennis tap swings
// live below it, so both tennis modes played without the streak (Oliver's sidequest). PACE now
// qualifies a ball too: a flat serve or a clean winner streaks in every mode, a lob stays clean.
const hot = ball.active && G.phase === 'rally' && !ball.collected
&& ((ball.pow || 0) > 0.8 || Math.hypot(ball.vx, ball.vz) > TUNE.trailSpeed);
if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 7) ballHist.shift(); }
else if (ballHist.length) ballHist.length = 0;
if (hot) {
@@ -2337,7 +2363,10 @@ export function render() {
// 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`
// the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here
ctx.fillText(G.match && G.match.cup
? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED`
: 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);
}
@@ -2354,7 +2383,7 @@ export function render() {
ctx.restore();
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
if (!G.bench) drawBlood(); // how many more hits you have left, beside the board — the vignette says "worse", not "how many"
if (G.mode === 'splatter' && !G.interlude && !G.drill && !G.bench) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
if (G.mode === 'splatter' && !G.interlude && !G.drill && !G.bench && !G.done) drawRack(); // ...and never under a trophy screen // 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
drawBenchHud(); // ...and the sprite bench's, same rule
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
@@ -2376,10 +2405,12 @@ export function render() {
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 title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
: G.done.cup ? 'CHAMPION OF THE DEAD CLUB.' : 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.done.cup ? 'THE CHAMPION FELL · ' + ((G.match && G.match.games) || G.runGames) + ' GAMES'
: 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);
@@ -2422,7 +2453,7 @@ export function render() {
// to aim, which is the one thing that does nothing there.
// after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words —
// so this slot shuts up rather than printing the same instruction a second time 40 px below it
const demo = G.mode === 'splatter' && !G.interlude && !gunLike && G.idleHintT > TUNE.pullHintAfter;
const demo = G.mode === 'splatter' && !G.interlude && !G.done && !gunLike && G.idleHintT > TUNE.pullHintAfter;
// NO GHOST HAND ON THE TENNIS SERVE — not the idle tap demo, not the apex rider. Oliver: "this
// ghost finger over the running serve animation is distracting. this must be removed. it is
// not in zombie tennis mode" — and the duel serving fine without a teacher is the argument.
+2 -1
View File
@@ -306,7 +306,8 @@ export function killPlayer() {
// record's two headline slots (see the shape note there), so the best is derived from the record
// rather than stored a second time in its own key and left to drift.
const m = G.match;
const felled = m ? m.opp - 1 : 0, gamesPlayed = score.games[0] + score.games[1];
// the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds
const felled = m ? (m.cup ? m.cup.round : m.opp - 1) : 0, gamesPlayed = score.games[0] + score.games[1];
const b = m ? endRun(felled, gamesPlayed, G.splatT, true, MODE_MATCH)
: endRun(G.runGames, G.sets[0], G.splatT);
G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets
+25
View File
@@ -811,6 +811,31 @@ test('an ordinary far winner keeps its ordinary ending, and the match plays on (
assert.equal(score.pts[0], pts + 2, 'the match plays on');
});
test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => {
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
assert.ok(d.cup, 'the cup rides the match machinery');
for (let round = 0; round < CUP_ROUNDS.length; round++) {
const R = CUP_ROUNDS[round];
assert.equal(d.cup.round, round, 'on ' + R.name);
assert.ok(R.band.includes(d.opp), R.name + ' drew from its band (rung ' + d.opp + ')');
const zb = oppOf();
assert.equal(zb.hpMax, TUNE.matchOppHp * R.need, 'the pool spans the whole round');
for (let g = 1; g <= R.need; g++) {
for (let k = 0; k < 4; k++) resolveInterlude('P');
G.modal = null; G.modalQ.length = 0; // the trainer's popup, tapped away
if (g < R.need)
assert.ok(zombies.includes(zb), R.name + ' game ' + g + ': the same body plays on');
}
assert.ok(!zombies.includes(zb), R.name + ': the round-winning game is the kill');
if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on
}
assert.ok(G.done && G.done.cup && G.done.won, 'the champion fell: the trophy screen holds');
assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run');
});
test('service alternates by game, and the opponent actually serves', () => {
// Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return