Nothing arrives unannounced: the horde gets its breather, the boss gets a name
THEY KEEP COMING is a 2.4s 'over' beat again (warnHorde in beats.js) instead of a flash raised on the same frame the wave lands, and the chapter boss walks on behind an overlay — his name in red over ENTERED THE COURT — held for 3s by the new self-dismissing modal clock (m.hold) rather than announced on a routine-set card. startHorde returns a bool: if the court has been taken when the warning expires the pendingHorde latch is re-armed rather than the set losing the horde it paid for. The warning ticks on its own clock, not on the 'over' beat, so a beat that moves the phase underneath it can't strand it (the mgT lock, again). Three tests, 15 total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f636530b9
commit
5ea84f8511
8 files changed
+132
-16
No files matched your search
+27
-6
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+20
-4
@@ -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;
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user