diff --git a/PROJECT.md b/PROJECT.md index 2ad5fe2..e8ed83b 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -10,7 +10,8 @@ detail of any individual change, read the git log. the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way. -Last updated 2026-08-11 (beats.js extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER — +Last updated 2026-08-11 (the horde's breather restored, the boss walk-on overlay; beats.js +extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER — shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback should be handled, the second is everything known to be outstanding in the game, the third is the demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed @@ -101,6 +102,11 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no `in` field draws fully landed, so older/test call sites still work. + **`m.hold` is an optional self-dismiss clock** (seconds, ticked only once `m.in` has landed): the + modal announces, holds the frozen court, and lets go by itself. The chapter-boss walk-on is the + case it exists for — an announcement whose job is to PREPARE the player, where demanding a tap to + start a fight he did not ask for would be the control steal the popup policy exists to prevent. + A tap still skips it. Every other modal has no `hold` and waits for the player, as before. While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there would drop that tap through into a serve instead. @@ -380,6 +386,33 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: nothing arrives unannounced (2026-08-11, same day) + +Oliver's retest: *"the horde is not announced anymore"* and *"bosses are just there now"*. Both were +true, and both were the same mistake — the announcement fired on the same frame as the thing it +announced, so it was over before it registered. + +- **The horde gets its breather back.** `warnHorde()` (beats.js) raises THEY KEEP COMING and holds + an ordinary 'over' beat for `TUNE.hordeWarnT` (2.4 s); `startHorde()` runs when it expires. The + cheat (`forceZombies`) still starts one instantly — it is a cheat. + The breather runs on **its own clock** (`G.hordeWarn`, ticked in update above the 'over' block), + deliberately NOT on the 'over' beat it also sets: any beat that moves the phase underneath it + would strand the latch forever, which is the `mgT` lock this file already has a section about. + `startHorde()` **returns a bool** now, and if the court has been taken when the warning expires + the caller re-arms `pendingHorde` — the set gets its horde later rather than losing it silently. +- **The chapter boss walks on behind an OVERLAY**, not a band card: the boss's name in `UI.bad` over + `ENTERED THE COURT`. It is a modal (a boss stage is guaranteed calm — `startBossFight` clears the + court two lines earlier, so this is on the sanctioned list) with the new `m.hold` self-dismiss + clock, `TUNE.bossIntroT` = 3 s. A card in the routine-set slot and the routine-set size is why the + biggest fight in the game read as the boss simply being there. +- **The flamer does not trigger a horde** (his "vague feeling"). Arming any `kind: 'burst'` tin puts + you in the same CONTROL MODE a horde does — racket auto-fires, the finger steers you absolutely, + no serving — so a flamer or gun genuinely feels like a horde starting. Nothing in the weapon paths + touches `startHorde`; with the horde announced again the two are told apart by the announcement. +- Three tests added (15 total): the wave may not land on the frame of its own announcement, a mode + that steals the court during the warning re-arms the latch, and the boss overlay names the boss + and hands the court back without a tap. + ## Session log: beats.js, the rename, and the build gate (2026-08-11, same day) Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both — diff --git a/index.html b/index.html index ec8dabb..f906025 100644 --- a/index.html +++ b/index.html @@ -398,6 +398,6 @@ - + diff --git a/src/beats.js b/src/beats.js index 4787553..33fae43 100644 --- a/src/beats.js +++ b/src/beats.js @@ -94,8 +94,14 @@ export function startBossFight(chapter) { zombieDuck(true); // the music drops out for it voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85); const art = bossArt(chapter); - G.card = { title: art.name, color: UI.bad, t: TUNE.setCardT }; - G.flash = null; + // THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in + // the same slot and size as a routine set, and it read as the boss simply being there. This is on + // the sanctioned modal list (a boss stage is guaranteed calm — the two lines above just cleared + // it), and it HOLDS itself for bossIntroT rather than waiting for a tap: the point is to prepare + // the player, and demanding a tap to start a fight he didn't ask for is the control steal the + // popup policy exists to prevent. A tap still skips it. + G.card = null; G.flash = null; + showModal({ title: art.name, color: UI.bad, hold: TUNE.bossIntroT, body: ['ENTERED THE COURT'] }); // (the four-line machine instruction modal is gone — people figure it out) } @@ -163,7 +169,7 @@ export function watchdog(dt) { if ((stuckT += dt) < TUNE.stuckT) return; logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`, phase: G.phase, sig, aim: !!G.aim }); - G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false; + G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false; G.hordeWarn = 0; G.aim = null; endBurst(); setupPoint(); stuckT = 0; stuckSig = ''; } @@ -200,9 +206,23 @@ export function resolveInterlude(side) { } } -// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you +// THE BREATHER BEFORE THE WAVE. The horde used to begin on the same frame as its own announcement, +// so the flash was already gone by the time the crowd was on you — "the horde is not announced +// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock +// counts it down under the flash), and startHorde still owns the transition itself. +export function warnHorde() { + if (G.horde || G.interlude || G.bossFight) return false; // same court-ownership contract as the rest + G.hordeWarn = TUNE.hordeWarnT; + G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT }; + ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT; + return true; +} + +// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you. +// Returns false when another mode owns the court — the caller re-arms the latch rather than +// silently eating the wave the set paid for. export function startHorde() { - if (G.horde || G.interlude || G.bossFight) return; // same contract as startDuel: one mode owns the court + if (G.horde || G.interlude || G.bossFight) return false; // same contract as startDuel: one mode owns the court G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell @@ -213,5 +233,6 @@ export function startHorde() { P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated' // that used to play right before it belongs to the transition, not here.) - G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; + G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; // re-raised so the warning's line carries into the wave + return true; } diff --git a/src/config.js b/src/config.js index d593bfd..dbb32ea 100644 --- a/src/config.js +++ b/src/config.js @@ -278,6 +278,9 @@ export const TUNE = { // 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which // is why switching tins felt fiddly. rackHit also pads the touch band (render.js). bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater + hordeWarnT: 2.4, // s of THEY KEEP COMING breather between the set card and the wave landing. + // The horde used to start on the same frame as its own announcement, which reads + // as no announcement at all — Oliver stopped noticing hordes were being called. hordeLen: 12, // s a horde wave lasts hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the // wave is meant to leave a standing crowd on your half when it ends, not to be a @@ -377,6 +380,9 @@ export const TUNE = { bossWinT: 4.4, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is // the longest fight in the game and the win was over before it registered, but // stretching setCardT would have made every routine set announcement drag too. + bossIntroT: 3.0, // s the boss walk-on overlay holds the frozen court before it lets go BY ITSELF. + // A boss used to arrive on a band card nobody read ("bosses are just there now") — + // this is the one announcement the player has to be prepared by, not informed of. modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the // left, each on easeOutBack. The gesture that dismisses is swallowed until it // lands, so a panic tap can't skip a popup before it has been read. diff --git a/src/gameplay.js b/src/gameplay.js index 74a1c6c..744d7f7 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -6,7 +6,7 @@ import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zom import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; -import { proceedAfterClear, startDuel, startHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing +import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. @@ -1187,7 +1187,14 @@ export function update(dt) { if (G.modal) { G.idleT += dt; const m = G.modal; // the entrance is the ONE thing that animates behind the freeze - if (m.in < 1 && (m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud + if (m.in < 1) { + if ((m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud + } + // ...and a modal may carry its OWN clock (`hold`): it announces, holds the frozen court for a + // couple of seconds and hands it back without being tapped. The boss walk-on is the case — an + // announcement the player must be PREPARED by, where waiting for a tap would be the control + // steal the popup policy forbids. Only ticks once it has landed; a tap still dismisses early. + else if (m.hold > 0 && (m.hold -= dt) <= 0) dismissModal(); return; } // The flash is FROZEN behind a popup too, and it has to be: it used to tick above the return, so @@ -1466,6 +1473,15 @@ export function update(dt) { const boy = G.boy; if (boy) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt); + // The announced breather runs on its OWN clock, not on the 'over' beat it also sets: any beat that + // moves the phase underneath it (a duel taking the calm court, say) would otherwise strand the + // latch forever — the mgT lock, exactly. If the court is taken when it expires, the latch is + // re-armed and the set gets its horde later rather than losing it. Above the 'over' block so the + // wave lands before that block can put the player back in the serve stance for one frame. + if (G.hordeWarn > 0 && (G.hordeWarn -= dt) <= 0) { + G.hordeWarn = 0; + if (!startHorde()) G.pendingHorde = true; + } if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { @@ -1479,8 +1495,8 @@ export function update(dt) { G.seen.horde = true; showModal({ title: 'THEY KEEP COMING', color: UI.hot, body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'], - onDismiss: startHorde }); - } else startHorde(); // ...and every later set starts on the flash alone. Re-showing the + onDismiss: warnHorde }); + } else warnHorde(); // ...and every later set starts on the flash alone. Re-showing the // same popup at the hottest moment of every set was one of the // "popups get me killed" reports — you know the rules by set two. return; diff --git a/src/main.js b/src/main.js index 991e043..862dddf 100644 --- a/src/main.js +++ b/src/main.js @@ -211,7 +211,7 @@ document.getElementById('hot').addEventListener('pointerdown', () => { function intoSplatter() { if (G.mode === 'tennis') forceZombies(); for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60); - G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false; + G.modal = null; G.horde = null; G.hordeWarn = 0; G.interlude = false; G.changeover = 0; G.clearing = false; G.mgT = 0; G.gunId = null; G.beam = null; G.hp = G.hpMax = TUNE.playerHp; zombies.length = 0; shots.length = 0; pellets.length = 0; score.server = 'P'; setupPoint(); @@ -352,7 +352,7 @@ function laserScene() { paused = false; if (G.mode === 'tennis') forceZombies(); for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter - G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false; + G.modal = null; G.horde = null; G.hordeWarn = 0; G.interlude = false; G.changeover = 0; G.clearing = false; G.mgT = 0; G.gunId = null; G.beam = null; G.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp; zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0; score.server = 'P'; setupPoint(); diff --git a/src/state.js b/src/state.js index c3ac243..daf301a 100644 --- a/src/state.js +++ b/src/state.js @@ -111,7 +111,8 @@ export const G = { clearing: false, // a game just ended: the surviving wave stays and must be killed by HAND before play advances (no auto-sweep). No new spawns, kills don't score. afterClear: null, // what happens once the court is clear: 'changeover' | 'game' | 'set' clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card) - pendingHorde: false,// a set was won; the horde starts after the set card's pause + pendingHorde: false,// a set was won; the horde is WARNED after the set card's pause + hordeWarn: 0, // s left of the THEY KEEP COMING breather; the wave lands when it expires (beats.warnHorde) card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block' // the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns) // and only a tap or key clears it, so a popup can be read without being eaten while you read it. diff --git a/test.js b/test.js index 68ae189..4933696 100644 --- a/test.js +++ b/test.js @@ -64,7 +64,7 @@ const { G, P, score, ball, setupPoint } = await import('./src/state.js'); const { update, forceZombies, splatPoint, restartRun } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight } = await import('./src/beats.js'); const { zombies, spawnZombie } = await import('./src/zombies.js'); -const { TUNE } = await import('./src/config.js'); +const { TUNE, bossArt } = await import('./src/config.js'); const DT = 1 / 60; function freshSplatter() { @@ -217,5 +217,44 @@ test('a lost duel banks their game — and their set — without a horde', () => assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde'); }); +// ---- the announcements: nothing that changes the control mode may arrive unannounced ----------- + +test('a set warns THEY KEEP COMING before the wave lands', () => { + G.pendingHorde = true; G.seen.horde = true; // not the first horde: no teaching modal in the way + G.phase = 'over'; G.overT = 0.01; + update(DT); + assert.equal(G.horde, null, 'the wave must not land on the same frame as its own announcement'); + assert.ok(G.hordeWarn > 0, 'the breather is running'); + assert.equal(G.flash && G.flash.text, 'THEY KEEP COMING', 'and it says so'); + for (let i = 0; i < 10 && !G.horde; i++) update(DT); // ...but not for a hundredth of a second + assert.equal(G.horde, null, 'the breather actually holds'); + for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT); + assert.ok(G.horde, 'and the wave lands when it runs out'); +}); + +test('a mode that steals the court during the warning re-arms the horde', () => { + G.pendingHorde = true; G.seen.horde = true; + G.phase = 'over'; G.overT = 0.01; + update(DT); + assert.ok(G.hordeWarn > 0, 'setup: warning running'); + startDuel(); // the court is calm during a breather, so a scheduled beat CAN grab it here + G.modal = null; G.modalQ.length = 0; // (its first-sight popup would freeze the world we're stepping) + for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT); + assert.equal(G.horde, null, 'the wave must not land inside the duel'); + assert.ok(G.pendingHorde, 'the set is still owed its horde'); +}); + +test('a chapter boss announces itself and hands the court back on its own', () => { + startBossFight(2); + assert.ok(G.modal, 'the walk-on is an overlay, not a band card'); + assert.equal(G.modal.title, bossArt(2).name, 'it names the boss'); + assert.deepEqual(G.modal.body, ['ENTERED THE COURT']); + for (let i = 0; i < (TUNE.modalIn + TUNE.bossIntroT) * 60 - 6; i++) update(DT); + assert.ok(G.modal, 'and holds the frozen court while the player reads it'); + for (let i = 0; i < 20 && G.modal; i++) update(DT); + assert.equal(G.modal, null, 'then lets go without waiting for a tap'); + assert.ok(G.bossFight, 'the fight is on'); +}); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report