Polish round: modal system, softened palette, pixel font, and the 14 plan items
The three foundations first, since everything else leans on them:
- G.modal is the only thing that stops the world. update() early-returns on it
so no zombie steps, no ball flight, no spawn timers run while a popup is on
screen — you can read it without being eaten. Dismissal swallows the gesture;
the pointerdown dismisser is registered only on pointer:fine, because on touch
pointerdown beats touchstart and a shared handler cleared the modal and then
served with the same thumb.
- UI in config.js is the accent palette. The arcade primaries were shouting over
the artwork; the gore reds deliberately stay loud.
- Every canvas font string now goes through F(). Bare `monospace` resolved to
Courier — a slab serif — which was the "generic serif" bug, not taste.
Then: weapons are only ever WON. deliverTin() has exactly one caller now
(resolveInterlude('P')); the old game schedule calls the DUEL instead, fired off
proceedAfterClear() where the court is guaranteed empty. Four popups (splatter
tutorial with a generated illustration, first duel, weapon received, horde),
wCap 9 with the tin amounts tunable, a 5-hp brute that comes apart over four
wound skins, fire that ignites the neighbours and chains outward, a three-stage
death where you become the biggest gore mound on the court, and a 7s changeover
with a ball boy who finally has a mop.
New art: assets/logo.png, x_howto, z_gw2-4, x_boymop1-4, plus assets/_key.py for
standalone art (chroma-key + crop, no frame splitting, no forced sprite height).
Benchmarks re-run and holding: CPU returns 23/24 serves, 30 human-tapped points
= 50 hits / 0 whiffs, 26 simulated games with no exceptions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5faaa99467
commit
a09a5d7559
23 files changed
+392
-103
No files matched your search
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||||
import { P, O, ball, score, crowd, G } from './state.js';
|
||||
import { predictLanding, flickTarget } from './gameplay.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras } from './zombies.js';
|
||||
@@ -32,6 +32,11 @@ function palette() {
|
||||
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)
|
||||
@@ -71,6 +76,8 @@ 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_ads',
|
||||
'x_boymop1','x_boymop2','x_boymop3','x_boymop4','x_howto', // the mopping boy, and the slingshot tutorial panel
|
||||
'z_gw2','z_gw3','z_gw4', // the brute comes apart in stages (4/5 hp = z_gw, down to 1/5 = z_gw4)
|
||||
'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)
|
||||
@@ -94,7 +101,7 @@ export function buildCrowd() {
|
||||
// -------------------------------------------------------------- primitives ----
|
||||
function line(x1, z1, x2, z2, w = 1) {
|
||||
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
|
||||
ctx.strokeStyle = '#e8e8dc'; ctx.lineWidth = w;
|
||||
ctx.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)
|
||||
@@ -121,7 +128,7 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
|
||||
ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill();
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||||
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)); }
|
||||
}
|
||||
|
||||
@@ -134,11 +141,16 @@ function drawStatic(name, x, z, hMeters, bob = 0) {
|
||||
ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
|
||||
}
|
||||
function drawBoy(b) {
|
||||
const img = SPR['x_boyrun' + (Math.floor(b.runT * 14) % 4 + 1)]; if (!img) return;
|
||||
// mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run
|
||||
// frames until the mop PNGs have loaded.
|
||||
const mop = b.mop && SPR.x_boymop1;
|
||||
const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor(b.runT * (mop ? 8 : 14)) % 4 + 1)]; if (!img) return;
|
||||
const p = proj(b.x, 0, b.z);
|
||||
const h = BOY_H * p.s, w = h * img.width / img.height;
|
||||
// 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 - (Math.floor(b.runT * 20) % 2));
|
||||
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();
|
||||
@@ -157,17 +169,37 @@ function drawZombie(zb) {
|
||||
// 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 && SPR.z_gw) name = 'z_gw';
|
||||
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]) name = stage; else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
|
||||
if (SPR[name]) h *= SPR[name].height / SPRITE_H; // the headless stage really is shorter — don't stretch it back
|
||||
}
|
||||
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler
|
||||
else if (rk && zb.hp <= 1 && 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 (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1';
|
||||
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||
if (zb.burnT > 0) drawFlames(zb, h);
|
||||
if (rk) { // HP bar floating above the mini-boss
|
||||
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
|
||||
ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3);
|
||||
ctx.fillStyle = '#e03030'; ctx.fillRect(x, p.y, w * Math.max(0, zb.hp) / TUNE.racketHp, 3);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -298,7 +330,7 @@ function drawRack() {
|
||||
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 ? '#e6ee3a' : '#05160e'; ctx.strokeRect(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3); // gold frame when live
|
||||
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;
|
||||
@@ -306,17 +338,17 @@ function drawRack() {
|
||||
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 = '#e8f03a'; ctx.beginPath(); ctx.arc(icx, icy, 3, 0, 7); ctx.fill(); } // fallback until the PNG loads
|
||||
} 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 = '700 8px Silkscreen, monospace'; ctx.fillStyle = sel ? '#e6ee3a' : '#e8f0dc';
|
||||
ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc';
|
||||
ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + pad + 5); // ball = ∞, else ×count
|
||||
ctx.font = '400 6px Silkscreen, monospace'; ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a';
|
||||
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a';
|
||||
ctx.fillText(labels[i], tx, g.y0 + g.h - pad - 3); // weapon label, under its count
|
||||
ctx.globalAlpha = 1;
|
||||
if (i === 4 && G.mgT > 0) { ctx.fillStyle = '#e6ee3a'; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown
|
||||
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();
|
||||
}
|
||||
@@ -326,12 +358,39 @@ function drawRack() {
|
||||
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 = 'bold 18px monospace'; ctx.fillText(title, W / 2, H / 2 - 26);
|
||||
ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace';
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
function drawModal(m) {
|
||||
ctx.fillStyle = 'rgba(8,6,10,0.82)'; 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);
|
||||
if (img) {
|
||||
const iw = Math.min(W - 24, iconH * img.width / img.height);
|
||||
ctx.drawImage(img, Math.round(W / 2 - iw / 2), y, Math.round(iw), iconH);
|
||||
y += iconH + 12;
|
||||
}
|
||||
ctx.font = F(14); ctx.fillStyle = m.color || UI.warm;
|
||||
ctx.fillText(m.title, W / 2, y + 8); y += 22;
|
||||
ctx.font = F(8, 0); ctx.fillStyle = UI.cream;
|
||||
for (const l of body) { ctx.fillText(l, W / 2, y + 6); y += 13; }
|
||||
if (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
|
||||
@@ -402,7 +461,7 @@ export function render() {
|
||||
const l = predictLanding();
|
||||
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
|
||||
}
|
||||
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048');
|
||||
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.
|
||||
@@ -412,7 +471,7 @@ export function render() {
|
||||
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 ? '#ff5050' : '#f8e048';
|
||||
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;
|
||||
@@ -501,9 +560,21 @@ export function render() {
|
||||
for (const pe of pellets) {
|
||||
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
|
||||
const pp = proj(pe.x, pe.y, pe.z);
|
||||
ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2);
|
||||
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.mode !== 'eaten') drawChar(P); // there is no P anymore
|
||||
if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player
|
||||
|
||||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady
|
||||
@@ -515,7 +586,7 @@ export function render() {
|
||||
// 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: '#e6ee3a', engHi: '#2c7a4f' };
|
||||
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')
|
||||
@@ -531,7 +602,6 @@ export function render() {
|
||||
}
|
||||
// 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.
|
||||
const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`;
|
||||
ctx.textBaseline = 'middle';
|
||||
const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@@ -555,24 +625,29 @@ export function render() {
|
||||
ctx.restore();
|
||||
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
|
||||
if (G.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel)
|
||||
if (G.mode === 'eaten') { // the run summary — permadeath makes this scoreboard the whole game
|
||||
// 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 = '#c02020'; ctx.font = 'bold 16px monospace';
|
||||
ctx.fillStyle = UI.bad; ctx.font = F(14);
|
||||
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40);
|
||||
ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace';
|
||||
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 = `rgba(248,224,72,${pulse})`; ctx.font = 'bold 11px monospace';
|
||||
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 = '#8fa0b8'; ctx.font = 'bold 8px monospace';
|
||||
ctx.fillStyle = UI.dim; ctx.font = F(8);
|
||||
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4);
|
||||
}
|
||||
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
|
||||
// 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)) {
|
||||
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
|
||||
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'
|
||||
@@ -585,28 +660,27 @@ export function render() {
|
||||
ctx.fillText(txt, W / 2, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack
|
||||
}
|
||||
if (G.flash) {
|
||||
ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
|
||||
ctx.font = F(12); ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
||||
}
|
||||
// the sudden-death duel card, then (below it in priority) the SET milestone card — same block
|
||||
if (G.interlude && G.interlude.introT > 0)
|
||||
drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050');
|
||||
else if (G.card && G.card.t > 0)
|
||||
// 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 = 'bold 8px monospace'; ctx.fillStyle = '#ff9060';
|
||||
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 = 'bold 7px monospace'; ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#8fa0b8'; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5);
|
||||
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';
|
||||
}
|
||||
Reference in new issue
Block a user