A burst fires for its whole window, and the countdown is the tile itself
The gun, beacon, flamer and rocket fell silent the moment they cleared the court: firing and steering were both gated on phase 'rally', and every beat raised by the kill that empties the court (a point, a won game, the mop-up) parks the phase in 'over'. A burst is its own control mode for its whole len, like a horde — its own clock is the only thing that ends it. Not fixed by forcing the phase back to rally: a parked 'over' beat that never expires is how pendingHorde gets stranded. The countdown moved off the tile's bottom edge (the rounded frame, and the bottom of the screen) into the tile: the armed slot's well drains as a gold wash, clipped to its own rounded path, under the icon and labels. 16 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ea84f8511
commit
c659b6882f
6 files changed
+59
-7
No files matched your search
+19
-1
@@ -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,
|
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
|
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.
|
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
|
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.
|
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)
|
## 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 —
|
Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both —
|
||||||
|
|||||||
+1
-1
@@ -398,6 +398,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=150"></script>
|
<script type="module" src="src/main.js?v=151"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+6
-1
@@ -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
|
// 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
|
// 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.
|
// 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
|
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);
|
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 };
|
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
|
||||||
|
|||||||
+13
-2
@@ -1227,6 +1227,19 @@ function drawRack() {
|
|||||||
sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit
|
sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit
|
||||||
sel ? UI.pick : '#05160e'); // gold frame when live
|
sel ? UI.pick : '#05160e'); // gold frame when live
|
||||||
if (sel) ctx.lineWidth = 1;
|
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
|
// 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)
|
// stealing the selection (grabbing the gun mid-plan was the complaint — see popBarrel)
|
||||||
if (G.rackGlow && G.rackGlow.slot === i) {
|
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.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.fillText(label, tx, y0 + g.h * 0.72); // weapon label, under its count
|
||||||
ctx.globalAlpha = 1;
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -927,7 +927,12 @@ export function updateZombies(dt) {
|
|||||||
G.mgT -= dt;
|
G.mgT -= dt;
|
||||||
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
|
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
|
||||||
const gw = WEAPONS[G.gunId] || WEAPONS.gun;
|
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.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)
|
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)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
|||||||
const { G, P, score, ball, setupPoint } = await import('./src/state.js');
|
const { G, P, score, ball, setupPoint } = await import('./src/state.js');
|
||||||
const { update, forceZombies, splatPoint, restartRun } = await import('./src/gameplay.js');
|
const { update, forceZombies, splatPoint, restartRun } = await import('./src/gameplay.js');
|
||||||
const { startDuel, resolveInterlude, startBossFight, endBossFight } = await import('./src/beats.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 { TUNE, bossArt } = await import('./src/config.js');
|
||||||
|
|
||||||
const DT = 1 / 60;
|
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');
|
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`);
|
console.log(`\n${passed} tests passed`);
|
||||||
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|
||||||
Reference in new issue
Block a user