diff --git a/HANDOFF.md b/HANDOFF.md index be0128e..616b713 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 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) 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 `window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap. 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 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). diff --git a/config.js b/config.js index 1740706..c908605 100644 --- a/config.js +++ b/config.js @@ -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." 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 + // ---- 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 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... diff --git a/gameplay.js b/gameplay.js index 656d68d..0b4d893 100644 --- a/gameplay.js +++ b/gameplay.js @@ -1,6 +1,6 @@ // 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 { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.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 }); } +// ---- 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. // 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. @@ -1197,6 +1233,7 @@ export function update(dt) { // --- zombies + transition timeline 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.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance if (G.mode === 'transition') transition(dt); diff --git a/index.html b/index.html index b08f571..aff33c0 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/main.js b/main.js index 5cec911..68229b4 100644 --- a/main.js +++ b/main.js @@ -5,7 +5,7 @@ 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 { 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 { 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'; @@ -166,13 +166,26 @@ const maybeRestart = () => { cv.addEventListener('pointerdown', maybeRestart); addEventListener('keydown', maybeRestart); -// hidden shortcut: tap the umpire three times to summon zombie mode early -let umpTaps = []; +// hidden shortcuts: three taps on the UMPIRE summons zombie mode early, three on the SCOREBOARD +// opens test mode (a medium wave plus every barrel weapon — see gameplay.toggleTestMode) +let umpTaps = [], boardTaps = []; cv.addEventListener('pointerdown', e => { const r = cv.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords 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); if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return; const now = performance.now(); @@ -184,6 +197,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // 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, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/state.js b/state.js index e92a98a..63cb1ef 100644 --- a/state.js +++ b/state.js @@ -58,6 +58,7 @@ export const G = { mgCool: 0, // s until the next pellet in that burst 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. + 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 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 diff --git a/zombies.js b/zombies.js index 931bd18..cebe13d 100644 --- a/zombies.js +++ b/zombies.js @@ -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 // 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. + // 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 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 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 @@ -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 // feel different from game 20 rather than merely faster. function pickVariant() { - const g = G.runGames; + const g = G.testMode ? TUNE.testLevel : G.runGames; // test mode unlocks the whole roster const pool = []; if (g >= 2 && !zombies.some(z => z.variant === 'f')) pool.push('f'); // at most one runner alive 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 // 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. - const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame)); - if (zombies.length >= cap - 1) aside('ump_hindrance', 0.6); // the court is genuinely full + // TEST MODE overrides the whole director with one fixed medium setting, so the sandbox is the + // 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) { 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)); } } }