THE FREEZE (root cause found): the game-winning kill fires INSIDE the zombie update loop — killZombie -> splatPoint -> gameOver -> sweepCourt guts the zombies array mid-iteration, the loop reads zombies[i] === undefined, throws, and the un-rescheduled RAF chain dies: a permanent hard freeze. Crowded courts (later games, brutes) make the mid-loop game-winner likelier — hence 'freezes around game 3 when the fat zombies arrive'. Fixed at the root (null guards in every loop whose body can cascade into a sweep: zombie walk loop, meleeSwipe, explode, pellet + extras inner loops) AND made frame() reschedule RAF FIRST so no single bad frame can ever kill the loop again. Aim awareness: the reticle now appears the moment the thumb lands (mag 0 = auto-target the most advanced zombie, red lock immediately). A pure HOLD (>250ms, no drag) releases into the auto-target serve; dragging aims as before; the serve prompt switches to 'RELEASE TO SERVE / RELEASE FIRES THE BOMB - DRAG BACK TO CANCEL' while aiming. Duel presentation: the interlude now opens with a 2.4s intro beat — serving blocked, centered card (SUDDEN DEATH / ONE POINT - THE LINES ARE LIVE / WIN IT FOR THE NEXT TIN), and the palette lerps back to the TENNIS colors for the duration of the duel (duelLerp, 0.8s each way) — order visibly restored, then the rot returns. Machine-gun steering: absolute now — the finger x IS the player x, zero lag (was sign-quantized velocity). Clamped to what the camera can see ((W/2-14)/proj-scale — portrait's world bounds are wider than its viewport), keyboard steering clamped the same during auto-fire. Verified: the exact pre-fix crash scenario (game-winning kill mid-loop, 9 zombies + brute) survives and sweeps clean; intro blocks taps then the duel plays; hold-release fired at the auto-lock and won a tin end-to-end; screenshot shows tennis-green court + centered card + GAME 2; MG jumps P.x to the finger in one frame, clamped; tennis 12/12; no console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
441 lines
24 KiB
JavaScript
441 lines
24 KiB
JavaScript
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
|
|
|
import { cv, ctx } from './dom.js';
|
|
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
|
import { P, O, ball, score, crowd, G } from './state.js';
|
|
import { predictLanding, flickTarget } from './gameplay.js';
|
|
import { zombies, gore, piles, parts, pellets, extras } from './zombies.js';
|
|
|
|
// ---------------------------------------------------------------- palette ----
|
|
// tennis palette -> drained splatter palette; the transition lerps between them
|
|
const PAL = {
|
|
tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e',
|
|
out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' },
|
|
splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212',
|
|
out: '#20331c', lawn: '#5e7747', stripe: '#576f42' },
|
|
};
|
|
const hexLerp = (a, b, t) => {
|
|
const c = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
|
const [ar, ag, ab] = c(a), [br, bg, bb] = c(b);
|
|
return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`;
|
|
};
|
|
function palette() {
|
|
// splatter lerps back toward the tennis palette while a duel runs — order, restored
|
|
const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
|
|
if (t === 0) return PAL.tennis;
|
|
if (t === 1) return PAL.splatter;
|
|
const m = {};
|
|
for (const k in PAL.tennis) m[k] = hexLerp(PAL.tennis[k], PAL.splatter[k], t);
|
|
return m;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- camera ----
|
|
export const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail
|
|
export let W = 480, H = 270;
|
|
|
|
export function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens
|
|
const portrait = innerHeight > innerWidth;
|
|
const [w, h] = portrait ? [270, 480] : [480, 270];
|
|
if (cv.width !== w * RES) {
|
|
W = w; H = h;
|
|
cv.width = w * RES; cv.height = h * RES;
|
|
TUNE.camF = portrait ? 400 : 280;
|
|
TUNE.horizon = portrait ? 90 : 40;
|
|
buildCrowd();
|
|
}
|
|
ctx.imageSmoothingEnabled = false; // resets on canvas resize
|
|
const sc = Math.min(innerWidth / w, innerHeight / h);
|
|
cv.style.width = w * sc + 'px'; cv.style.height = h * sc + 'px';
|
|
}
|
|
addEventListener('resize', fitViewport);
|
|
|
|
export function proj(x, y, z) {
|
|
const d = TUNE.camZ - z, s = TUNE.camF / d;
|
|
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY - y) * s, s };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sprites ----
|
|
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
|
const SPR = {};
|
|
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
|
|
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
|
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
|
|
'z_a1','z_a2','z_b1','z_b2','z_c1','z_c2',
|
|
'z_f1','z_f2','z_g1','z_g2','z_s1','z_s2','z_s3']) {
|
|
const img = new Image();
|
|
img.onload = () => SPR[n] = img;
|
|
img.src = 'assets/' + n + '.png';
|
|
}
|
|
|
|
// ------------------------------------------------------------------ crowd ----
|
|
export function buildCrowd() {
|
|
crowd.dots.length = 0;
|
|
const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8);
|
|
for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({
|
|
x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2,
|
|
c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8]
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------- primitives ----
|
|
function line(x1, z1, x2, z2, w = 1) {
|
|
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
|
|
ctx.strokeStyle = '#e8e8dc'; ctx.lineWidth = w;
|
|
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
}
|
|
function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid)
|
|
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), c = proj(x2, 0, z2), d = proj(x1, 0, z2);
|
|
ctx.fillStyle = color; ctx.beginPath();
|
|
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill();
|
|
}
|
|
function drawShadow(px, py, rx, ry, alpha = 0.28) {
|
|
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
|
|
ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill();
|
|
}
|
|
function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
|
|
const sh = proj(x, 0, z);
|
|
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3);
|
|
const bp = proj(x, y, z);
|
|
const r = Math.max(1.5, bp.s * 0.09);
|
|
if (bomb) { // black ball with a flickering orange fuse pixel on top
|
|
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
|
if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); }
|
|
return;
|
|
}
|
|
if (flame) { // white-hot core in an orange shell, flickering
|
|
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r + (Math.floor(G.idleT * 24) % 2 ? 1 : 0), 0, 7); ctx.fill();
|
|
ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill();
|
|
return;
|
|
}
|
|
ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
|
if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); }
|
|
}
|
|
|
|
// ----------------------------------------------------------------- actors ----
|
|
function drawStatic(name, x, z, hMeters, bob = 0) {
|
|
const img = SPR[name]; if (!img) return;
|
|
const p = proj(x, 0, z);
|
|
const h = hMeters * p.s, w = h * img.width / img.height;
|
|
drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
|
|
ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
|
|
}
|
|
function drawBoy(b) {
|
|
const img = SPR['x_boyrun' + (Math.floor(b.runT * 14) % 4 + 1)]; if (!img) return;
|
|
const p = proj(b.x, 0, b.z);
|
|
const h = BOY_H * p.s, w = h * img.width / img.height;
|
|
drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25);
|
|
ctx.save(); ctx.translate(p.x, p.y - (Math.floor(b.runT * 20) % 2));
|
|
if (b.dir < 0) ctx.scale(-1, 1);
|
|
ctx.drawImage(img, -w / 2, -h, w, h);
|
|
ctx.restore();
|
|
}
|
|
function drawZombie(zb) {
|
|
const rk = zb.variant === 'r';
|
|
const v = rk ? 'c' : zb.variant; // the racket boss reuses the turned-CPU sprite (already holds a racket)
|
|
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6)) % 2 + 1;
|
|
if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
|
|
const h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
|
|
drawStatic('z_' + v + frame, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
|
if (rk) { // HP bar floating above the mini-boss
|
|
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
|
|
ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3);
|
|
ctx.fillStyle = '#e03030'; ctx.fillRect(x, p.y, w * Math.max(0, zb.hp) / TUNE.racketHp, 3);
|
|
}
|
|
}
|
|
function drawRing(x, z, color) {
|
|
const p = proj(x, 0, z);
|
|
// integer-snapped so the moving ring steps whole pixels instead of shimmering
|
|
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));
|
|
ctx.strokeStyle = color; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke();
|
|
}
|
|
|
|
// gore piles: little 3D-ish mounds that accrete where kills cluster. Each pile's blobs are
|
|
// seeded ONCE from p.seed (rebuilt only when it grows), so the pile never shimmers frame to
|
|
// frame and drawing costs one proj() call — blob screen positions offset from that base.
|
|
function mulberry32(a) {
|
|
return () => { a |= 0; a = a + 0x6D2B79F5 | 0;
|
|
let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
return ((t ^ t >>> 14) >>> 0) / 4294967296; };
|
|
}
|
|
const PILE_COLS = ['#6e1414', '#8c1212', '#4e0e0e', '#7a2a18'];
|
|
function buildBlobs(pl) {
|
|
const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5);
|
|
pl.blobs = [];
|
|
for (let k = 0; k < n; k++) {
|
|
const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk
|
|
pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad,
|
|
lift: rnd(), pr: 1 + rnd() * 2, color: rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0] });
|
|
}
|
|
pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top
|
|
pl.builtSize = pl.size;
|
|
}
|
|
function drawGorePile(pl) {
|
|
if (!pl.blobs || pl.size > pl.builtSize) buildBlobs(pl); // rebuild only on growth, never on the mop-shrink
|
|
const base = proj(pl.x, 0, pl.z), s = base.s, sz = pl.size;
|
|
for (const b of pl.blobs) {
|
|
const sx = base.x + b.nx * sz * 0.28 * s;
|
|
const sy = base.y + b.nz * sz * 0.28 * s * 0.4 - b.lift * sz * 0.14 * s; // depth squished onto ground; lift raises it
|
|
const r = Math.max(1.2, b.pr * s * 0.035);
|
|
ctx.fillStyle = b.color;
|
|
ctx.beginPath(); ctx.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); ctx.fill();
|
|
}
|
|
}
|
|
function drawPiles(near) { // split at the net like zombies so height occludes correctly
|
|
for (const pl of piles.filter(p => near ? p.z > COURT.netZ : p.z <= COURT.netZ).sort((a, b) => a.z - b.z))
|
|
drawGorePile(pl);
|
|
}
|
|
|
|
function drawChar(ch) {
|
|
const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle';
|
|
const sway = receiverWaiting ? Math.sin(G.idleT * 3.2) : 0;
|
|
const p = proj(ch.x + sway * 0.12, 0, ch.z);
|
|
const hpx = PLAYER_H * p.s;
|
|
// pick sprite
|
|
const pre = ch.side === 'P' ? 'p_' : 'o_';
|
|
let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift
|
|
if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') {
|
|
const w = G.idleT * BOUNCE_IDLE.tempo; // same phase as the bouncing ball
|
|
const rising = Math.sin(w) * Math.cos(w) > 0;
|
|
name = pre + (rising ? 'bounce2' : 'bounce'); // hand up while the ball rises, pat down exactly at the apex
|
|
}
|
|
if (ch.pose === 'run') name = pre + (Math.floor(ch.runT * 9) % 2 ? 'run1' : 'run2');
|
|
if (receiverWaiting) name = pre + (sway > 0 ? 'run1' : 'run2'); // split-step shuffle
|
|
if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
|
|
if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash');
|
|
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
|
|
const img = SPR[name];
|
|
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
|
|
if (img) {
|
|
const k = hpx / SPRITE_H; // sprites share one scale; the idle pose defines it
|
|
const w = img.width * k, h = img.height * k;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
if (ch.facing < 0) ctx.scale(-1, 1);
|
|
ctx.drawImage(img, -w / 2, -h, w, h);
|
|
ctx.restore();
|
|
} else drawFallbackChar(ch, p, hpx);
|
|
}
|
|
function drawFallbackChar(ch, p, h) { // shown only until sprites load
|
|
const you = ch.side === 'P';
|
|
const u = h / 16, x = p.x, y = p.y;
|
|
const skin = '#e8b88a', shirt = you ? '#e8e8e8' : '#d04848', shorts = you ? '#3858a8' : '#404040';
|
|
ctx.fillStyle = skin; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 4 * u);
|
|
ctx.fillStyle = you ? '#804820' : '#282828'; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 1.2 * u);
|
|
ctx.fillStyle = shirt; ctx.fillRect(x - 2.6 * u, y - 12 * u, 5.2 * u, 6 * u);
|
|
ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u);
|
|
ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u);
|
|
}
|
|
function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style
|
|
const hip = proj(P.x + 0.15, 1.05, P.z);
|
|
const tip = proj(P.x, 0.95, P.z - 1.35); // pointing up-court, where the pellets stream out
|
|
ctx.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke();
|
|
ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward
|
|
ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke();
|
|
const cool = G.horde ? G.horde.mgT : G.mgCool; // horde or GUN-tin pickup — same racket
|
|
if (cool > 1 / TUNE.mgRate - 0.06) { // muzzle flash right after a shot
|
|
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill();
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ frame ----
|
|
export function render() {
|
|
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
|
|
if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD)
|
|
const m = G.shake.mag * G.shake.t / 0.35;
|
|
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
|
|
}
|
|
|
|
const pal = palette();
|
|
// stands: tiered crowd from top of screen down to just above the far court
|
|
const standsBottom = proj(0, 0, 0).y - 12;
|
|
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
|
|
for (let r = 0; r < 16; r++) {
|
|
ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB;
|
|
ctx.fillRect(0, 6 + r * 5.5, W, 5.5);
|
|
}
|
|
for (const d of crowd.dots) {
|
|
const jx = crowd.cheer > 0 ? (Math.random() * 2 - 1) : 0;
|
|
const jy = crowd.cheer > 0 ? -Math.random() * 1.5 : 0;
|
|
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
|
|
}
|
|
// courtside billboard wall
|
|
ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - 10, W, 10);
|
|
ctx.fillStyle = '#dfe8d0'; ctx.font = 'bold 7px monospace'; ctx.textBaseline = 'top'; ctx.textAlign = 'center';
|
|
ctx.fillText(W > 400 ? '· SPLATTER OPEN · NEW BALLS PLEASE · SPLATTER OPEN ·' : '· SPLATTER OPEN ·', W / 2, standsBottom - 9);
|
|
ctx.textAlign = 'left';
|
|
|
|
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
|
|
const dw = COURT.dblW / 2;
|
|
quadXZ(-14, -6, 14, 30, pal.out); // out area
|
|
quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, '#e8e8dc'); // perimeter border line
|
|
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // lawn (doubles court)
|
|
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe); // mow stripes
|
|
// lines: baselines, doubles + singles sidelines, service box
|
|
line(-dw, 0, dw, 0, 2); line(-dw, COURT.L, dw, COURT.L, 2);
|
|
line(-dw, 0, -dw, COURT.L, 1.5); line(dw, 0, dw, COURT.L, 1.5);
|
|
line(-COURT.W / 2, 0, -COURT.W / 2, COURT.L, 1.5); line(COURT.W / 2, 0, COURT.W / 2, COURT.L, 1.5);
|
|
line(-COURT.W / 2, COURT.svcFar, COURT.W / 2, COURT.svcFar, 1);
|
|
line(-COURT.W / 2, COURT.svcNear, COURT.W / 2, COURT.svcNear, 1);
|
|
line(0, COURT.svcFar, 0, COURT.svcNear, 1);
|
|
line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks
|
|
|
|
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
|
|
for (const d of gore) {
|
|
const p = proj(d.x, 0, d.z);
|
|
ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : `rgba(140,18,18,${d.a})`; // craters burn black-brown
|
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill();
|
|
}
|
|
drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
|
|
|
|
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
|
|
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
|
const l = predictLanding();
|
|
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
|
|
}
|
|
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048');
|
|
// two-stage serve aim: the reticle tracks the thumb; red = the magnet has a zombie locked
|
|
if (G.aim && G.mode === 'splatter' && G.phase === 'pretoss') {
|
|
const ft = flickTarget(P, G.aim);
|
|
if (ft.zt) { // locked: the ring rides the zombie itself, plus a marker over its head
|
|
drawRing(ft.zt.x, ft.zt.z, '#ff5050');
|
|
const hp = proj(ft.zt.x, 2.2, ft.zt.z);
|
|
ctx.fillStyle = '#ff5050'; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3);
|
|
} else if (ft.tx !== undefined) drawRing(ft.tx, ft.tz, '#f8e048');
|
|
}
|
|
|
|
// far-side drawables in z order (far → near), then the net, then the near side
|
|
const farSide = [
|
|
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, UMP_H) },
|
|
];
|
|
if (!G.hideO) farSide.push({ z: O.z, f: () => drawChar(O) });
|
|
if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) });
|
|
for (const zb of zombies) if (zb.z <= COURT.netZ) farSide.push({ z: zb.z, f: () => drawZombie(zb) });
|
|
farSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
|
|
|
// net
|
|
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
|
|
const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ);
|
|
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.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); 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);
|
|
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);
|
|
zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie);
|
|
|
|
// server idly bouncing the ball while waiting
|
|
if (G.phase === 'pretoss') {
|
|
const sv = score.server === 'P' ? P : O;
|
|
const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1);
|
|
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex;
|
|
drawBall(bx, by, sv.z);
|
|
}
|
|
// 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);
|
|
for (const e of extras) drawBall(e.x, e.y, e.z);
|
|
// splatter particles (fire embers ride the same array)
|
|
for (const pt of parts) {
|
|
const pp = proj(pt.x, pt.y, pt.z);
|
|
ctx.fillStyle = pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
|
|
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
|
|
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
|
}
|
|
// machine-gun pellets: flat tracers with a tiny ground shadow
|
|
for (const pe of pellets) {
|
|
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
|
|
const pp = proj(pe.x, pe.y, pe.z);
|
|
ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2);
|
|
}
|
|
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
|
|
if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player
|
|
|
|
ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady
|
|
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,255,255,${Math.min(1, G.boomFlash / 0.08) * 0.85})`; ctx.fillRect(0, 0, W, H); }
|
|
|
|
// HUD — scoreboard
|
|
ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30);
|
|
ctx.strokeStyle = '#5a6a85'; ctx.strokeRect(6.5, 6.5, 131, 29);
|
|
ctx.font = 'bold 9px monospace'; ctx.textBaseline = 'top';
|
|
const ptTxt = i => score.pts[i] >= 3 && score.pts[0] >= 3 && score.pts[1] >= 3
|
|
? (score.pts[i] === score.pts[1 - i] ? '40' : (score.pts[i] > score.pts[1 - i] ? 'AD' : '40'))
|
|
: ['0', '15', '30', '40'][Math.min(score.pts[i], 3)];
|
|
const setsTxt = i => G.mode === 'tennis' ? '' : G.sets[i] + '·'; // splatter scoreboard: SETS·GAMES PTS
|
|
ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc';
|
|
ctx.fillText('YOU ' + setsTxt(0) + score.games[0] + ' ' + ptTxt(0), 12, 10);
|
|
for (let b = 0; b < G.bombs; b++) { // stocked bomb balls: black pips with an orange fuse rim
|
|
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill();
|
|
ctx.strokeStyle = '#ff8010'; ctx.lineWidth = 0.75; ctx.stroke();
|
|
}
|
|
for (let b = 0; b < G.triples; b++) { // triple serves: a yellow pip flanked by two dots
|
|
ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill();
|
|
ctx.fillRect(96 + b * 8 - 4, 13, 1.5, 1.5); ctx.fillRect(96 + b * 8 + 3, 13, 1.5, 1.5);
|
|
}
|
|
for (let b = 0; b < G.flames; b++) { // flame balls: orange pip, hot core
|
|
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill();
|
|
ctx.fillStyle = '#ffe8a0'; ctx.fillRect(96 + b * 8 - 1, 13, 2, 2);
|
|
}
|
|
if (G.mgT > 0) { // machine-gun time: a draining ammo bar
|
|
ctx.fillStyle = '#333'; ctx.fillRect(96, 11, 34, 5);
|
|
ctx.fillStyle = '#f8e048'; ctx.fillRect(96, 11, 34 * Math.min(1, G.mgT / TUNE.mgPickupLen), 5);
|
|
}
|
|
ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc';
|
|
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + setsTxt(1) + score.games[1] + ' ' + ptTxt(1), 12, 22);
|
|
ctx.textAlign = 'center';
|
|
if (G.mode === 'eaten') { // the run summary — permadeath makes this scoreboard the whole game
|
|
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = '#c02020'; ctx.font = 'bold 16px monospace';
|
|
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 32);
|
|
ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace';
|
|
ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 6);
|
|
if (G.best) {
|
|
ctx.fillStyle = '#8fa0b8'; ctx.font = 'bold 8px monospace';
|
|
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 10);
|
|
}
|
|
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 28); }
|
|
}
|
|
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) {
|
|
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
|
|
const touch = matchMedia('(pointer: coarse)').matches;
|
|
const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null;
|
|
const txt = G.mode === 'splatter'
|
|
? (G.aim ? (stock ? `RELEASE FIRES THE ${stock} · DRAG BACK TO CANCEL` : 'RELEASE TO SERVE · DRAG BACK TO CANCEL')
|
|
: stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT`
|
|
: touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM')
|
|
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
|
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
|
|
ctx.fillText(txt, W / 2, H - 16);
|
|
}
|
|
if (G.flash) {
|
|
ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
|
|
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
|
}
|
|
if (G.interlude && G.interlude.introT > 0) { // the duel card: a real pause, front and center
|
|
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
|
|
ctx.fillStyle = '#ff5050'; ctx.font = 'bold 18px monospace';
|
|
ctx.fillText('SUDDEN DEATH', W / 2, H / 2 - 26);
|
|
ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace';
|
|
ctx.fillText('ONE POINT · THE LINES ARE LIVE', W / 2, H / 2 - 2);
|
|
ctx.fillText('WIN IT FOR THE NEXT TIN', W / 2, H / 2 + 12);
|
|
}
|
|
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
|
|
ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060';
|
|
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
|
|
}
|
|
if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly
|
|
const list = G.missLog.slice(-9), lh = 10, top = 40;
|
|
ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16);
|
|
ctx.font = 'bold 7px monospace'; ctx.textAlign = 'left';
|
|
ctx.fillStyle = '#8fa0b8'; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5);
|
|
const maxCh = Math.floor((W - 20) / 4.3);
|
|
list.forEach((e, i) => {
|
|
ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060';
|
|
ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh);
|
|
});
|
|
}
|
|
ctx.textAlign = 'left';
|
|
}
|