diff --git a/PROJECT.md b/PROJECT.md index e8ed83b..1676cb9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -409,10 +409,28 @@ announced, so it was over before it registered. 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 +- Three tests added: 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. +**Same round, the burst weapons.** *"When firing a burst tin the weapon stops when it has destroyed +all visible enemies. It should just fire on."* It did, and the cause had nothing to do with enemies: +both the auto-fire and the steer were gated on `G.phase === 'rally'`, and every beat raised by +clearing the court parks the phase in `'over'` (a point, a won game, the mop-up). So the moment the +flamer killed the last body it went silent AND the player froze — worst possible timing, and it read +as the weapon jamming. **A burst is its own control mode for its whole `len`, exactly like a horde: +its own clock is the only thing that ends it.** The fire block in `updateZombies` has no phase guard +at all now, and the movement block runs on `G.phase === 'rally' || G.mgT > 0`. The settled "the +player only walks during rally" rule is untouched — nothing arms a burst except the player. +Deliberately NOT fixed by forcing the phase back to `'rally'` during a burst: that would stop `overT` +ticking, and a parked beat that never expires is how `pendingHorde` gets stranded (see the mgT lock). + +**And the burst countdown is the TILE now, not a bar.** It was 1.5 px along the tile's bottom edge — +which is both the rounded frame and, on a phone, the very bottom of the screen. The armed slot's +WELL drains instead: a gold wash across the whole tile, emptying leftward, clipped to the tile's own +rounded path (`panel` leaves it as the current path) and drawn under the icon and labels. Measured +lit vs empty on the live tile: rgb(88,125,68) against rgb(31,84,49). + ## 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 f906025..1dd7f1b 100644 --- a/index.html +++ b/index.html @@ -398,6 +398,6 @@ - + diff --git a/src/gameplay.js b/src/gameplay.js index 744d7f7..b3ba100 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1236,7 +1236,12 @@ export function update(dt) { // and vetoed on feel: you never walk freely during play, so suddenly being able to between points // reads as the game breaking, not as a fix. The frozen-changeover complaint is answered by // holding the DEAD instead (see updateZombies), and the stuck pose by the reset below. - if (G.phase === 'rally') { + // ...and a live BURST is the exception, for the same reason the horde forces 'rally': it is an + // auto-fire control mode that owns the finger for its whole window, and the beats that fire when + // it clears the court (a point, a won game, the mop-up) park the phase in 'over'. Gating the + // steer on the phase froze the player mid-burst the moment he was winning. The settled "no + // walking between points" rule is untouched — nothing arms a burst except the player. + if (G.phase === 'rally' || G.mgT > 0) { if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; diff --git a/src/render.js b/src/render.js index f725225..b637965 100644 --- a/src/render.js +++ b/src/render.js @@ -1227,6 +1227,19 @@ function drawRack() { sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit sel ? UI.pick : '#05160e'); // gold frame when live if (sel) ctx.lineWidth = 1; + // THE BURST COUNTDOWN IS THE TILE ITSELF. It was a 1.5 px bar along the tile's bottom edge — + // which is both the rounded frame AND the very bottom of a phone screen, so it was invisible + // ("the progress bar is not visible anymore because of the lower border"). The armed slot's + // WELL drains instead: a lit wash across the whole tile, emptying to the left as the window + // runs out. Clipped to the tile's own rounded path (panel leaves it current) so it can never + // square off the corners, and drawn UNDER the icon and labels, which stay fully readable. + if (G.mgT > 0 && slotId(i) === G.gunId) { + const frac = Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)); + ctx.save(); ctx.clip(); + ctx.fillStyle = hexA(UI.pick, 0.34); + ctx.fillRect(x + 1.5, y0 + 1.5, (g.tw - 3) * frac, g.h - 3); + ctx.restore(); + } // a slot that just came out of a barrel announces itself: a pink pulse for a moment, WITHOUT // stealing the selection (grabbing the gun mid-plan was the complaint — see popBarrel) if (G.rackGlow && G.rackGlow.slot === i) { @@ -1253,8 +1266,6 @@ function drawRack() { ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : temp ? UI.pink : '#8fa89a'; ctx.fillText(label, tx, y0 + g.h * 0.72); // weapon label, under its count ctx.globalAlpha = 1; - // the burst countdown belongs to whichever slot is holding the armed weapon now, not to slot 4 - if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); } } ctx.restore(); } diff --git a/src/zombies.js b/src/zombies.js index a626cba..c671475 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -927,7 +927,12 @@ export function updateZombies(dt) { G.mgT -= dt; // whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A const gw = WEAPONS[G.gunId] || WEAPONS.gun; - if (G.phase === 'rally' && gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; } + // NO PHASE GUARD. It used to require 'rally', and the beats that fire the moment a burst + // clears the court all park the phase in 'over' (a point, a won game, the mop-up) — so the + // weapon fell silent exactly when it had killed everything, which reads as the gun jamming + // ("it should just fire on"). A burst is its own control mode for its whole `len`, like a + // horde: the clock above is what ends it, never the beat playing behind it. + if (gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; } if (G.mgT <= 0) { endBurst(); setupPoint(); } // window over: spend the tin, resume the serve loop } if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns) diff --git a/test.js b/test.js index 4933696..c40641e 100644 --- a/test.js +++ b/test.js @@ -63,7 +63,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); 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 { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { TUNE, bossArt } = await import('./src/config.js'); const DT = 1 / 60; @@ -256,5 +256,18 @@ test('a chapter boss announces itself and hands the court back on its own', () = assert.ok(G.bossFight, 'the fight is on'); }); +test('a burst fires through the beat that follows the kill that emptied the court', () => { + // The gun/flamer/beacon used to require phase 'rally', and every beat raised by clearing the + // court (a point, a won game, the mop-up) parks the phase in 'over' — so the weapon fell silent + // exactly when it had killed everything. Its OWN clock is the only thing that ends it. + G.mgT = 2; G.gunId = 'flamer'; G.mgCool = 0; + G.phase = 'over'; G.overT = 1.5; // the won-game beat, running behind the burst + pellets.length = 0; + for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } // an empty court, as after the last kill + assert.equal(G.phase, 'over', 'setup: the beat is still running'); + assert.ok(pellets.length > 0, 'the burst keeps firing through it'); + assert.ok(G.mgT > 0, 'and its window is still open'); +}); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report