diff --git a/HANDOFF.md b/HANDOFF.md
index 732ec2f..a903f0a 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -425,6 +425,23 @@ strategic answer, so the run keeps feeding it.
**The umpire triple-tap cheat is deleted** — the test group found it. `T.forceZombies()` remains.
+**Third batch, same day — REACTIONS LEFT THE BAND (option C, settled in discussion).** Oliver's
+"reactions play after shooting, not when the ball hits" was MEASURED and real: `praise()` fired in
+hitServe at RELEASE while the ball flew for another half second. The word is now ARMED on
+`ball.praise` and pops at the IMPACT — first body the ball touches (zombies.js contact branch) or
+its first bounce (onBounce); a rally hit pops at the racket, where contact and praise are the same
+moment. Verified: a 32-frame flight popped its word on frame 32.
+Reactions are **world-anchored pop texts** now (`pops`/`popText` in zombies.js, drawn in the world
+pass before the un-shake): DOUBLE/TRIPLE KILL and MASSACRE rise off the corpse, RETURNED off the
+parry, praise off the impact. Small (Bangers 13), rise-and-fade in 0.85 s, capped at 10.
+**The band got ROWS**: flash line 68..90, countdown under it, and `bandTop` floors a card at 96 so
+a tall icon card can no longer print over the flash — that collision was the "spamming over each
+other" report. **Cuts, per Oliver**: the GAME N flash is deleted (with the whole `pendingGame`
+latch — the scoreboard bar prints the number permanently) and the CHANGEOVER flash is folded into
+the countdown panel ('CHANGEOVER · RESUMING IN n'). He kept the burst-arm weapon label and the
+praise words. **A bottom/above-rack message slot was proposed and REJECTED**: that is thumb
+territory — covered by the hand mid-pull and outside where the eyes are.
+
**Second batch, same day — THE MESSAGE BAND.** Oliver: transient messages need "a definite space
… over the crowd". `AD_TOP()`/`bandTop(h)` in render.js pin the whole family there — below the
scoreboard block, above the ad strip, never on the court:
diff --git a/gameplay.js b/gameplay.js
index ff1ce42..779ec5a 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, court, other, deuceSide, setupPoint, sendB
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
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, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam } from './zombies.js';
+import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -46,10 +46,14 @@ let praiseN = 0;
// ...and he does not say it every time. Same cooldown model as the asides (aside()): once you have
// learned the band you hit it constantly, and a HUD that talks on every other serve is noise
// whatever the word is. The bright click in the pull is the reliable feedback; this is the garnish.
-function praise() {
- if (G.praiseT > 0) return;
+// Returns the word (or null, on cooldown) — the CALLER decides where and when it appears, because
+// the word must land at the IMPACT, not at the release (Oliver: "the reactions are playing after
+// shooting the ball, not when the ball hits"). A serve arms it on ball.praise and the contact/
+// bounce pops it; a rally hit pops it at the racket, where contact and praise are the same moment.
+function praiseWord() {
+ if (G.praiseT > 0) return null;
G.praiseT = TUNE.praiseEvery;
- G.flash = { text: PRAISE[praiseN++ % PRAISE.length], t: 0.7 };
+ return PRAISE[praiseN++ % PRAISE.length];
}
// Court dust: a kicked-up puff at ground level, tinted per surface — chalk on clay, flecks on the
@@ -157,6 +161,7 @@ function doHit(ch, quality, aim) {
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them
+ ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
// TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be
// true there, so it was a permanent yellow ring on the grass carrying no information — and
@@ -169,7 +174,11 @@ function doHit(ch, quality, aim) {
G.aiReactT = 0; // CPU hasn't read the shot yet
}
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side
- if (quality > 0.999) { G.hitstop = 0.05; praise(); pock(0.5, 1600); } // the rally hit: a real 45 ms window (perfectMs)
+ if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs)
+ G.hitstop = 0.05; pock(0.5, 1600);
+ const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
+ if (w) popText(ball.x, ball.y, ball.z, w);
+ }
ch.pose = 'swing'; ch.poseT = 0;
}
@@ -292,6 +301,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
ball.lastHit = sv.side; ball.bounces = 0;
ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts
+ ball.praise = null; // ...and any word a spent ball never got to pop dies with it
// the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your
// specials for later. Placement still follows the flick — only WHICH ball flies is the
// rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve.
@@ -332,7 +342,12 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
sv.pose = 'serve'; sv.poseT = 0;
if (sv.side === 'P') G.tossDrops = 0; // a served toss ends the apex lesson
- if (perfect) { praise(); G.hitstop = 0.05; }
+ if (perfect && sv.side === 'P') {
+ G.hitstop = 0.05;
+ // the word is ARMED here and pops at the IMPACT — first body the ball touches, or its first
+ // bounce. Praising at release meant the HUD spoke while the ball was still mid-air.
+ ball.praise = praiseWord();
+ }
}
// ---------------------------------------------------------------- scoring ----
@@ -511,10 +526,12 @@ export function proceedAfterClear() {
G.pendingHorde = true; // the horde begins when the card's pause ends
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
} else if (mode === 'changeover') {
- G.pendingGame = true; startChangeover(); // 'GAME N' after the breather
- } else { // 'game': even-game beat, then GAME N announces the next wave
- G.pendingGame = true; ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
+ startChangeover();
+ } else { // 'game': even-game beat, then the next wave arms
+ ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
}
+ // (no 'GAME N' flash on either path anymore — the bar under the scoreboard prints the game
+ // number permanently, and the flash was the same news shouted a second time)
}
// changeover (after odd games, as the rules demand): a forced breather — no spawns,
@@ -526,7 +543,8 @@ function startChangeover() {
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
// now long enough that an empty court reads as the game having stalled.
sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
- G.flash = { text: 'CHANGEOVER', t: 1.4 };
+ // no CHANGEOVER flash — the countdown panel says 'CHANGEOVER · RESUMING IN n' for the whole
+ // breather (render), so the flash was the same word with a shorter lifespan
aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
}
@@ -824,7 +842,6 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2);
function startHorde() {
if (G.horde) return;
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
- G.pendingGame = false; // the horde IS the announcement
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
// further behind on every set banked (the second horde was where every run ended)
@@ -917,6 +934,8 @@ export function inServeBox(x, z) {
function onBounce() {
ball.bounces++;
pock(0.35, 500);
+ // a deferred praise word that met no body lands with the ball — the DRAW was still perfect
+ if (ball.praise) { popText(ball.x, 0.3, ball.z, ball.praise); ball.praise = null; }
if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up
explode(ball.x, ball.z); ball.bomb = false;
spentBall(); return;
@@ -1303,6 +1322,7 @@ export function update(dt) {
if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // settles rather than freezing
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
if (G.boomFlash > 0) G.boomFlash -= dt;
+ for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // no DOUBLE KILL frozen over the feast
updateZombies(dt);
return;
}
@@ -1331,6 +1351,7 @@ export function update(dt) {
// late in a run — the weapon modal is first-sight-only, so once you have seen a tin the label
// flash IS the announcement, and the trainer's popup was eating it every time you were wounded.
if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; }
+ for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // reaction pops decay
crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt;
if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside())
@@ -1604,13 +1625,7 @@ export function update(dt) {
if (G.horde || G.mgT > 0) {
G.phase = 'rally'; ball.active = false;
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
- } else {
- setupPoint();
- if (G.pendingGame && G.mode === 'splatter') { // the next wave is announced as it arms
- G.pendingGame = false;
- G.flash = { text: 'GAME ' + (G.runGames + 1), t: 1.1 };
- }
- }
+ } else setupPoint();
}
}
}
diff --git a/index.html b/index.html
index 5ecad3d..7df0e1b 100644
--- a/index.html
+++ b/index.html
@@ -359,6 +359,6 @@
-
+