Ten small flourishes, all borrowed machinery
Kills stutter the world a touch more per streak; a massacre borrows the boss's slow-mo; a boss death gets the transition's zoom punch. PERFECT contact flashes a spark ring. The sidestep and every real bounce kick up court-tinted dust. The net cord ripples where the ball clips it. The landing ring contracts on time-to-touchdown. Hot rally balls drag three fading ghosts. And every 8-15 seconds the floodlights stutter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d847c2e4a8
commit
8ab7ac0c25
4 files changed
+98
-10
No files matched your search
+25
-4
@@ -1,7 +1,7 @@
|
|||||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||||||
|
|
||||||
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js';
|
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js';
|
||||||
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
import { P, O, ball, score, crowd, G, court, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
||||||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
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 { 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 { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||||
@@ -21,7 +21,7 @@ export function predictLanding() { // first-bounce point, closed form — exact,
|
|||||||
const disc = ball.vy * ball.vy + 2 * g * ball.y;
|
const disc = ball.vy * ball.vy + 2 * g * ball.y;
|
||||||
if (disc < 0) return null;
|
if (disc < 0) return null;
|
||||||
const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0
|
const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0
|
||||||
return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t };
|
return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t, t }; // t rides along: the landing ring contracts on it
|
||||||
}
|
}
|
||||||
|
|
||||||
// splatter FREE-AIM (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the
|
// splatter FREE-AIM (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the
|
||||||
@@ -52,6 +52,18 @@ function praise() {
|
|||||||
G.flash = { text: PRAISE[praiseN++ % PRAISE.length], t: 0.7 };
|
G.flash = { text: PRAISE[praiseN++ % PRAISE.length], t: 0.7 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Court dust: a kicked-up puff at ground level, tinted per surface — chalk on clay, flecks on the
|
||||||
|
// grass, grit on the slab. Squarely inside the "blood, fire, smoke" procedural exemption; the ball's
|
||||||
|
// bounce and the sidestep both kick it, which is what pins fast feet and fast balls to the ground.
|
||||||
|
const DUST_COL = { grass: '#8fa06a', clay: '#c9895a', hard: '#8494a4' };
|
||||||
|
function dustPuff(x, z, n = 5, sp = 1) {
|
||||||
|
for (let k = 0; k < n; k++) parts.push({
|
||||||
|
x: x + (Math.random() * 2 - 1) * 0.15, y: 0.05, z: z + (Math.random() * 2 - 1) * 0.1,
|
||||||
|
vx: (Math.random() * 2 - 1) * 1.2 * sp, vy: 0.5 + Math.random() * 0.9 * sp,
|
||||||
|
vz: (Math.random() * 2 - 1) * 1.2 * sp,
|
||||||
|
t: 0.25 + Math.random() * 0.2, spark: DUST_COL[court.id] || '#98948a' });
|
||||||
|
}
|
||||||
|
|
||||||
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
||||||
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
|
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
|
||||||
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
|
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
|
||||||
@@ -156,7 +168,8 @@ function doHit(ch, quality, aim) {
|
|||||||
G.aiReactT = 0; // CPU hasn't read the shot yet
|
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
|
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) { G.hitstop = 0.05; praise(); pock(0.5, 1600); // the rally hit: a real 45 ms window (perfectMs)
|
||||||
|
if (ch.side === 'P') G.spark = { x: ball.x, y: ball.y, z: ball.z, t: 0.3 }; } // ...and the contact flashes a ring
|
||||||
ch.pose = 'swing'; ch.poseT = 0;
|
ch.pose = 'swing'; ch.poseT = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +331,8 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
|||||||
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
|
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
|
||||||
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
|
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;
|
sv.pose = 'serve'; sv.poseT = 0;
|
||||||
if (perfect) { praise(); G.hitstop = 0.05; }
|
if (perfect) { praise(); G.hitstop = 0.05;
|
||||||
|
if (sv.side === 'P') G.spark = { x: ball.x, y: ball.y, z: ball.z, t: 0.3 }; } // the PERFECT ring, at the strike
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- scoring ----
|
// ---------------------------------------------------------------- scoring ----
|
||||||
@@ -803,6 +817,7 @@ export function spentBall() {
|
|||||||
export function dodge(dir) {
|
export function dodge(dir) {
|
||||||
if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return;
|
if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return;
|
||||||
G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame;
|
G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame;
|
||||||
|
dustPuff(P.x, P.z, 6); // the committed step kicks up the court — it also marks where the i-frame began
|
||||||
G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls
|
G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls
|
||||||
// G.aim to fire this, and the demo's own guard only watches G.aim.
|
// G.aim to fire this, and the demo's own guard only watches G.aim.
|
||||||
P.facing = dir;
|
P.facing = dir;
|
||||||
@@ -1299,6 +1314,8 @@ export function update(dt) {
|
|||||||
if (was < EATEN_SUMMARY && G.eatenT >= EATEN_SUMMARY) { zombieDuck(true); menuDuck(false); }
|
if (was < EATEN_SUMMARY && G.eatenT >= EATEN_SUMMARY) { zombieDuck(true); menuDuck(false); }
|
||||||
crowd.cheer = Math.max(0, crowd.cheer - dt); // the stand sits back down over the death too —
|
crowd.cheer = Math.max(0, crowd.cheer - dt); // the stand sits back down over the death too —
|
||||||
// a cheer live at the kill used to clap forever
|
// a cheer live at the kill used to clap forever
|
||||||
|
if (G.spark && (G.spark.t -= dt) <= 0) G.spark = null; // one-shot effects finish
|
||||||
|
if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // rather than freezing mid-air
|
||||||
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||||||
if (G.boomFlash > 0) G.boomFlash -= dt;
|
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||||||
updateZombies(dt);
|
updateZombies(dt);
|
||||||
@@ -1311,6 +1328,8 @@ export function update(dt) {
|
|||||||
else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
|
else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
|
||||||
}
|
}
|
||||||
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||||||
|
if (G.spark && (G.spark.t -= dt) <= 0) G.spark = null; // the PERFECT ring burns out
|
||||||
|
if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // the tape settles
|
||||||
if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it)
|
if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it)
|
||||||
if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null;
|
if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null;
|
||||||
if (G.boomFlash > 0) G.boomFlash -= dt;
|
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||||||
@@ -1514,11 +1533,13 @@ export function update(dt) {
|
|||||||
ball.z = COURT.netZ + (oldZ < COURT.netZ ? -0.05 : 0.05);
|
ball.z = COURT.netZ + (oldZ < COURT.netZ ? -0.05 : 0.05);
|
||||||
ball.vz *= -0.08; ball.vx *= 0.3; ball.vy = Math.min(ball.vy, 0);
|
ball.vz *= -0.08; ball.vx *= 0.3; ball.vy = Math.min(ball.vy, 0);
|
||||||
pock(0.4, 300);
|
pock(0.4, 300);
|
||||||
|
G.netRipple = { x: ball.x, t: 0.4 }; // the tape carries the hit: a damped wobble where it clipped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ball.y <= 0 && ball.vy < 0) {
|
if (ball.y <= 0 && ball.vy < 0) {
|
||||||
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
||||||
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
||||||
|
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court
|
||||||
onBounce();
|
onBounce();
|
||||||
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
||||||
ball.active = false;
|
ball.active = false;
|
||||||
|
|||||||
@@ -924,11 +924,11 @@ function drawAimMark(a, u0, flying) {
|
|||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRing(x, z, color) {
|
function drawRing(x, z, color, k = 1) { // k: radius scale — the landing ring contracts on time-to-touchdown
|
||||||
const p = proj(x, 0, z);
|
const p = proj(x, 0, z);
|
||||||
// integer-snapped so the moving ring steps whole pixels instead of shimmering
|
// integer-snapped so the moving ring steps whole pixels instead of shimmering
|
||||||
const cx = Math.round(p.x), cy = Math.round(p.y);
|
const cx = Math.round(p.x), cy = Math.round(p.y);
|
||||||
const rx = Math.max(2, Math.round(p.s * 0.3)), ry = Math.max(1, Math.round(p.s * 0.12));
|
const rx = Math.max(2, Math.round(p.s * 0.3 * k)), ry = Math.max(1, Math.round(p.s * 0.12 * k));
|
||||||
ctx.strokeStyle = color; ctx.lineWidth = 2;
|
ctx.strokeStyle = color; ctx.lineWidth = 2;
|
||||||
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke();
|
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke();
|
||||||
}
|
}
|
||||||
@@ -1300,6 +1300,8 @@ function drawModal(m) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ frame ----
|
// ------------------------------------------------------------------ frame ----
|
||||||
|
const ballHist = []; // hot-ball ghost trail positions (render-owned, see the streak block in render)
|
||||||
|
let flickAt = 14; // idleT of the next floodlight stutter (splatter ambience, see render)
|
||||||
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
|
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
|
||||||
const FLASH_Y = 68; // the ONE slot every free-floating message uses. Clear of the scoreboard block
|
const FLASH_Y = 68; // the ONE slot every free-floating message uses. Clear of the scoreboard block
|
||||||
// (6 + 42 + 15 = 63 at its tallest), which the old 60 was not in portrait —
|
// (6 + 42 + 15 = 63 at its tallest), which the old 60 was not in portrait —
|
||||||
@@ -1330,6 +1332,17 @@ export function render() {
|
|||||||
// the clap ripple is overlaid live inside. The ad strip sits flush on the court's back edge (the
|
// the clap ripple is overlaid live inside. The ad strip sits flush on the court's back edge (the
|
||||||
// out-area quad reaches z=-6), which keeps the boards clear of the lawn in BOTH orientations.
|
// out-area quad reaches z=-6), which keeps the boards clear of the lawn in BOTH orientations.
|
||||||
const standsBottom = drawBackdrop(pal, pt);
|
const standsBottom = drawBackdrop(pal, pt);
|
||||||
|
// floodlight stutter (splatter only): every 8-15 s the stadium lights dip for a tenth of a second
|
||||||
|
// — "the lights went wrong", made ambient. An overlay over the stands, so it never invalidates
|
||||||
|
// the backdrop cache; skipped during the duel, where the lights come back to tennis.
|
||||||
|
if (G.mode === 'splatter' && !G.interlude) {
|
||||||
|
if (flickAt > G.idleT + 16) flickAt = G.idleT + 8 + Math.random() * 7; // a fresh run pulled it back in
|
||||||
|
if (G.idleT > flickAt + 0.12) flickAt = G.idleT + 8 + Math.random() * 7;
|
||||||
|
else if (G.idleT >= flickAt) {
|
||||||
|
ctx.fillStyle = `rgba(4,2,10,${(0.3 * Math.abs(Math.sin((G.idleT - flickAt) / 0.12 * Math.PI * 2))).toFixed(3)})`;
|
||||||
|
ctx.fillRect(0, 0, W, standsBottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
|
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
|
||||||
for (const d of gore) {
|
for (const d of gore) {
|
||||||
@@ -1344,7 +1357,9 @@ export function render() {
|
|||||||
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
|
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
|
||||||
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
||||||
const l = predictLanding();
|
const l = predictLanding();
|
||||||
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
|
// the ring CONTRACTS toward touchdown (predictLanding's closed-form t), so the static marker
|
||||||
|
// becomes a timing cue: wide while the ball is high, snapping to its true size as it arrives
|
||||||
|
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)', Math.min(2.2, 0.55 + l.t * 1.5));
|
||||||
}
|
}
|
||||||
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? UI.hot : UI.warm);
|
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? UI.hot : UI.warm);
|
||||||
// SLINGSHOT AIM: the shot's actual flight, drawn as a band through the air.
|
// SLINGSHOT AIM: the shot's actual flight, drawn as a band through the air.
|
||||||
@@ -1391,7 +1406,18 @@ export function render() {
|
|||||||
ctx.fillStyle = 'rgba(20,26,34,0.72)';
|
ctx.fillStyle = 'rgba(20,26,34,0.72)';
|
||||||
ctx.beginPath(); ctx.moveTo(nl.x, nl.y); ctx.lineTo(nr.x, nr.y); ctx.lineTo(nrt.x, nrt.y); ctx.lineTo(nlt.x, nlt.y); ctx.fill();
|
ctx.beginPath(); ctx.moveTo(nl.x, nl.y); ctx.lineTo(nr.x, nr.y); ctx.lineTo(nrt.x, nrt.y); ctx.lineTo(nlt.x, nlt.y); ctx.fill();
|
||||||
ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
|
ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
|
||||||
ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke();
|
ctx.beginPath();
|
||||||
|
const rip = G.netRipple;
|
||||||
|
if (rip) { // net cord: a damped travelling wave centred where the ball clipped the tape (0.4 s)
|
||||||
|
const px = proj(rip.x, COURT.netH, COURT.netZ).x;
|
||||||
|
for (let i = 0; i <= 24; i++) {
|
||||||
|
const x = nlt.x + (nrt.x - nlt.x) * i / 24;
|
||||||
|
const y = nlt.y + (nrt.y - nlt.y) * i / 24
|
||||||
|
+ Math.sin((x - px) * 0.3 + rip.t * 34) * 3 * (rip.t / 0.4) * Math.exp(-Math.abs(x - px) * 0.05);
|
||||||
|
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||||
|
}
|
||||||
|
} else { ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); }
|
||||||
|
ctx.stroke();
|
||||||
ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y);
|
ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y);
|
||||||
drawPiles(true); // near-side piles: after the net (they're closer), before the near actors
|
drawPiles(true); // near-side piles: after the net (they're closer), before the near actors
|
||||||
if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy);
|
if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy);
|
||||||
@@ -1431,6 +1457,22 @@ export function render() {
|
|||||||
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex);
|
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex);
|
||||||
drawBall(bx, by, sv.z);
|
drawBall(bx, by, sv.z);
|
||||||
}
|
}
|
||||||
|
// hot-ball ghost streak: a rally ball above 0.8 pow drags three fading ghosts behind it. Serves
|
||||||
|
// already have the erasing arc band; this gives the volley its own read. Render-owned visual
|
||||||
|
// state (like the crowd dots) — gameplay never sees it.
|
||||||
|
const hot = ball.active && (ball.pow || 0) > 0.8 && G.phase === 'rally' && !ball.collected;
|
||||||
|
if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 7) ballHist.shift(); }
|
||||||
|
else if (ballHist.length) ballHist.length = 0;
|
||||||
|
if (hot) {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const h = ballHist[ballHist.length - 2 - i * 2]; if (!h) break;
|
||||||
|
const p = proj(h.x, h.y, h.z);
|
||||||
|
ctx.globalAlpha = 0.26 - i * 0.08;
|
||||||
|
ctx.fillStyle = UI.ball;
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1, p.s * 0.09 * (0.8 - i * 0.18)), 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
// the match ball (and any triple-serve side balls in flight)
|
// the match ball (and any triple-serve side balls in flight)
|
||||||
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
||||||
for (const e of extras) drawBall(e.x, e.y, e.z);
|
for (const e of extras) drawBall(e.x, e.y, e.z);
|
||||||
@@ -1516,6 +1558,20 @@ 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();
|
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)
|
if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying)
|
||||||
|
if (G.spark) { // PERFECT contact: a one-shot expanding ring + four spark pixels at the strike.
|
||||||
|
// Light, which the procedural rule explicitly exempts — same clause as the barrel's glow.
|
||||||
|
const s = G.spark, k = 1 - s.t / 0.3, p = proj(s.x, s.y, s.z);
|
||||||
|
const r = Math.max(2, p.s * (0.06 + 0.4 * k));
|
||||||
|
ctx.strokeStyle = `rgba(255,255,255,${(0.9 * (1 - k)).toFixed(3)})`;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, r, r * 0.75, 0, 0, 7); ctx.stroke();
|
||||||
|
ctx.fillStyle = `rgba(255,250,220,${(1 - k).toFixed(3)})`;
|
||||||
|
for (let i = 0; i < 4; i++) { // sparks fly the diagonals, riding the ring's edge outward
|
||||||
|
const a = i * 1.5708 + 0.7854;
|
||||||
|
ctx.fillRect(Math.round(p.x + Math.cos(a) * r * 1.35), Math.round(p.y + Math.sin(a) * r), 1.5, 1.5);
|
||||||
|
}
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
}
|
||||||
// The braced "rambo racket" is NOT drawn any more. It was a procedural racket laid over a sprite
|
// The braced "rambo racket" is NOT drawn any more. It was a procedural racket laid over a sprite
|
||||||
// that already holds one: straight up-court it projected onto his back as a floating oval, and
|
// that already holds one: straight up-court it projected onto his back as a floating oval, and
|
||||||
// offset to the side it just stuck out. The pellets streaming up-court are the feedback; the man
|
// offset to the side it just stuck out. The pellets streaming up-court are the feedback; the man
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ export const G = {
|
|||||||
modal: null, // {title, body: [lines], icon, iconH, color, onDismiss}
|
modal: null, // {title, body: [lines], icon, iconH, color, onDismiss}
|
||||||
modalQ: [], // popups waiting behind the open one — see showModal
|
modalQ: [], // popups waiting behind the open one — see showModal
|
||||||
seen: {}, // one-shot markers so a first-time-only modal fires once per run
|
seen: {}, // one-shot markers so a first-time-only modal fires once per run
|
||||||
zoom: null, // {x, z, k, t, T} — a scripted punch-in on a world point (the transition's turn)
|
zoom: null, // {x, z, k, t, T} — a scripted punch-in on a world point (the transition's turn, a boss death)
|
||||||
|
spark: null, // {x, y, z, t} — PERFECT contact: a one-shot ring + sparks at the racket face
|
||||||
|
netRipple: null, // {x, t} — the ball clipped the tape; the net's top line wobbles there briefly
|
||||||
best: null, // best run {games, sets}, loaded from localStorage when you get eaten
|
best: null, // best run {games, sets}, loaded from localStorage when you get eaten
|
||||||
newBest: false, // this run beat the stored best — the eaten summary celebrates it
|
newBest: false, // this run beat the stored best — the eaten summary celebrates it
|
||||||
lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde
|
lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde
|
||||||
|
|||||||
+10
-1
@@ -390,6 +390,10 @@ function wreckMachine(zb) {
|
|||||||
// through the crowd's roar, and the card that announces it waits until the pieces have landed.
|
// through the crowd's roar, and the card that announces it waits until the pieces have landed.
|
||||||
function bossDeath(zb) {
|
function bossDeath(zb) {
|
||||||
G.slowT = TUNE.bossSlowT; // real seconds; main.js scales dt while it runs
|
G.slowT = TUNE.bossSlowT; // real seconds; main.js scales dt while it runs
|
||||||
|
// ...and the camera leans in on it: the transition's punch-in, reused at a gentler magnification,
|
||||||
|
// riding the slow-mo so the longest fight in the game ends on a camera moment. T is GAME time
|
||||||
|
// (update ticks zoom with the scaled dt), so the peak lands mid-slow-mo and eases out after.
|
||||||
|
G.zoom = { x: zb.x, z: zb.z, k: 1.22, t: 0, T: 0.8 };
|
||||||
G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws
|
G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws
|
||||||
G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12;
|
G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12;
|
||||||
boom(); splat(1, bodySize(zb)); cheer(4, 1); voice('unbelievable', 0.5); // the biggest body in the game, at its lowest
|
boom(); splat(1, bodySize(zb)); cheer(4, 1); voice('unbelievable', 0.5); // the biggest body in the game, at its lowest
|
||||||
@@ -590,11 +594,16 @@ function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
|||||||
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
|
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
|
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
|
||||||
ball.kills++; // this shot's running streak
|
ball.kills++; // this shot's running streak
|
||||||
|
// splat micro-freeze: every ball kill stutters the world a touch, and the stutter grows with the
|
||||||
|
// streak, so a triple kill physically hitches three times. max(), never assignment — a bomb's own
|
||||||
|
// 0.18 s freeze must not be trimmed by the kills it causes.
|
||||||
|
G.hitstop = Math.max(G.hitstop, Math.min(0.09, 0.03 + 0.012 * ball.kills));
|
||||||
// escalating multi-kill fanfare — one shot, ≥2 zombies
|
// 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
|
// ...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); }
|
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 === 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); }
|
else if (ball.kills >= 4) { G.flash = { text: `MASSACRE ×${ball.kills}`, t: 0.9 }; 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball)
|
// ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball)
|
||||||
|
|||||||
Reference in new issue
Block a user