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:
1 parent
9476f354c2
commit
1337048a85
7 files changed
+91
-30
No files matched your search
+17
@@ -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:
|
||||
|
||||
+33
-18
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -359,6 +359,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=130"></script>
|
||||
<script type="module" src="main.js?v=131"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; } };
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+17
-5
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user