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

+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.