Six reports from Oliver's first playtest of the boss build. Two mattered. A boss sprite turned up in the shambler crowd because z_B1.png and z_b1.png are the same file on a case-insensitive filesystem — writing the boss frames silently overwrote the 'b' shambler's first two frames, and `ls` only ever showed the original lowercase name. Restored from HEAD~1, boss frames renamed z_boss*, and the rule is now in the asset pipeline notes. The parry was unreachable, which killed the entire new mechanic: P.swingT is only set in keyPressed's `phase === 'rally'` branch, and splatter sits in pretoss almost all the time, so every tap aimed at an incoming shot tossed the serve ball instead of swinging. An incoming shot inside parryArm now owns the tap in every phase, with its own generous reach and window — this is a phone, one thumb, against something aimed at you. The three fail cases (too early, too late, no tap) are what keep it a timing skill. The swish no longer eats shots: it runs first in keyPressed and was swallowing the very ball the parry was about to return. One flame ball killed a 16 hp boss, and that was a bug rather than tuning. A flame ball is not consumed on contact (full pierce, so it can cut a lane) but a boss is not removed when hit, so the same ball re-wounded it every frame. Boss bodies now take a contact cooldown and flame spends itself on them. Chapter 1 costs 4 flame balls or 16 clean smashes, and bossHp goes 10 to 16. The boss had no wounded state while throwing — correct, the wound skins were standing poses only. New sheet gives all four damage states their own overarm pitch. The ball machine's four painted rects are replaced with a real sheet, idle and firing, keeping only the muzzle glow procedural because the telegraph has to read at 20px tall. And the player's blood no longer sits on him as a painted layer: live drip and the screen-edge wash, nothing else. Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean, all four chapters firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
864 lines
52 KiB
JavaScript
864 lines
52 KiB
JavaScript
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||
|
||
import { cv, ctx } from './dom.js';
|
||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_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, 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
|
||
export const RES = 3; // physical pixels per logical pixel — 3x so phone screens downscale (crisp) instead of upscaling (soft)
|
||
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;
|
||
// 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
|
||
TUNE.camF = portrait ? 580 : 406;
|
||
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);
|
||
|
||
// 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/<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','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', // 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', // the ball machine: idle, and firing with a muzzle flash
|
||
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
|
||
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
|
||
'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;
|
||
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 = 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();
|
||
}
|
||
const ZFRAMES = { a: 4, b: 4, f: 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;
|
||
const name = 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.machWind, p = proj(zb.x, h * 0.62, zb.z);
|
||
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;
|
||
}
|
||
}
|
||
|
||
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 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
|
||
if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
|
||
let h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
|
||
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 = '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] && Math.floor(zb.z / TUNE.bruteStride) % 2 ? b : stage;
|
||
h *= SPR[stage].height / SPRITE_H; // height ALWAYS off the A frame, or the size pops each step
|
||
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
|
||
}
|
||
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler
|
||
// 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';
|
||
}
|
||
// 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.
|
||
if (zb.variant === 'B') {
|
||
const f = zb.hp / (zb.hpMax || 1);
|
||
// 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 showed up in the
|
||
// shambler crowd. All lowercase from here.
|
||
const wounded = f <= 0.25 ? 3 : f <= 0.5 ? 2 : f <= 0.75 ? 1 : 0;
|
||
// mid-throw it rears up — and the wounded state has to stay visible while it does, which is what
|
||
// the z_bosst<n> set is for. Oliver flagged the throwing animation as the one place damage vanished.
|
||
const stage = zb.windT > 0 && SPR['z_bosst' + wounded] ? 'z_bosst' + wounded
|
||
: wounded ? 'z_bossw' + wounded
|
||
: (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_boss2' : 'z_boss1');
|
||
if (SPR[stage]) { name = stage; h *= SPR[stage].height / SPRITE_H; } // height ALWAYS off the frame, or the size pops
|
||
}
|
||
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||
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.machWind, p = proj(zb.x, h * 0.62, zb.z);
|
||
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
|
||
}
|
||
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);
|
||
// 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.
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
// 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 = 8; // px of breathing room below the rack (floats it up off the screen edge)
|
||
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; // 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, 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 ----
|
||
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();
|
||
// 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 adH = 14, crowdH = standsBottom - adH + 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 * (adH / ads.height);
|
||
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - adH, tw, adH);
|
||
} else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - adH, W, adH); }
|
||
// 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
|
||
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);
|
||
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);
|
||
// 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 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 = UI.warm; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2);
|
||
}
|
||
// death, in three stages. A: you are still there, thrashing and going red as they crowd in.
|
||
// B (from EATEN_BURST): the sprite is gone and you ARE the gore — playerGore() drops the mound.
|
||
// C: the summary fades in over it, so the biggest pile on the court stays visible underneath.
|
||
if (G.mode !== 'eaten') drawChar(P);
|
||
else if (G.eatenT < EATEN_BURST) {
|
||
const k = G.eatenT / EATEN_BURST, j = 1 + k * 3; // the shake grows as they pull you down
|
||
ctx.save();
|
||
ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6);
|
||
ctx.filter = `sepia(1) saturate(${2 + 7 * k}) hue-rotate(-25deg) brightness(${1 - 0.25 * k})`;
|
||
drawChar(P);
|
||
ctx.filter = 'none'; // ponytail: unsupported filter just means no tint — the shake still reads
|
||
ctx.restore();
|
||
}
|
||
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();
|
||
}
|
||
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim) { // 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)
|
||
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';
|
||
}
|