From 1337048a85d344a0df8566f25bd7ed5a9996b7d9 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 8 Aug 2026 20:50:46 +0200 Subject: [PATCH] The reaction lands where the ball does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- HANDOFF.md | 17 +++++++++++++++++ gameplay.js | 51 +++++++++++++++++++++++++++++++++------------------ index.html | 2 +- main.js | 4 ++-- render.js | 23 ++++++++++++++++++++--- state.js | 2 +- zombies.js | 22 +++++++++++++++++----- 7 files changed, 91 insertions(+), 30 deletions(-) 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 @@ - + diff --git a/main.js b/main.js index 6e97cfd..0d49d39 100644 --- a/main.js +++ b/main.js @@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './sta import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js'; import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; -import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; +import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { cv } from './dom.js'; import * as stats from './stats.js'; import './input.js'; @@ -334,6 +334,6 @@ if (location.hash === '#laser') setTimeout(laserScene, 600); requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, +window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, stats, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 80661a3..08dfbc1 100644 --- a/render.js +++ b/render.js @@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js'; import { P, O, ball, score, crowd, G, court, tour } from './state.js'; import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js'; -import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; +import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js'; // ---------------------------------------------------------------- palette ---- // tennis palette -> drained splatter palette; the transition lerps between them. @@ -1178,7 +1178,11 @@ function drawRack() { // CROWD, below the scoreboard block, above the ad strip. Nothing transient sits on the court // anymore — a card at centre court covered the very play it was announcing, non-blocking or not. const AD_TOP = () => Math.round(proj(0, 0, -6).y) - AD_H; // top of the courtside ad strip -const bandTop = h => Math.max(56, AD_TOP() - h - 4); // fit an h-px band above the ads +// The band has ROWS now: the flash line owns 68..90 (FLASH_Y, Bangers 18) and the countdown panel +// the strip under it, so a card may never climb above 96 — a tall icon card used to reach the +// flash line and the two printed over each other, which was the "spamming over each other" report. +// The floor wins over the ad-strip anchor: a too-tall card overlaps the ads (decoration) instead. +const bandTop = h => Math.max(96, AD_TOP() - h - 4); function drawCard(title, sub1, sub2, color, icon) { // An ICON (the won-duel tin) needs its own room above the title, so the band grows for it @@ -1646,6 +1650,18 @@ export function render() { ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke(); } if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying) + // world-anchored reaction pops (zombies.popText): DOUBLE KILL rises off the corpse, the praise + // word lands on the impact. Drawn IN the world pass, before the un-shake, so they ride the kick + // of the moment they belong to. Small, brief, and where you are already looking — the reactions + // left the announcement band because up there they read as one more banner shouting over play. + for (const p of pops) { + const f = p.t / p.T, pr = proj(p.x, p.y, p.z); + ctx.save(); + ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end + ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (1 - f) * 14), p.col || '#ffffff'); + ctx.restore(); + } // (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The // hitstop, the click and the praise word already carry the moment.) // The braced "rambo racket" is NOT drawn any more. It was a procedural racket laid over a sprite @@ -1762,7 +1778,8 @@ export function render() { // In the message band with the rest of the family, and on its OWN PANEL: bare shadowed text // was barely readable over the sunlit clay crowd (Oliver flagged it every court since). // Plain Silkscreen on purpose — a countdown is information, not an announcement. - const txt = 'RESUMING IN ' + Math.ceil(waitT); + // the changeover names itself here — its old separate flash was the same word, twice + const txt = (G.changeover > 0 ? 'CHANGEOVER · ' : '') + 'RESUMING IN ' + Math.ceil(waitT); const y = Math.min(FLASH_Y + 26, AD_TOP() - 18); ctx.font = F(10); const tw = Math.ceil(ctx.measureText(txt).width); diff --git a/state.js b/state.js index 97845e3..70527d5 100644 --- a/state.js +++ b/state.js @@ -8,6 +8,7 @@ export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: - export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false, + praise: null, // a perfect serve ARMS its praise word here; it pops at the impact, not the release // which weapon launched the ball in flight — the stats screen files a kill under it long after // the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free. wid: 'ball' }; @@ -74,7 +75,6 @@ export const G = { deliveries: 0, // tins delivered this run — indexes the weapon ladder duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves - pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the // pull gesture demonstrates itself (render.drawPullHint) diff --git a/zombies.js b/zombies.js index 270f479..32e0b9e 100644 --- a/zombies.js +++ b/zombies.js @@ -21,13 +21,21 @@ export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, // theirs and they hurt YOU. Swat one with a swing and it becomes an `extras` entry flying back // up-court, which is how a parry turns into a real weapon for free. export const shots = []; // {x, y, z, vx, vy, vz, g, t} +// world-anchored reaction pops: DOUBLE KILL rises off the corpse that earned it, PERFECT lands on +// the impact — the answer to "what did I just do" appears where you are already looking, not in +// the announcement band. Render draws them, gameplay's update ticks them, killed in resetZombies. +export const pops = []; // {x, y, z, text, col, t, T} +export function popText(x, y, z, text, col) { + pops.push({ x, y, z, text, col, t: 0.85, T: 0.85 }); + if (pops.length > 10) pops.shift(); +} let spawnT = 1.5, moanT = 3; // Every body, decal and projectile on the court, plus this module's own timers. Called by // gameplay.restartRun — a restart is in-place now, not a page reload. export function resetZombies() { - for (const a of [zombies, gore, piles, parts, pellets, extras, shots]) a.length = 0; + for (const a of [zombies, gore, piles, parts, pellets, extras, shots, pops]) a.length = 0; spawnT = 1.5; moanT = 3; mgTick = 0; lastBarrel = null; } @@ -598,6 +606,7 @@ export function meleeSwipe(x, z, range) { } function splatter(i, fanfare = true) { // the MAIN ball hit a zombie + const zx = zombies[i].x, zy = zombieH(zombies[i]) * 0.9, zz = zombies[i].z; // for the pops — killZombie splices killZombie(i, !!G.horde); ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter @@ -608,9 +617,9 @@ function splatter(i, fanfare = true) { // the MAIN ball hit a zombie G.hitstop = Math.max(G.hitstop, Math.min(0.09, 0.03 + 0.012 * ball.kills)); // escalating multi-kill fanfare — one shot, ≥2 zombies // ...and the stat is banked on the TRANSITION to 2, so a massacre of six is one multi-kill - if (ball.kills === 2) { multiKill(); G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); } - else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); } - else if (ball.kills >= 4) { G.flash = { text: `MASSACRE ×${ball.kills}`, t: 0.9 }; cheer(2.8, 0.7); voice('unbelievable', 0.4); + if (ball.kills === 2) { multiKill(); popText(zx, zy, zz, 'DOUBLE KILL'); cheer(1.6, 0.45); voice('double_kill', 0.4); } + else if (ball.kills === 3) { popText(zx, zy, zz, 'TRIPLE KILL'); cheer(2.2, 0.6); voice('triple_kill', 0.4); } + else if (ball.kills >= 4) { popText(zx, zy, zz, `MASSACRE ×${ball.kills}`); cheer(2.8, 0.7); voice('unbelievable', 0.4); G.slowT = Math.max(G.slowT, 0.35); } // a massacre borrows the boss's slow-mo — the infra already runs every frame } @@ -953,6 +962,9 @@ export function updateZombies(dt) { CAUSE = ball.wid; // re-stamped per body: burnTick above shares this loop and moves it const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); if (d < hitR(zb) + ballSweep && !(zb.hitT > 0)) { // brute and boss are bigger targets + // the serve's deferred praise pops on the FIRST body the ball meets — this is the impact + // the word was waiting for (armed in hitServe, see praiseWord) + if (ball.praise) { popText(zb.x, zombieH(zb) * 0.8, zb.z, ball.praise, UI.warm); ball.praise = null; } if (ball.bomb) { // detonate on first contact, whatever the pace explode(ball.x, ball.z); ball.bomb = false; spentBall(); break; // zombies mutated — stop this frame's loop @@ -1122,7 +1134,7 @@ export function updateZombies(dt) { const sp = Math.hypot(s.vx, s.vz) * 1.15; extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5, vx: Math.sin(ang) * sp, vy: 4.2, vz: -Math.cos(ang) * sp }); - pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3); + pock(0.85, 1300); popText(s.x, s.y + 0.4, s.z, 'RETURNED'); cheer(0.8, 0.3); done = true; } if (!done && dHit < TUNE.shotHitR && s.y < TUNE.shotHitY) { hurtPlayer(TUNE.machDmg); done = true; }