Triple-tap the scoreboard for a sandbox

A test mode for trying the new content without playing twenty games to reach it.
Three taps on the scoreboard toggles it, three more turns it off, and a single
tap is still the miss-log overlay. Same shape as the umpire triple-tap cheat it
sits next to.

It OVERRIDES the difficulty director rather than editing it, so the wave is the
same whether you opened it in game 1 or game 40 and the run keeps running
underneath: 8 bodies, a fixed 1.15 m/s shamble, a spawn every 1.6 s, and
pickVariant pretending to be at game 20 so every body type can turn up. All four
barrel weapons sit on the rack and are topped back up each frame, because
committing a burst spends the whole tin and a sandbox that runs dry is not one.
A barrel rolls out every 14 s so the pickup itself is testable too.

The third tap must NOT toggle logShow: taps one and two already toggled it twice
and it is back where it started, so toggling again left the miss-log overlay
switched on behind you every single time.

Also records the trap that cost a debugging round getting here: index.html loads
main.js?v=N, and fetching 'main.js' does not bust that URL -- so main.js stayed
stale through a reload while every other module was fresh, and the tap handler
appeared to do nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 14:20:14 +02:00
1 parent 282f5145a9
commit 46e268973d
7 files changed
+101 -12

No files matched your search

+23
View File
@@ -320,11 +320,34 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so
every legal ball stays catchable. every legal ball stays catchable.
## TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the
scoreboard (the same shape as the umpire's triple-tap cheat next to it in main.js) toggles
`G.testMode`; three more turns it off, and a SINGLE tap is still the miss-log overlay.
- a fixed MEDIUM wave: `testZMax` 8 bodies, `testZSpeed` 1.15 m/s, `testSpawnEvery` 1.6 s — it
overrides the difficulty director rather than editing it, so the sandbox is the same whether you
opened it in game 1 or game 40, and `G.runGames` keeps climbing underneath.
- `testLevel` 20 is the roster level `pickVariant` pretends to be at, so every body type can appear.
- every barrel weapon on the rack, TOPPED BACK UP each frame (`stockTestRack`), so committing a
burst — which spends the whole tin — leaves it available again a moment later.
- a barrel every `testBarrelEvery` seconds, so the pickup itself is testable.
**The third tap must not toggle `logShow`.** Taps one and two already toggled it twice, so it is
back where it started; toggling again in the triple branch leaves the miss-log overlay switched on
behind you every time you enter the mode.
## Verification recipe (the regression net) ## Verification recipe (the regression net)
Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval
context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with
`window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap. `window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap.
Clear the intro first: `document.getElementById('intro')?.remove()`. Clear the intro first: `document.getElementById('intro')?.remove()`.
- **The module-refresh list MUST include the VERSIONED entry point.** `index.html` loads
`main.js?v=N`, and `fetch('main.js', {cache:'reload'})` does not bust that URL — so main.js alone
stays stale through a reload while every other module is fresh. Read the src off the page:
`document.querySelector('script[type=module]').getAttribute('src')` and fetch that too. Symptom: a
change in main.js appears to do nothing at all, repeatably.
- **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so - **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so
the mobile assist is off and every ball reads as "never reachable". the mobile assist is off and every ball reads as "never reachable".
- Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead). - Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead).
+9
View File
@@ -230,6 +230,15 @@ export const TUNE = {
mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage." mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage."
dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them
mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
// ---- TEST MODE (triple-tap the scoreboard). A sandbox for trying the new content: a steady
// MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
// kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
// editing it — G.runGames keeps climbing underneath and the run is still a real run.
testZMax: 8, // bodies on court: enough to test a crowd weapon, few enough to read
testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
testSpawnEvery: 1.6,// s between spawns
testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
testBarrelEvery: 14,// s between barrels, so the pickup itself is testable too
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane // ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear
beamW0: 0.35, // m half-width at the racket... beamW0: 0.35, // m half-width at the racket...
+38 -1
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX } from './config.js'; import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js'; import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
@@ -548,6 +548,42 @@ export function weaponModal(id, healLine = null) {
icon: w.icon, iconH: 40, color: UI.warm }); icon: w.icon, iconH: 40, color: UI.warm });
} }
// ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing
// twenty games to reach it: a steady medium wave and every barrel weapon on the rack, topped back up
// as you spend it. It does NOT stop the run — games, duels and bosses all still happen underneath;
// it only overrides the difficulty dials and keeps the rack stocked.
export function toggleTestMode() {
G.testMode = !G.testMode;
if (!G.testMode) { G.flash = { text: 'TEST MODE OFF', t: 1.4 }; return false; }
if (G.mode === 'tennis') forceZombies(); // no zombies to test against in a tennis match
stockTestRack();
G.flash = { text: 'TEST MODE', t: 1.6 };
showModal({ title: 'TEST MODE', color: UI.pink,
body: ['A STEADY MEDIUM WAVE', 'EVERY BARREL WEAPON, KEPT FULL',
'', 'TRIPLE-TAP THE SCOREBOARD AGAIN TO STOP'] });
return true;
}
// every barrel weapon on the rack at full stock. Called on entry and then kept topped up in update,
// so a burst you commit (which spends the whole tin) is available again a moment later.
function stockTestRack() {
for (const id of Object.keys(BARREL_WEAPONS)) {
const have = G.rack.find(e => e.id === id);
if (have) have.n = Math.max(have.n, WEAPONS[id].tin);
else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin });
}
}
let testBarrelT = 0;
export function tickTestMode(dt) {
if (!G.testMode) return;
stockTestRack();
if (G.mode !== 'splatter' || G.bossFight || G.interlude) return;
if ((testBarrelT -= dt) <= 0) {
testBarrelT = TUNE.testBarrelEvery;
if (!zombies.some(z => z.variant === 'o')) spawnBarrel();
}
}
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie. // ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching. // Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed. // This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
@@ -1197,6 +1233,7 @@ export function update(dt) {
// --- zombies + transition timeline // --- zombies + transition timeline
if (G.mode !== 'tennis') updateZombies(dt); if (G.mode !== 'tennis') updateZombies(dt);
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
if (G.meleeT > 0) G.meleeT -= dt; if (G.meleeT > 0) G.meleeT -= dt;
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt); if (G.mode === 'transition') transition(dt);
+1 -1
View File
@@ -179,6 +179,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=99"></script> <script type="module" src="main.js?v=100"></script>
</body> </body>
</html> </html>
+18 -5
View File
@@ -5,7 +5,7 @@
import { TUNE, COURT, COURTS, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { TUNE, COURT, COURTS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt } from './state.js'; import { P, O, ball, score, G, setupPoint, selectCourt } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js'; import { fitViewport, render, proj, RES } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
@@ -166,13 +166,26 @@ const maybeRestart = () => {
cv.addEventListener('pointerdown', maybeRestart); cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart); addEventListener('keydown', maybeRestart);
// hidden shortcut: tap the umpire three times to summon zombie mode early // hidden shortcuts: three taps on the UMPIRE summons zombie mode early, three on the SCOREBOARD
let umpTaps = []; // opens test mode (a medium wave plus every barrel weapon — see gameplay.toggleTestMode)
let umpTaps = [], boardTaps = [];
cv.addEventListener('pointerdown', e => { cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect(); const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
const y = (e.clientY - r.top) / r.height * (cv.height / RES); const y = (e.clientY - r.top) / r.height * (cv.height / RES);
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { // the scoreboard
const now = performance.now();
boardTaps = boardTaps.filter(t => now - t < 1600);
boardTaps.push(now);
if (boardTaps.length >= 3) {
boardTaps = [];
// taps one and two already toggled the miss-log twice, so it is back where it started —
// this third one must NOT toggle it again, or every trip through test mode leaves the
// overlay on behind you
toggleTestMode();
} else G.logShow = !G.logShow; // a single tap is still the miss-log overlay
return;
}
const u = proj(UMP_X, 0, COURT.netZ); const u = proj(UMP_X, 0, COURT.netZ);
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return; if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
const now = performance.now(); const now = performance.now();
@@ -184,6 +197,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
step: dt => update(dt), render, press: keyPressed, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+1
View File
@@ -58,6 +58,7 @@ export const G = {
mgCool: 0, // s until the next pellet in that burst mgCool: 0, // s until the next pellet in that burst
gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour
beam: null, // the laser's live wedge {x, z, ux, uz, t} — drawn, then gone. Not a projectile. beam: null, // the laser's live wedge {x, z, ux, uz, t} — drawn, then gone. Not a projectile.
testMode: false, // the scoreboard triple-tap sandbox: medium wave, every new weapon on the rack
deliveries: 0, // tins delivered this run — indexes the weapon ladder deliveries: 0, // tins delivered this run — indexes the weapon ladder
duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court
afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves
+11 -5
View File
@@ -34,8 +34,10 @@ export function spawnZombie(x, z, variant) {
// Keyed on GAMES FINISHED, not on splatT: the old `1 + min(1.5, splatT / 70)` maxed out after // Keyed on GAMES FINISHED, not on splatT: the old `1 + min(1.5, splatT / 70)` maxed out after
// 105 seconds of splatter, so every shambler from roughly game 3 onward walked at the same top // 105 seconds of splatter, so every shambler from roughly game 3 onward walked at the same top
// speed for the rest of a 50-game run. Games are what the player experiences as progress. // speed for the rest of a 50-game run. Games are what the player experiences as progress.
// test mode pins the shamble at a fixed MEDIUM speed instead of riding the per-game ramp
const ramp = 1 + Math.min(TUNE.zSpeedMax, G.runGames * TUNE.zSpeedPerGame); const ramp = 1 + Math.min(TUNE.zSpeedMax, G.runGames * TUNE.zSpeedPerGame);
const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, const base = G.testMode ? TUNE.testZSpeed : TUNE.zSpeed * ramp;
const zb = { x, z, v: base * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9,
homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column
if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable
if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass
@@ -260,7 +262,7 @@ function fireAtPlayer(x, z, spread = 0, speed = TUNE.machSpeed) {
// Now the advanced SHARE keeps climbing after everything is unlocked, which is what makes game 40 // Now the advanced SHARE keeps climbing after everything is unlocked, which is what makes game 40
// feel different from game 20 rather than merely faster. // feel different from game 20 rather than merely faster.
function pickVariant() { function pickVariant() {
const g = G.runGames; const g = G.testMode ? TUNE.testLevel : G.runGames; // test mode unlocks the whole roster
const pool = []; const pool = [];
if (g >= 2 && !zombies.some(z => z.variant === 'f')) pool.push('f'); // at most one runner alive if (g >= 2 && !zombies.some(z => z.variant === 'f')) pool.push('f'); // at most one runner alive
if (g >= 6) pool.push('g'); if (g >= 6) pool.push('g');
@@ -735,11 +737,15 @@ export function updateZombies(dt) {
// wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which // wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which
// the game literally stopped changing. The gap is hyperbolic now so it keeps tightening on its // the game literally stopped changing. The gap is hyperbolic now so it keeps tightening on its
// own instead of running into a floor; zSpawnMin is only a safety stop. // own instead of running into a floor; zSpawnMin is only a safety stop.
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame)); // TEST MODE overrides the whole director with one fixed medium setting, so the sandbox is the
if (zombies.length >= cap - 1) aside('ump_hindrance', 0.6); // the court is genuinely full // same wave whether you triple-tapped in game 1 or game 40.
const cap = G.testMode ? TUNE.testZMax
: Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame));
if (zombies.length >= cap - 1 && !G.testMode) aside('ump_hindrance', 0.6); // the court is genuinely full
if (spawnT <= 0 && zombies.length < cap) { if (spawnT <= 0 && zombies.length < cap) {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant()); spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel)); spawnT = G.testMode ? TUNE.testSpawnEvery
: Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel));
} }
} }
} }