Weapons: a courtside rack you pick from + pixel-art HUD

Tins now accumulate on a courtside rack (cap 3) instead of replacing each other; a PLAIN slot banks specials for later, and GUN is a charge you pop for an auto-fire burst. Tap the rack or press 1-5 to choose what your serve throws. A fresh delivery auto-equips but never overrides a deliberate PLAIN, and the 1:1 sudden-death duel forces plain balls.

HUD gets real art: generated pixel-art weapon icons, an ornate Wimbledon scoreboard PNG backing the live text (no more code-drawn boxes on top), and a bundled Silkscreen pixel font replacing the system serif. Rack tiles get padding, count-over-label alignment, and an infinity marker on the ball.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-14 16:11:12 +02:00
1 parent 148da4713c
commit 37e5bfd10f
17 files changed
+155 -68

No files matched your search

+81 -42
View File
@@ -67,7 +67,9 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'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',
'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_s1','z_s2','z_s3']) {
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_s1','z_s2','z_s3',
'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';
@@ -259,6 +261,52 @@ function drawMgRacket() { // horde: the player braces the racket forward and up-
}
}
// 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 = 10; // px of breathing room below the rack (floats it up off the screen edge)
function rackGeom() {
const h = TUNE.rackH, n = 5, tw = Math.min(52, (W - 8) / 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;
if (cy < g.y0 || cy > g.y0 + g.h || 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 ? '#e6ee3a' : '#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 = '#e8f03a'; 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.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.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
}
ctx.restore();
}
// ------------------------------------------------------------------ frame ----
export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
@@ -397,50 +445,40 @@ export function render() {
? (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 = splat ? 42 : 30, rowsB = splat ? 28 : bH - 2;
R(0, 0, bW, bH, bd.face); // panel face
R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi); // bevel highlight (top/left)
R(0, bH - 1, bW, 1, bd.lo); R(bW - 1, 0, 1, bH, bd.lo); // bevel shadow (bottom/right)
R(1, 1, 5, bH - 2, bd.spine); R(1, 1, 5, 1, bd.spineHi); R(6, 1, 1, bH - 2, bd.lo); // purple club spine
for (const cx of (splat ? [58, 74, 90] : [74, 90])) { R(cx, 3, 1, rowsB - 4, bd.lo); R(cx + 1, 3, 1, rowsB - 4, bd.engHi); } // engraved column dividers
R(7, 13, 84, 1, bd.lo); R(7, 14, 84, 1, bd.engHi); // engraved row divider (name→games only)
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.
const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`;
ctx.textBaseline = 'middle';
const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
const px = 92, pw = bW - px - 3; // recessed points slot
for (let i = 0; i < 2; i++) {
const cy = 8 + i * 11, svr = (score.server === 'P') === (i === 0), sy = cy - 5;
R(px, sy, pw, 11, svr ? bd.slotSvr : bd.slot); // slot well
R(px, sy, pw, 1, '#05160e'); R(px, sy, 1, 11, '#05160e'); // inset shadow (top/left)
R(px, sy + 10, pw, 1, bd.engHi); R(px + pw - 1, sy, 1, 11, svr ? bd.spineHi : bd.engHi); // inset light (bottom/right)
if (svr) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(11, cy, 2.4, 0, 7); ctx.fill(); ctx.strokeStyle = '#0a2417'; ctx.lineWidth = 0.6; ctx.stroke(); } // server holds the ball
ctx.textAlign = 'left'; ctx.font = 'bold 9px Georgia, "Times New Roman", serif';
ctx.fillStyle = svr ? bd.cream : bd.dim; ctx.fillText(names[i], 16, cy + 0.5);
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 = 'bold 8px Georgia, serif'; ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 66, cy + 0.5); } // sets
ctx.font = 'bold 9px Georgia, serif'; ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 82, cy + 0.5); // games
const pt = ptTxt(i); ctx.font = 'bold ' + (pt.length > 1 ? 9 : 10) + 'px Georgia, serif';
ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(pt, px + pw / 2, cy + 0.5); // live game
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) { // recessed status well: kills-to-next-point pips + weapon stock
const y0 = 29, sy = 35;
R(7, y0, bW - 9, bH - y0 - 2, '#0a2418'); R(7, y0, bW - 9, 1, '#05160e');
ctx.textAlign = 'left';
if (TUNE.killPips && TUNE.killsPerPoint > 1) { // empty pips = zombies still to go before the next point
ctx.font = 'bold 6px Georgia, serif'; ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 12, sy + 0.5);
for (let k = 0; k < TUNE.killsPerPoint; k++) {
ctx.beginPath(); ctx.arc(36 + k * 7, sy, 2.2, 0, 7);
if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); }
else { ctx.strokeStyle = '#3a5a4a'; ctx.lineWidth = 1; ctx.stroke(); }
}
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(); }
}
const wx = 82; // weapon stock
for (let b = 0; b < G.bombs; b++) { ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(wx + b * 8, sy, 2.6, 0, 7); ctx.fill(); ctx.strokeStyle = '#ff8010'; ctx.lineWidth = 0.75; ctx.stroke(); }
for (let b = 0; b < G.triples; b++) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(wx + b * 8, sy, 2.6, 0, 7); ctx.fill(); ctx.fillRect(wx + b * 8 - 4, sy - 1, 1.5, 1.5); ctx.fillRect(wx + b * 8 + 3, sy - 1, 1.5, 1.5); }
for (let b = 0; b < G.flames; b++) { ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(wx + b * 8, sy, 2.6, 0, 7); ctx.fill(); ctx.fillStyle = '#ffe8a0'; ctx.fillRect(wx + b * 8 - 1, sy - 1, 2, 2); }
if (G.mgT > 0) { ctx.fillStyle = '#06180f'; ctx.fillRect(wx, sy - 2.5, 40, 5); ctx.fillStyle = bd.gold; ctx.fillRect(wx, sy - 2.5, 40 * Math.min(1, G.mgT / TUNE.mgPickupLen), 5); }
}
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
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#c02020'; ctx.font = 'bold 16px monospace';
@@ -459,15 +497,16 @@ export function render() {
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
const touch = IS_TOUCH;
const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null;
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.aim ? (stock ? `RELEASE FIRES THE ${stock} · DRAG BACK TO CANCEL` : 'RELEASE TO SERVE · DRAG BACK TO CANCEL')
: stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT`
: touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM')
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: G.aim ? 'RELEASE TO SERVE · DRAG BACK TO CANCEL'
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
: touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
ctx.fillText(txt, W / 2, H - 16);
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';