The dead club doubles its staff, and the draw is made where you can see it

Six new opponent families (head coach, stringer, strawberry seller, chair
umpire, spectator, roller man) through the famroll/zsheet route, placed by
hierarchy into an 18-rung ladder; the champion steps up behind them.

THE DRAW: picking the tournament now rolls its own bracket in front of you —
only the final was ever settled — and what lands is what walks on.

Netplay, off Oliver's cellular report: the mid-flight early-decide was missing
its !G.net gate, so the striker judged its own outbound flight and the two
scoreboards drifted a point apart into a court nobody could serve on. The
host's court now rides the room, its scoreboard rides the pretoss stream, and
a called ball rolls to its own halt instead of freezing where the call caught it.

Plus the night court, and somebody in the stand is taking pictures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 11:49:19 +02:00
1 parent 4c47295a73
commit 9cc3ac54b3
207 files changed
+632 -81

No files matched your search

+80 -7
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, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } 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, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
@@ -117,7 +117,8 @@ function netChip(label, join, death = false) {
if (!code) return;
}
start('classic'); // the waiting court is classic either way; a death room swaps it on pairing
if (startNetVs()) netConnect(code, death);
// the host's court rides the room too (server.py) — the guest plays where the host plays
if (startNetVs()) netConnect(code, death, picked);
});
return b;
}
@@ -219,6 +220,69 @@ for (const c of COURTS) {
courtList.appendChild(b);
}
// ---- THE DRAW (Oliver, 2026-08-15: "when starting the tournament there should be a view of the
// tournament hierarchy but only the finale is settled — the game against the champion... random
// browsing through the roster in the individual spots which begins fast, gets slower and then
// settles on a random character, so that every tournament is new").
//
// THE SCREEN IS THE DRAW, NOT A TEASER. `cupPicks` is what walks on: startMatch takes it and
// cupDraw reads it instead of rolling its own, so the face you watched land in ROUND ONE is the
// body across the net. A screen that showed a different draw from the one being played would be
// worse than no screen — it would teach the player that the ceremony is a lie.
//
// The roll is one recursive setTimeout per row whose gap grows with p^2 — fast, then visibly
// slowing, then stopped. No rAF: the menu deliberately parks the game loop (see menuUp), and three
// staggered timers are the whole animation.
const cupRows = document.getElementById('cupRows'), cupGo = document.getElementById('cupGo');
let cupPicks = null, cupSpins = [];
// the portrait: every family's own head crop (assets/_heads.py), the club pro's included — the
// same file the in-play walk-on card uses, so the draw and the court show the same face
const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`;
function buildBracket() {
for (const t of cupSpins) clearTimeout(t);
cupSpins = [];
cupPicks = CUP_ROUNDS.map(r => r.band[Math.floor(Math.random() * r.band.length)]);
cupRows.innerHTML = '';
cupGo.disabled = true;
document.getElementById('brState').textContent = 'DRAWING…';
document.getElementById('brSum').innerHTML =
`<span>ONLY THE FINAL WAS EVER SETTLED</span><span>${
(COURTS.find(c => c.id === picked) || COURTS[0]).name}</span>`;
CUP_ROUNDS.forEach((r, i) => {
const fixed = r.band.length === 1; // the final: one rung, nothing to roll
const row = document.createElement('div');
row.className = 'cupRow' + (fixed ? ' fin set' : '');
row.innerHTML = `<u>${r.name}</u><img alt=""><b></b><i>${
r.need === 1 ? 'ONE GAME' : r.need + ' GAMES'}</i>`;
cupRows.appendChild(row);
const img = row.querySelector('img'), nm = row.querySelector('b');
const show = n => { const f = matchFoe(n); img.src = foeArt(f); nm.textContent = f.name; };
if (fixed) return show(cupPicks[i]);
const t0 = performance.now(), len = 1500 + i * 550; // 1.5 / 2.05 / 2.6 s — they land in order
(function roll() {
const p = Math.min(1, (performance.now() - t0) / len);
if (p >= 1) {
show(cupPicks[i]);
row.classList.add('set');
if (cupRows.querySelectorAll('.set').length === CUP_ROUNDS.length) {
cupGo.disabled = false;
document.getElementById('brState').textContent = 'THE DRAW IS MADE';
}
return;
}
show(r.band[Math.floor(Math.random() * r.band.length)]);
cupSpins.push(setTimeout(roll, 45 + 330 * p * p));
})();
});
}
document.getElementById('brBack').addEventListener('click', () => {
for (const t of cupSpins) clearTimeout(t); // a screen you walked out of must stop spinning
cupSpins = [];
go('setup');
});
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// what the setup footer says you are about to play, and what it is worth beating
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
@@ -267,7 +331,9 @@ function start(modeId = pickedMode) {
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
if (modeId === 'cup') { intoSplatter(); startMatch(true); }
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
// means "roll it yourself", which is what the mode did before the screen existed.
if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); }
}
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
@@ -328,7 +394,12 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle
// SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or
// ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click
// handler always passes something.
document.getElementById('cStart').addEventListener('click', () => start());
// SERVE UP starts the run — except in the cup, where the draw has to be made first. That screen
// carries its own start button, so this stays the one place a run begins from the setup screen.
document.getElementById('cStart').addEventListener('click', () => {
if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; }
start();
});
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
// the wizard walks back the way it came: who-and-where returns to the mode's own second step when
@@ -476,7 +547,9 @@ addEventListener('keydown', e => {
if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface
const n = parseInt(e.key, 10);
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
else if (e.key === 'Enter' || e.key === ' ') start();
// through the BUTTON, not start() — the cup's draw screen sits between the two and a keyboard
// must not be the one way to walk past it
else if (e.key === 'Enter' || e.key === ' ') document.getElementById('cStart').click();
});
// A run the player walks out on is still a run. killPlayer banks a death; this banks everything
@@ -590,11 +663,11 @@ if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter();
// #net hosts classic, #netd hosts a death match, #net-ABCD joins — the netplay doors from a URL
if (location.hash === '#netd') setTimeout(() => {
start('classic');
if (startNetVs()) netConnect(null, true);
if (startNetVs()) netConnect(null, true, picked);
}, 700);
else if (location.hash.startsWith('#net')) setTimeout(() => {
start('classic');
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null);
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null, false, picked);
}, 700);
requestAnimationFrame(frame);