// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, 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, shots } 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)})`; }; // how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home function paletteT() { return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6); } function palette() { const t = paletteT(); 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; } // Every string on the canvas goes through this. Bare `monospace` resolves to Courier — a slab // SERIF — which is what made the HUD look generic; Silkscreen is the bundled pixel face. const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`; const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`; // ---------------------------------------------------------------- camera ---- const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC // Physical pixels per logical pixel. On an iPhone the canvas ends up ~390 CSS px wide = 1170 DEVICE // px, so RES 3 (810) was still being upscaled 1.44x by the browser. 4 gets us to 1080, near 1:1. // THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget. export const RES = 4; export let W = 480, H = 270; // Split responsibility, and the split is deliberate: // TOP — index.html insets the canvas element below the status bar. SAFE.top stays 0; adding an // offset here as well would subtract the notch twice. // BOTTOM — the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the // screen (the home indicator is a slim bar, not opaque UI). Only the interactive RACK is // lifted clear of it, by SAFE.bottom below. export const SAFE = { top: 0, bottom: 0, topCss: 0 }; function measureSafe(scale) { const el = document.getElementById('safe'); if (!el) return; const cs = getComputedStyle(el); SAFE.bottom = Math.round((parseFloat(cs.paddingBottom) || 0) / scale); SAFE.topCss = Math.round(parseFloat(cs.paddingTop) || 0); // CSS px — for the page background below } // The page background behind the canvas. It shows in exactly two places: the strip above the canvas // under the status bar, and the slivers a phone's ROUNDED DISPLAY CORNERS clip out of the canvas // itself. A flat dark body therefore drew a visible dark notch where the bottom corners curve in. // So: dark down to the top inset, then the LIVE out-area colour for the rest — live because `pal.out` // lerps between the tennis and splatter palettes, so any fixed hex would only match in one mode. // Written in resolved px (not env()) so the declaration can never be invalid and drop the background. let bgLast = ''; function pageBackground(out) { const t = SAFE.topCss; const css = `linear-gradient(#10141c 0px, #10141c ${t}px, ${out} ${t}px, ${out} 100%)`; if (css === bgLast) return; // only touch the DOM when it actually changed bgLast = css; // documentElement, NOT body: the ROOT element's background is what paints the viewport canvas. // index.html gives html a background, so body's never propagates — setting it there painted only // body's own box, which has no height (the canvas is position: fixed) and so painted nothing. document.documentElement.style.background = css; } // The CSS box is only a PROPOSAL. Added to the homescreen, iOS resolves 100dvh once at launch against // a provisional viewport and never re-resolves it — no resize, no layout change, so nothing (not even // a ResizeObserver) fires and the canvas stays a strip short of the bottom until you rotate the phone // and back. visualViewport is the authority on what is actually visible, so measure that and pin the // height ourselves. Re-read on every fit, so a late safe-area inset or a rotation just re-pins it. function fitBox() { const el = document.getElementById('safe'); const top = el ? parseFloat(getComputedStyle(el).paddingTop) || 0 : 0; // the inset the canvas starts below const want = Math.round((window.visualViewport?.height || innerHeight) - top); if (want > 120 && Math.abs(cv.clientHeight - want) > 1) cv.style.height = want + 'px'; // sane, and only when it differs } export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is fitBox(); const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth); // Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller // (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom. // The court is positioned by proj() off TUNE.horizon in ABSOLUTE logical px, and the rack/meter hang // off `H - ...`, so extra height simply widens the apron between the court and the rack — nothing // has to be re-laid-out. Clamped so a freak aspect can't stretch the apron absurdly. // Derive the logical size from the box CSS actually gave the canvas, not from innerWidth/innerHeight. // The canvas is stretched to 100vw x 100dvh, so matching its logical aspect to that box exactly is // what guarantees no letterbox and no distortion, however the phone's toolbars behave. const bw = cv.clientWidth || innerWidth, bh = cv.clientHeight || innerHeight; const base = portrait ? 270 : 480; const other = Math.round(base * (portrait ? bh / Math.max(1, bw) : bh / Math.max(1, bw))); const [w, h] = portrait ? [base, Math.max(430, Math.min(680, other))] : [base, Math.max(230, Math.min(340, other))]; if (cv.width !== w * RES || cv.height !== h * RES) { W = w; H = h; cv.width = w * RES; cv.height = h * RES; // flatter lens: camZ pushed back (config 42, was 36) with camF scaled to keep the PLAYER // the same size — far-court zombies render ~25% bigger and actually read as zombies // READABILITY PASS. Oliver's wife: "everything is a tad too small" — a far shambler on an iPhone // was 26 logical px tall with almost no legible detail. Pushing the camera BACK (camZ) while // lengthening the lens (camF) flattens the perspective, so distant bodies grow relative to near // ones instead of everything just zooming. camY is raised with it for a steeper look-down (bodies // overlap each other less at depth) and `horizon` re-centres the court after both. // Net: far shambler 26 -> 30 logical px, the player unchanged, the court occupying the same band. // If you retune these, check all four: far size, near size, court top, court bottom. TUNE.camZ = portrait ? 48 : 42; TUNE.camY = portrait ? 11 : 9; TUNE.camF = portrait ? 760 : 406; // horizon RIDES the adaptive height. proj() places the court in ABSOLUTE logical px off this, so // leaving it fixed while h grew just dumped every extra pixel into a dead green apron between the // court and the rack. 0.72 of the extra height goes to pushing the court down (the rest becomes // more stand above it), which keeps the apron about where it was at the old fixed 480. TUNE.horizon = portrait ? Math.round(45 + (h - 480) * 0.72) : 40; buildCrowd(); } ctx.imageSmoothingEnabled = false; // resets on canvas resize // CSS owns the on-screen size and the TOP inset; we match the logical grid and measure the bottom // inset in logical px so the rack can lift clear of the home indicator. measureSafe((cv.clientWidth || innerWidth) / w); } // Every signal that the visible area may have changed, plus a short settle salvo — because the case // that broke it (a homescreen launch) fires NO signal at all: the viewport iOS hands you at launch is // simply wrong and is never corrected. fitViewport is idempotent (fitBox no-ops within 1px, and the // logical size early-outs unless it really changed), so re-running it costs nothing. addEventListener('resize', fitViewport); addEventListener('orientationchange', fitViewport); addEventListener('pageshow', fitViewport); // the app was resumed from the bfcache window.visualViewport?.addEventListener('resize', fitViewport); // the one iOS does fire when the bars move new ResizeObserver(fitViewport).observe(cv); // the box moved, whatever moved it for (const ms of [0, 120, 400, 1200]) setTimeout(fitViewport, ms); // ...and the launch that says nothing // The aim camera: while the slingshot is drawn the view rises and leans back a little, which // steepens the look down the court. That extra depth is what makes a shambler standing right // behind the net a shot you can actually judge. Eased once per frame in render(), never mid-frame // — every proj() call within one frame must share the same camera or the scene shears. let aimCam = 0; export function proj(x, y, z) { const d = TUNE.camZ + aimCam * TUNE.aimCamZ - z, s = TUNE.camF / d; return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s }; } // ---------------------------------------------------------------- sprites ---- // assets/.png; game falls back to code-drawn figures until sprites load. const SPRV = 63; // asset cache-buster — bump with main.js?v=N (see the img.src note below) const SPR = {}; for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5', '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','x_crowd','x_crowd2','x_ads', 'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working 'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel 'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b) 'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless) 'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT) 'z_mach1','z_mach2','z_machw1','z_machw2', // the ball machine: idle, firing, dented, split open 'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 5, ...) 'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...) 'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2', 'z_d1','z_d2','z_d3','z_d4', // the steward: bigger body, hi-vis vest, 4-frame walk 'z_awb','z_bwb','z_cw1b','z_cw2b','z_dw','z_dwb', // ...and every wound skin's second frame: wounded bodies walk too 'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_g3','z_g4','z_gw', 'z_s1','z_s2','z_s3','z_s4','z_sdown', // the chair spectator: 4-frame shuffle + the staggered pose 'z_c3','z_c4', // ...and the turned CPU walks on four frames too 'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers) 'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons 'scoreboard']) { const img = new Image(); img.onload = () => SPR[n] = img; // ?v=SPRV, for the same reason index.html versions main.js. python http.server sends no cache // headers, so the browser caches sprite responses heuristically — INCLUDING 404s. A brand-new sheet // whose name was added to this list before the PNG existed stays permanently "missing" for that // browser, and drawZombie silently falls back to another design. That cost a debugging round. // BUMP THIS whenever assets/ changes, alongside main.js?v=N in index.html. img.src = 'assets/' + n + '.png?v=' + SPRV; } // ------------------------------------------------------------------ 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 = UI.cream; 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 = UI.ball; 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) { // mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run // frames until the mop PNGs have loaded. // Three sheets, because a boy WALKING to the next stain is not a boy mopping: x_boymop is the // push cycle (used only while he's stopped and working), x_boycarry is him walking upright with // the mop shouldered, x_boyrun is the plain tennis-mode ball fetch. const mop = b.mop && SPR.x_boymop1; const sheet = !mop ? 'x_boyrun' : b.mopT > 0 ? 'x_boymop' : SPR.x_boycarry1 ? 'x_boycarry' : 'x_boymop'; // Frames step off DISTANCE COVERED, not elapsed time: a time-driven cycle made him moonwalk // whenever his speed and the frame rate disagreed. Now the stride owns the ground he covers. const stride = TUNE.boyStride * (mop ? 1 : 2.1); // a sprint eats more ground per frame than a push const img = SPR[sheet + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return; const p = proj(b.x, 0, b.z); // scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per // sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright const h = BOY_H * p.s * (img.height / SPRITE_H), 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 - (mop ? 0 : Math.floor(b.runT * 20) % 2)); if (b.dir < 0) ctx.scale(-1, 1); ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } // Frames stepped by DISTANCE WALKED need a POSITIVE modulo. Bodies spawn BEHIND the far baseline at // negative z, and JS keeps the sign of the dividend: floor(-1.5 / 0.25) % 4 is -2, so the pick asked // for 'z_g-1' — a sprite that does not exist, and drawStatic silently draws nothing. That is the // blinking Oliver saw on the way in, on exactly the bodies that step off distance: brutes and bosses. const stepFrame = (v, n) => ((Math.floor(v) % n) + n) % n; // 0..n-1, whatever the sign of v const ZFRAMES = { a: 4, b: 4, f: 4, d: 4, g: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2 // The rogue ball machine, on its own generated sheet: a dented hopper cart on wheels with a barrel // aimed down-court, and a second frame with the muzzle flashing. Oliver called the first, procedural // version "naively painted" — it was four rects, which is fine for fire but not for furniture the // player has to recognise and prioritise. function drawMachine(zb) { const firing = zb.windT > 0; // It wears its damage like every other multi-hit body on court: dented at two thirds, split open // and sparking at one. The healthy sheet keeps the muzzle-flash frame; a damaged machine flashes // procedurally instead of needing a firing frame per stage. const f = zb.hp / (zb.hpMax || TUNE.machHp); const dmg = f <= 0.34 ? 'z_machw2' : f <= 0.67 ? 'z_machw1' : null; const name = dmg && SPR[dmg] ? dmg : firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1'; const img = SPR[name]; if (!img) return; // sheet not loaded yet: draw nothing, not a box const h = MACH_H * (img.height / SPRITE_H); drawStatic(name, zb.x, zb.z, h); if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster ctx.globalAlpha = 0.3 + k * 0.7; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm; const r = Math.max(1.5, p.s * 0.05 * (0.5 + k)); ctx.beginPath(); ctx.arc(Math.round(p.x + p.s * 0.1), Math.round(p.y), r, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } } const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres function drawZombie(zb) { if (zb.variant === 'm') return drawMachine(zb); const rk = zb.variant === 'r'; const v = rk ? 'c' : zb.variant; // the racket zombie reuses the turned-CPU sprite (already holds a racket) let N = ZFRAMES[v] || 2; if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback // same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up let frame = rk && zb.pose === 'swing' ? 2 : zb.variant === 'g' ? stepFrame(zb.z / (TUNE.bruteStride * 2 / N), N) + 1 // heaviest body: step off DISTANCE // WALKED, like its wound lurch below, // or the feet slide (same cycle length) : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1; const chairDown = zb.variant === 's' && zb.shieldT > 0; // chair dropped — hit it NOW. Its own sprite // now (z_sdown); it used to be walk frame 3, // which a 4-frame cycle would walk right through let h = zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd; the steward stands between him and it if (zb.variant === 'B') h = 1.8 * (zb.scale || 1.5); // ...and a chapter boss towers over the brute // wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still // shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in // zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded. let name = chairDown && SPR.z_sdown ? 'z_sdown' : 'z_' + v + frame; if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))]; if (SPR[stage]) { // Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). Stepped by // DISTANCE WALKED, not time — the brute is the slowest thing on court, so a time-driven cycle // was pure foot-slide. This replaced a procedural squash-and-sway that read as exactly what // it was: fake. The comedy is in the gait, and the gait has to be drawn. const b = stage + 'b'; name = SPR[b] && stepFrame(zb.z / TUNE.bruteStride, 2) ? b : stage; } else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded } else if ('abd'.includes(zb.variant) && zb.hp < (zb.hpMax || 0) && SPR['z_' + zb.variant + 'w']) { // A wounded body used to freeze on ONE frame and slide across the court — the wound skins were // single stills. Every skin has its …b twin now (the stride swapped), stepped off the same clock // as the healthy walk, so losing an arm changes how he walks and not whether he walks. const w = 'z_' + zb.variant + 'w'; // Same CADENCE as the healthy walk (which runs four frames at 5.2/s), or a wounded body reads // as frozen next to the crowd it is walking in — it was stepping at half the rate on two frames. name = SPR[w + 'b'] && stepFrame(zb.walkT * 5.2, 2) ? w + 'b' : w; // gashed shambler, still lurching } // The boss's damage is read as a FRACTION of full health — his hp scales with the run // (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run // boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors. else if (rk) { const f = zb.hp / (zb.hpMax || TUNE.racketHp); if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1'; const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 5.2, 2)) name = b; } // The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over // three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs // out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same // rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide. // The chapter boss picks its frames from its DESIGN (`zb.art`, one row of config.BOSS_ART per // chapter, cycling), so a new boss is a sheet plus a data row and never a change here. Damage is a // FRACTION of hpMax spread over however many wound stages that design has, and the wounded state // stays visible MID-THROW — the one place Oliver found damage disappearing. // NEVER name a sprite so it differs from another only by case: macOS is case-insensitive, so // writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss turned up in the crowd. if (zb.variant === 'B') { const A = zb.art || BOSS_ART[0]; const f = zb.hp / (zb.hpMax || 1), nStage = A.wounds.length; const idx = f >= 1 ? 0 : Math.min(nStage, Math.max(1, Math.ceil((1 - f) * nStage))); const wanted = zb.windT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)] : idx ? A.wounds[idx - 1] : A.walk[stepFrame(zb.z / TUNE.bruteStride, 2)]; // a design whose sheet has not been generated (or has not loaded) degrades to design 1 rather // than vanishing, since drawStatic silently draws nothing for a missing sprite const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(zb.z / TUNE.bruteStride, 2)]; if (SPR[stage]) name = stage; } // Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched // frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body // grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule // already existed for the wound stages; it belongs to every frame, and it is the same rule // _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG). if (SPR[name]) h *= SPR[name].height / SPRITE_H; // feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm. const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05) : Math.floor(zb.walkT * 5.2) % 2; drawStatic(name, zb.x, zb.z, h, bob); if (zb.burnT > 0) drawFlames(zb, h); if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad; const r = Math.max(2, p.s * 0.1 * (0.5 + k)); ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } // No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for // almost the whole run (a boss respawns every game you win), and nobody knew what it meant — // while the wound SKINS above already encode exactly the same information in the language every // other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead. } // Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose // offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it // reads at every burn stage, from fresh yellow flare to guttering smoke, for free. function drawFlames(zb, hMeters) { const p = proj(zb.x, 0, zb.z), s = p.s; const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52'; for (let i = 0; i < 5; i++) { const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2; const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 2) * s * 0.05; const fy = p.y - (0.2 + i * 0.15 + 0.25 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.08; ctx.fillStyle = i === 0 && k > 0.5 ? '#ffe8a0' : col; // one white-hot core tongue while it rages ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2))))); } } 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); pl.cv = null; } // rebuild only on growth, never on the mop-shrink const base = proj(pl.x, 0, pl.z), s = base.s, sz = pl.size; const key = Math.round(s * 4); // proj scale is constant per pile until growth or an orientation change if (!pl.cv || pl.cvKey !== key) { // pre-render: 1 drawImage per frame instead of ~26 ellipses (battery) const pad = Math.ceil(sz * 0.28 * s + 4); const off = document.createElement('canvas'); off.width = off.height = pad * 2; const oc = off.getContext('2d'); for (const b of pl.blobs) { const sx = pad + b.nx * sz * 0.28 * s; const sy = pad + 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); oc.fillStyle = b.color; oc.beginPath(); oc.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); oc.fill(); } pl.cv = off; pl.cvKey = key; pl.cvPad = pad; } ctx.drawImage(pl.cv, base.x - pl.cvPad, base.y - pl.cvPad); } 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 } // Four-frame run where the frames exist (the player), two where they don't (the opponent). Same // CYCLE rate either way — more frames subdivide the stride, they do not speed it up, exactly as // ZFRAMES does for the shamblers. const runN = SPR[pre + 'run4'] ? 4 : 2; if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * runN) % runN + 1); 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 // Auto-fire: the idle sprite carries a racket at his hip AND drawMgRacket puts one in his hands — // two rackets. The run frames are the racket-less art, so he jogs on the spot instead. if (ch.side === 'P' && ch.pose === 'idle' && G.mode === 'splatter' && (G.horde || G.mgT > 0)) name = pre + 'run' + (Math.floor(G.idleT * 3 * runN) % runN + 1); 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); // No blood painted on the player. Oliver: "the player blood looks odd, appears as a painted layer // on top" — and he is right, and it also broke the settled rule that blood is never painted into // the man. His health reads as bleedPlayer()'s live drip plus the screen-edge wash on impact. } // The death. It used to be a red tint and a shake for half a second, which reads as "the sprite is // having a moment", not as being eaten. Now: he is dragged to his knees fighting it (p_die1/p_die2 // alternating), then pulled flat and pressed DOWN into the ground as the ring of bodies closes over // him — he is drawn inside the crowd's own z order (see render()), so the ones between him and the // camera hide him exactly as they arrive. What is left when he is gone is the mound. // Five DRAWN frames, arched back -> knees -> hands -> flat -> a heap, each one genuinely shorter // than the last (100/84/60/47/20% of standing height, common-scaled off one sheet). The first pass // faked the collapse by scaling one sprite down, which is miniaturising a man, not killing him. // Nothing here is scaled: each frame is drawn at ITS OWN height, the same rule the walk cycles use. const DIE_AT = [0, 0.15, 0.35, 0.6, 0.8]; // fraction of the way to EATEN_BURST each frame takes over function drawDying() { const at = G.eatenAt || P, t = G.eatenT; if (t >= EATEN_BURST) return; // he is the pile now const k = t / EATEN_BURST; let i = 0; while (i + 1 < DIE_AT.length && k >= DIE_AT[i + 1]) i++; const img = SPR['p_die' + (i + 1)]; const p = proj(at.x, 0, at.z); const j = i < 2 ? 1.6 : i < 4 ? 0.7 : 0; // he stops fighting on the way down ctx.save(); ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6); if (img) { const h = PLAYER_H * p.s * (img.height / SPRITE_H), w = h * img.width / img.height; drawShadow(p.x, p.y, w * 0.42, h * 0.06, 0.3); ctx.drawImage(img, p.x - w / 2, p.y - h, w, h); } else drawFallbackChar(P, p, PLAYER_H * p.s); // sprites not loaded: he still dies ctx.restore(); } 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 const rate = G.horde ? G.horde.mgRate || TUNE.mgRate : TUNE.mgRate; if (cool > 1 / rate - 0.03) { // 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(); } } // the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to // pick which one your next serve throws; empty tins dim out. Geometry is shared with input // via rackHit so a tap lands on the tile you see. const RACK_BOT = 0; // px below the rack: none — it sits ON the bottom edge. SAFE.bottom still lifts // it clear of the home indicator, which is the only gap that has to be there. const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge) export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom; // the draw meter's fixed bottom slot function rackGeom() { const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n; return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT - SAFE.bottom, tw, h, n, rw }; } export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1 if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel const g = rackGeom(), cx = nx * W, cy = ny * H; // The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to // the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a // phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly. if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1; return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0)); } function drawRack() { const g = rackGeom(), pad = 3; const stock = [Infinity, G.bombs, G.triples, G.flames, G.guns]; const labels = ['BALL', 'BOMB', 'TRIPLE', 'FIRE', 'GUN']; const WICONS = ['w_ball', 'w_bomb', 'w_triple', 'w_fire', 'w_gun']; // generated pixel-art icons ctx.save(); ctx.lineWidth = 1; for (let i = 0; i < g.n; i++) { const x = g.x0 + i * g.tw, sel = G.weapon === i, dim = stock[i] <= 0 && i > 0; ctx.fillStyle = sel ? '#123a24' : '#0a2418'; ctx.fillRect(x + 1, g.y0 + 1, g.tw - 2, g.h - 2); // sunk slot ctx.strokeStyle = sel ? UI.pick : '#05160e'; ctx.strokeRect(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3); // gold frame when live // icon: left, vertically centred, padded off the edge const box = g.h - pad * 2, icx = x + pad + box / 2, icy = g.y0 + g.h / 2; ctx.globalAlpha = dim ? 0.35 : 1; const spr = SPR[WICONS[i]]; if (spr) { const k = Math.min(box / spr.width, box / spr.height), iw = spr.width * k, ih = spr.height * k; ctx.drawImage(spr, icx - iw / 2, icy - ih / 2, iw, ih); } else { ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(icx, icy, 3, 0, 7); ctx.fill(); } // fallback until the PNG loads // count over label, both centred on the text column right of the icon — aligned as one unit const tx = x + pad + box + (g.tw - pad * 2 - box) / 2; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.globalAlpha = dim ? 0.5 : 1; ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc'; ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a'; ctx.fillText(labels[i], tx, g.y0 + g.h * 0.72); // weapon label, under its count ctx.globalAlpha = 1; if (i === 4 && G.mgT > 0) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown } ctx.restore(); } // the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel // and the SET announcement so both read as the same interruption. Assumes textAlign is already set. function drawCard(title, sub1, sub2, color) { ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64); ctx.textAlign = 'center'; ctx.fillStyle = color; ctx.font = F(16); ctx.fillText(title, W / 2, H / 2 - 26); ctx.fillStyle = UI.cream; ctx.font = F(8); if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2); if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12); } // Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides // BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats. // Pulling is the whole game in splatter mode, and a line of text was carrying that alone. function drawPullHint() { const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc; const draw = Math.min(1, t / 1.15); // the pull itself, then a beat held at full const gone = t > 1.45; // released — the thumb lifts off const anchor = proj(P.x, 0, P.z + 0.6); const ax = Math.round(anchor.x), ay = Math.round(Math.min(anchor.y + 6, METER_Y() - 26)); const len = 30 * draw; ctx.save(); ctx.globalAlpha = gone ? 0 : 0.85; // the dotted track the thumb travels, straight down-screen = "pull back" ctx.fillStyle = UI.dim; for (let d = 6; d < 34; d += 6) ctx.fillRect(ax - 1, ay + d, 2, 2); // chevrons growing UP-court as the pull grows — the inverse relationship is the whole lesson ctx.fillStyle = UI.warm; for (let i = 0; i < 3; i++) { const u = (i + 1) / 4; if (draw < u * 0.9) continue; const cy = ay - 8 - i * 7, s = 4; ctx.beginPath(); ctx.moveTo(ax, cy - s); ctx.lineTo(ax + s, cy + s * 0.7); ctx.lineTo(ax - s, cy + s * 0.7); ctx.fill(); } // the ghost thumb ctx.globalAlpha = gone ? 0 : 0.9; ctx.fillStyle = UI.cream; ctx.beginPath(); ctx.arc(ax, ay + len, 5, 0, 7); ctx.fill(); ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.beginPath(); ctx.arc(ax, ay + len, 2, 0, 7); ctx.fill(); ctx.restore(); } // The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update // early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away. // easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet // menu ever arrived by decelerating politely into place. const BACK_C = 1.70158; const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2; function drawModal(m) { // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and // title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each // on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour // scaled image re-snaps its pixel columns every frame, which is the same boiling that once read // as HUD flicker. Legacy/test modals with no `in` field draw fully landed. const t = m.in === undefined ? 1 : m.in; const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time const lane = k => { // 0 = off screen, 1 = home (may overshoot) const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN)); return p >= 1 ? 1 : easeBack(p); }; const dx = (k, from) => Math.round(from * (1 - lane(k)) * W * 0.85); // from: -1 left, 1 right ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const body = m.body || [], img = m.icon && SPR[m.icon]; // cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a // portrait-sized icon pushed the TAP prompt down onto the weapon rack const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0; const blockH = (iconH ? iconH + 12 : 0) + 22 + body.length * 13; let y = Math.round(H / 2 - blockH / 2); const headX = dx(0, 1); // icon and title travel together, as one masthead if (img) { const iw = Math.min(W - 24, iconH * img.width / img.height); ctx.drawImage(img, Math.round(W / 2 - iw / 2) + headX, y, Math.round(iw), iconH); y += iconH + 12; } ctx.font = F(14); ctx.fillStyle = m.color || UI.warm; if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it ctx.save(); ctx.globalAlpha = a; ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8); ctx.restore(); } ctx.fillText(m.title, W / 2 + headX, y + 8); y += 22; ctx.font = F(8, 0); ctx.fillStyle = UI.cream; body.forEach((l, i) => { ctx.fillText(l, W / 2 + dx(i + 1, -1), y + 6); y += 13; }); // the prompt only exists once everything has landed — offering the tap mid-flight would be a lie, // dismissModal swallows it until then if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy ctx.font = F(8); ctx.fillStyle = UI.warm; ctx.fillText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22)); } ctx.textBaseline = 'top'; } // ------------------------------------------------------------------ frame ---- const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density // ease the aim camera FIRST, before anything projects — one camera per frame (see proj) aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15; 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(), pt = paletteT(); // CLEAR THE WHOLE FRAME to the apron colour first. There was no clear at all: the stands cover the // top and the out-area quad covers the middle, but the quad only reaches z=30, and below where that // projects the canvas was simply TRANSPARENT — you saw the page through it as a duller band across // the bottom of the screen. Oversized by 8px so a camera shake can never expose an edge. ctx.fillStyle = pal.out; ctx.fillRect(-8, -8, W + 16, H + 16); pageBackground(pal.out); // ...and the page behind it matches, for the rounded display corners // stands: the generated crowd, drawn in columns so it can BOUNCE when it cheers. // sit the ad strip flush on the court's back edge (the out-area quad below reaches z=-6); // pinning to that projected line keeps the boards clear of the lawn in BOTH orientations // (a fixed baseline offset over-/under-shoots because camF differs portrait vs landscape). const standsBottom = proj(0, 0, -6).y; const crowdH = standsBottom - AD_H + 2; const crowdImg = SPR['x_crowd']; if (crowdImg) { // Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same // stand with every arm up. Columns swap between them on their own phase, so the clap ripples // across the stands. Crucially there is NO vertical offset any more — the old bounce shifted // each column by a FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a // fractional y makes its pixel rows re-snap every frame. That boiling, right beside the // scoreboard, is what read as the HUD flickering. Integer-aligned draws don't shimmer. const clapImg = SPR['x_crowd2']; const cols = 16, sw = crowdImg.width / cols, dw = W / cols; for (let c = 0; c < cols; c++) { const clapping = crowd.cheer > 0 && clapImg && Math.floor(G.idleT * 9 + c * 0.7) % 2; const img = clapping ? clapImg : crowdImg; ctx.drawImage(img, c * sw, 0, sw, img.height, c * dw, 0, dw + 0.5, crowdH + 2); } } else { // fallback until the image loads: the old tier stripes + dots 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) { // dot fallback (pre-sprite): whole-pixel hops only, same no-shimmer rule as above const jx = 0; const jy = crowd.cheer > 0 && Math.floor(G.idleT * 9 + d.x * 0.2) % 2 ? -2 : 0; ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2); } } // courtside advertising: tiled sponsor panels at native aspect (never half-cut text) const ads = SPR['x_ads']; if (ads) { const tw = ads.width * (AD_H / ads.height); for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - AD_H, tw, AD_H); } else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - AD_H, W, AD_H); } // the rot dims the stadium: one tint over crowd + boards, fading home during duels // the rot dims the stadium — but 0.5 on top of an already-dark crowd sheet buried the stands in // splatter mode, where you spend most of the game. 0.26 still reads as "the lights went wrong". if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.26 * pt})`; ctx.fillRect(0, 0, W, standsBottom); } // 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. NEVER let z approach // TUNE.camZ (48): proj() divides by (camZ - z), so at z >= camZ the scale blows up and then goes // NEGATIVE, and the quad's far edge folds back above the screen as an inverted triangle. The // full-frame clear above is what covers the bottom of the screen; this quad does not need to. 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 ? UI.hot : UI.warm); // slingshot aim. Three readings, all of them power: a chevron trail running down the lane, an // arrowhead on the target, and a notched meter over your head. The notch is the point of the // whole thing — it puts the kill threshold on screen instead of leaving it to be discovered. if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) { const ft = flickTarget(P, G.aim); const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz; const kills = ft.q >= TUNE.zSplatQ; // cool blue-white = this pull only staggers. It stays READABLE on the dark lawn: you still // need to see where you're pointing, you just need to know it won't kill. const col = !kills ? '#9db4d0' : ft.zt ? UI.hot : UI.warm; if (tx !== undefined) { const a = proj(P.x, 1.4, P.z), b = proj(tx, 0.35, tz); const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len, px = -uy, py = ux; // unit along the lane, and its normal // (proj is a true projective map, so a straight world line stays straight on screen — one // screen direction is correct for every chevron along it, only the SIZE has to change) const chevron = (c, size, alpha) => { ctx.globalAlpha = alpha; ctx.beginPath(); ctx.moveTo(c.x + ux * size, c.y + uy * size); ctx.lineTo(c.x - ux * size * 0.4 + px * size, c.y - uy * size * 0.4 + py * size); ctx.lineTo(c.x - ux * size * 0.4 - px * size, c.y - uy * size * 0.4 - py * size); ctx.fill(); }; // Chevrons stepped along the lane in WORLD space, each sized by its own projection scale, // so they shrink into the distance like everything else on the court. They also fade OUT // toward the target: solid at your racket, thinning up the lane, so the trail reads as // something leaving your hand rather than as a label on the zombie. More of them, and // fatter, the harder you draw. const n = Math.round(3 + ft.f * 5); ctx.fillStyle = col; for (let i = 1; i <= n; i++) { const u = i / (n + 1); const c = proj(P.x + (tx - P.x) * u, 1.4 + (0.35 - 1.4) * u, P.z + (tz - P.z) * u); chevron(c, Math.max(1.5, c.s * (0.07 + ft.f * 0.09)), 1 - 0.7 * u); } chevron(b, Math.max(2.5, b.s * (0.10 + ft.f * 0.11)), 1); // the arrowhead lands ON the target ctx.globalAlpha = 1; drawRing(tx, tz, col); } // The draw meter: it fills as you pull and the notch marks the kill line. A fixed BAR ACROSS // THE BOTTOM, ~90% of the width — it used to be a 26x4 px stub floating on the apron by your // feet, which is nothing at arm's length with a thumb over it. Fixed position also means it // never wanders behind the chevrons or off the top of the court. const mw = Math.round(W * 0.9), mh = 10, mx = Math.round((W - mw) / 2); const my = METER_Y(); ctx.fillStyle = 'rgba(0,0,0,0.62)'; ctx.fillRect(mx - 2, my - 2, mw + 4, mh + 4); ctx.fillStyle = 'rgba(255,255,255,0.16)'; ctx.fillRect(mx, my, mw, mh); // empty channel ctx.fillStyle = col; ctx.fillRect(mx, my, Math.round(mw * ft.f), mh); const notch = Math.round(mw * (TUNE.zSplatQ - TUNE.pullQMin) / (1 - TUNE.pullQMin)); ctx.fillStyle = '#ffffff'; ctx.fillRect(mx + notch, my - 3, 2, mh + 6); // the kill line if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the whole bar flashes ctx.fillStyle = '#ffffff'; ctx.fillRect(mx, my, mw, mh); } } // 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', UMP_X, 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); const nearSide = zombies.filter(zb => zb.z > COURT.netZ).map(zb => ({ z: zb.z, f: () => drawZombie(zb) })); if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying }); nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); // 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); // INCOMING hostile balls, marked so they can never be mistaken for one of yours: a red ring and a // hard ground shadow, so you can read WHERE it will land while it is still in the air. for (const s of shots) { const sh = proj(s.x, 0, s.z); drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.05), Math.max(1, sh.s * 0.025), 0.4); const sp = proj(s.x, s.y, s.z), r = Math.max(2, sp.s * 0.045); ctx.fillStyle = UI.bad; ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r + 1.5, 0, 7); ctx.fill(); ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill(); } // splatter particles (fire embers and machine smoke ride the same array) for (const pt of parts) { const pp = proj(pt.x, pt.y, pt.z); if (pt.smoke) { // a broken machine's smoke: dark, soft-edged, growing and thinning as it climbs const age = Math.min(1, pt.t / 1.8), r = Math.max(2, pp.s * 0.09 * (2 - age)); ctx.fillStyle = `rgba(46,44,48,${0.62 * age})`; ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill(); continue; } 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 = UI.warm; ctx.fillRect((pp.x | 0) - 2, (pp.y | 0) - 2, 4, 4); // fat round — it hits like one } // the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise if (G.bossBurst) { const b = G.bossBurst, k = 1 - b.t / 0.75, p = proj(b.x, 0.5, b.z); ctx.strokeStyle = `rgba(190,26,26,${(1 - k) * 0.8})`; ctx.lineWidth = Math.max(1.5, p.s * 0.05 * (1 - k)); ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.9 * k, p.s * 0.32 * k, 0, 0, 7); ctx.stroke(); ctx.strokeStyle = `rgba(255,180,170,${(1 - k) * 0.35})`; ctx.lineWidth = 1; 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.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 // bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone // and hid the very gore eruption it was announcing. if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); } // took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill — // this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash). if (G.hurtFlash > 0) { const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26; ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`; ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b); ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, H); } // HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light // top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game // sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed // status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro. const splat = G.mode !== 'tennis'; const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', slot: '#0b2b1c', slotSvr: '#2b1a46', spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick, engHi: '#2c7a4f' }; const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); }; const ptTxt = i => 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)]; ctx.save(); ctx.translate(6, 6); const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42) if (SPR.scoreboard) ctx.drawImage(SPR.scoreboard, 0, 0, bW, bH); // generated ornate board backdrop else { // code-drawn fallback until the PNG loads R(0, 0, bW, bH, bd.face); R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi); R(0, bH - 1, bW, 1, bd.lo); R(bW - 1, 0, 1, bH, bd.lo); R(1, 1, 5, bH - 2, bd.spine); R(1, 1, 5, 1, bd.spineHi); R(6, 1, 1, bH - 2, bd.lo); } // live text laid straight onto the art's own regions (spine ~x14, two rows, right slot ~x105-147), // Silkscreen pixel font — no code-drawn slots or bevels painted on top of the art. ctx.textBaseline = 'middle'; const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; for (let i = 0; i < 2; i++) { const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands if (svr) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(11, cy, 1.8, 0, 7); ctx.fill(); } // server pip on the purple spine ctx.textAlign = 'left'; ctx.font = F(8, svr); ctx.fillStyle = svr ? bd.cream : bd.dim; ctx.fillText(names[i], 18, cy); ctx.textAlign = 'center'; if (splat) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } // sets ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot } if (splat) { // which game you're in, permanently — the 1.1s GAME N flash was the only place it showed ctx.textAlign = 'right'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('GAME ' + (G.runGames + 1), bW, bH + 7); } if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, just under the board — no box ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 2, bH + 7); for (let k = 0; k < TUNE.killsPerPoint; k++) { ctx.beginPath(); ctx.arc(32 + k * 8, bH + 7, 2.4, 0, 7); if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); } else { ctx.strokeStyle = '#5a6a5a'; ctx.lineWidth = 1; ctx.stroke(); } } } ctx.restore(); ctx.textBaseline = 'top'; ctx.textAlign = 'center'; if (G.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel) // phase C: the summary fades UP over the death animation instead of cutting on top of it if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) { const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6); ctx.save(); ctx.globalAlpha = fade; ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = UI.bad; ctx.font = F(14); ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40); ctx.fillStyle = UI.cream; ctx.font = F(9); ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14); if (G.newBest) { // a fresh record crows; otherwise show the mark to beat const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4)); ctx.fillStyle = hexA(UI.warm, pulse); ctx.font = F(10); ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4); } else if (G.best && G.best.games > 0) { ctx.fillStyle = UI.dim; ctx.font = F(8); ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4); } // gated well past the animation so a panic tap can't skip it (main.js gates the restart the same) if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); } ctx.restore(); } // Timed freezes swallow every input until they run out, which reads as the game having hung // ("sometimes the controls just feel frozen"). Show the clock instead of hiding it. A modal is // excluded on purpose — that one waits for YOU, and says so. // 'transition' is excluded: startTransition parks overT at 9999 to hold the point flow while its // own scripted timeline runs, and counting that down read as "resuming in 9999". const waitT = G.modal || G.mode === 'eaten' || G.mode === 'transition' ? 0 : Math.max(G.changeover > 0 ? G.changeover : 0, G.phase === 'over' ? G.overT : 0, G.interlude ? G.interlude.introT : 0); if (waitT > 0.5) { // Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the // busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible // whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd. const txt = 'RESUMING IN ' + Math.ceil(waitT); const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16); ctx.font = F(10); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillText(txt, W / 2 + 1, y + 1); ctx.fillStyle = hexA(UI.warm, 0.92); ctx.fillText(txt, W / 2, y); } if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot ctx.fillStyle = UI.warm; ctx.font = F(8); const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH'; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; const txt = G.mode === 'splatter' ? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN') : G.aim ? 'PULL BACK: FURTHER = HARDER' : G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}` : touch ? 'PULL BACK TO AIM · RELEASE TO FIRE' : 'DRAG BACK TO AIM · SPACE SERVES') : 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, METER_Y()); // same slot the draw meter uses // and after a beat of inaction, show the gesture instead of only naming it if (G.mode === 'splatter' && !G.interlude && G.weapon !== 4 && G.idleHintT > TUNE.pullHintAfter) drawPullHint(); } if (G.flash) { ctx.font = F(12); ctx.fillStyle = '#ffffff'; ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1); } // the SET milestone card (the duel's own announcement is a real modal now, not a timed card) if (G.card && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it) drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color); if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live ctx.font = F(8); 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 = F(7, 0); ctx.textAlign = 'left'; ctx.fillStyle = UI.dim; 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); }); } if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it ctx.textAlign = 'left'; }