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:
1 parent
098aefeb63
commit
f9b26e5380
8 files changed
+202
-22
No files matched your search
+20
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user