The reaction lands where the ball does

- praise was firing at RELEASE, half a second before impact (measured:
  32-frame flight, word on frame 0) — it now arms on the ball and pops
  on the first body it meets, or its first bounce
- DOUBLE KILL / MASSACRE / RETURNED rise off the thing that earned them,
  as world-anchored pop texts — the announcement band is for milestones
- the band got rows: a card can no longer print over the flash line
- GAME N flash deleted (the scoreboard bar already says it), CHANGEOVER
  folded into the countdown panel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-08 20:50:46 +02:00
1 parent 9476f354c2
commit 1337048a85
7 files changed
+91 -30

No files matched your search

+33 -18
View File
@@ -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();
}
}
}