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

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+2
View File
@@ -35,6 +35,8 @@ export const TUNE = {
ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here) ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here)
ballChangeEvery: 4, // games between deliveries after the first ballChangeEvery: 4, // games between deliveries after the first
bombMax: 3, // bomb balls in a fresh tin bombMax: 3, // bomb balls in a fresh tin
wCap: 3, // per-type cap on the courtside rack — tins accumulate, but only so many
rackH: 22, // px height of the bottom weapon rack (feel knob: how much court it covers)
bombRadius: 2.5, // m blast radius of a bomb ball bombRadius: 2.5, // m blast radius of a bomb ball
hordeLen: 12, // s a horde wave lasts hordeLen: 12, // s a horde wave lasts
hordeRate: 2.5, // zombies spawned per second during a horde hordeRate: 2.5, // zombies spawned per second during a horde
+44 -16
View File
@@ -146,14 +146,15 @@ function hitServe(sv, quality, aim = null) {
ball.lastHit = sv.side; ball.bounces = 0; ball.lastHit = sv.side; ball.bounces = 0;
ball.pow = quality; ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts ball.kills = 0; // fresh serve: multi-kill streak restarts
// specials ride only DELIBERATE serves: a flick (or arrow-aimed keyboard serve) commits the // the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your
// tin, a plain tap keeps it in stock — so bombs wait for the enemy you mean them for // specials for later. Placement still follows the flick — only WHICH ball flies is the
const armed = G.mode === 'splatter' && !!aim; // rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve.
ball.bomb = armed && G.bombs > 0; const wsel = G.mode === 'splatter' ? G.weapon : 0;
ball.bomb = wsel === 1 && G.bombs > 0;
if (ball.bomb) G.bombs--; if (ball.bomb) G.bombs--;
ball.flame = armed && !ball.bomb && G.flames > 0; ball.flame = wsel === 3 && G.flames > 0;
if (ball.flame) G.flames--; if (ball.flame) G.flames--;
if (armed && G.triples > 0) { // one serve, three balls: clones fanned ±6° if (wsel === 2 && G.triples > 0) { // one serve, three balls: clones fanned ±6°
G.triples--; G.triples--;
for (const deg of [-6, 6]) { for (const deg of [-6, 6]) {
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r); const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
@@ -161,6 +162,7 @@ function hitServe(sv, quality, aim = null) {
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c }); vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
} }
} }
if (wsel > 0 && slotStock(wsel) <= 0) G.weapon = 0; // that was the last tin — hand back to plain balls
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 }; if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 };
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
sv.pose = 'serve'; sv.poseT = 0; sv.pose = 'serve'; sv.poseT = 0;
@@ -265,17 +267,28 @@ function startChangeover() {
} else G.flash = { text: 'CHANGEOVER', t: 1.4 }; } else G.flash = { text: 'CHANGEOVER', t: 1.4 };
} }
// the weapon ladder: each delivery of the run opens a nastier tin. One tin at a time — // ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is
// a fresh delivery replaces any unspent stock (simpler than stacking, and it keeps you spending). // infinite plain balls. selectWeapon is the tap/key target — an empty special can't be picked.
function deliverTin() { export const SLOTS = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'];
const w = G.deliveries++ % 4; export const slotStock = i => [Infinity, G.bombs, G.triples, G.flames, G.guns][i];
G.bombs = G.triples = G.flames = 0; G.mgT = 0; export function selectWeapon(i) {
if (w === 0) G.bombs = TUNE.bombMax; if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
else if (w === 1) G.triples = 3; if (i > 0 && slotStock(i) <= 0) return; // dud: no tins of that kind on the rack
else if (w === 2) G.flames = 3; G.weapon = i; pock(0.3, 1100);
else G.mgT = TUNE.mgPickupLen; // starts firing when play resumes }
// the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
// instead of spending it now. G.weapon picks which tin the next serve throws.
export function deliverTin() {
const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k);
if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax);
else if (w === 1) G.triples = add(G.triples + 3);
else if (w === 2) G.flames = add(G.flames + 3);
else G.guns = add(G.guns + 1); // one machine-gun charge — a burst you pop when you choose, not on arrival
if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking)
sendBoy(); voice('new_balls', 0.6); sendBoy(); voice('new_balls', 0.6);
G.flash = { text: 'NEW BALLS: ' + ['BOMB', 'TRIPLE', 'FIRE', 'GUN'][w], t: 1.6 }; G.flash = { text: 'NEW BALLS: ' + SLOTS[w + 1], t: 1.6 };
} }
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie. // ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
@@ -519,11 +532,24 @@ function racketAI(rz, dt) {
} }
} }
// GUN in the rack isn't a ball — committing a charge starts an mgT auto-fire burst (the same
// mode a horde uses). With GUN selected, any serve gesture spends one charge instead of serving.
function tryGunBurst() {
if (G.mode !== 'splatter' || G.weapon !== 4 || G.guns <= 0) return false;
G.guns--; if (G.guns <= 0) G.weapon = 0; // spent the last tin — back to plain balls
G.mgT = TUNE.mgPickupLen; G.mgCool = 0;
ball.active = false; G.phase = 'rally';
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
G.flash = { text: 'GUN', t: 1.0 };
return true;
}
// release of the two-stage aim: an instant serve to the reticle (quality fixed — placement // release of the two-stage aim: an instant serve to the reticle (quality fixed — placement
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) // is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
export function dragServe(aim) { export function dragServe(aim) {
if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return; if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return;
if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) return; if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) return;
if (tryGunBurst()) return;
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
hitServe(P, 0.62, aim); hitServe(P, 0.62, aim);
@@ -539,6 +565,7 @@ export function keyPressed(code) {
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
if (G.phase === 'pretoss' && score.server === 'P') { if (G.phase === 'pretoss' && score.server === 'P') {
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
if (G.mode === 'splatter') { if (G.mode === 'splatter') {
const flick = takeFlickAim(); const flick = takeFlickAim();
if (flick) { // flick = a quick aimed serve; a plain tap still does the full toss ritual if (flick) { // flick = a quick aimed serve; a plain tap still does the full toss ritual
@@ -654,6 +681,7 @@ export function update(dt) {
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0)
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) { && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) {
zombies[0].dueled = true; // one duel per opponent — after that it just rallies zombies[0].dueled = true; // one duel per opponent — after that it just rallies
G.weapon = 0; // tennis rules for the duel: force plain balls, no tins
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return
zombieDuck(true); // fade the zombie music out for the 1:1 duel zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4); voice('play_resume', 0.2); voice('sudden_death', 2.4);
+6 -1
View File
@@ -5,6 +5,11 @@
<title>Splatter Tennis</title> <title>Splatter Tennis</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<style> <style>
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack */
@font-face { font-family: 'Silkscreen'; font-weight: 400; font-display: block;
src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); }
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
src: url('assets/fonts/Silkscreen-Bold.ttf') format('truetype'); }
html, body { margin: 0; height: 100%; background: #10141c; display: grid; place-items: center; } html, body { margin: 0; height: 100%; background: #10141c; display: grid; place-items: center; }
canvas { image-rendering: pixelated; width: min(96vw, calc(96vh * 16 / 9)); aspect-ratio: 16/9; touch-action: none; } canvas { image-rendering: pixelated; width: min(96vw, calc(96vh * 16 / 9)); aspect-ratio: 16/9; touch-action: none; }
#hint { position: fixed; bottom: 8px; width: 100%; text-align: center; #hint { position: fixed; bottom: 8px; width: 100%; text-align: center;
@@ -47,6 +52,6 @@
<p class="start">TAP TO START</p> <p class="start">TAP TO START</p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=10"></script> <script type="module" src="main.js?v=15"></script>
</body> </body>
</html> </html>
+10 -3
View File
@@ -5,13 +5,15 @@
import { cv } from './dom.js'; import { cv } from './dom.js';
import { audio } from './audio.js'; import { audio } from './audio.js';
import { G, score } from './state.js'; import { G, score } from './state.js';
import { keyPressed, dragServe } from './gameplay.js'; import { keyPressed, dragServe, selectWeapon } from './gameplay.js';
import { rackHit } from './render.js';
import { togglePanel } from './panel.js'; import { togglePanel } from './panel.js';
const keys = {}; const keys = {};
addEventListener('keydown', e => { addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault(); if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
if (e.key === 't' || e.key === 'T') togglePanel(); if (e.key === 't' || e.key === 'T') togglePanel();
if (e.key >= '1' && e.key <= '5') selectWeapon(+e.key - 1); // pick a rack slot
if (!keys[e.code]) keyPressed(e.code); if (!keys[e.code]) keyPressed(e.code);
keys[e.code] = true; keys[e.code] = true;
}); });
@@ -40,7 +42,7 @@ export function takeFlickAim(maxAgeMs = 450) {
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle // the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
// (G.aim, drawn by render); release fires the serve there, dragging back home cancels. // (G.aim, drawn by render); release fires the serve there, dragging back home cancels.
const canAimServe = () => const canAimServe = () =>
G.mode === 'splatter' && G.phase === 'pretoss' && score.server === 'P' G.mode === 'splatter' && G.phase === 'pretoss' && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle
&& !G.horde && !(G.mgT > 0) && !G.changeover && !(G.interlude && G.interlude.introT > 0); && !G.horde && !(G.mgT > 0) && !G.changeover && !(G.interlude && G.interlude.introT > 0);
let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it
@@ -48,13 +50,18 @@ export const touchPX = () => steerPX;
cv.addEventListener('touchstart', e => { cv.addEventListener('touchstart', e => {
e.preventDefault(); audio(); e.preventDefault(); audio();
const r = cv.getBoundingClientRect();
for (const t of e.changedTouches) { for (const t of e.changedTouches) {
if (!G.horde && !(G.mgT > 0)) { // rack tap selects a tin — but never during finger-steer modes
const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height);
if (slot >= 0) { selectWeapon(slot); continue; }
}
if (!stick) { if (!stick) {
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(),
moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve
// the reticle appears the moment the thumb lands: mag 0 = auto-target the most advanced // the reticle appears the moment the thumb lands: mag 0 = auto-target the most advanced
if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 }; if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
else { const r = cv.getBoundingClientRect(); steerPX = (t.clientX - r.left) / r.width * 2 - 1; } else steerPX = (t.clientX - r.left) / r.width * 2 - 1;
} }
else keyPressed('Space'); else keyPressed('Space');
} }
+4 -2
View File
@@ -5,7 +5,7 @@
import { TUNE, COURT, UMP_H, UMP_X } from './config.js'; import { TUNE, COURT, UMP_H, UMP_X } from './config.js';
import { P, O, ball, score, G, setupPoint } from './state.js'; import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint } from './gameplay.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js'; import { fitViewport, render, proj, RES } from './render.js';
import { voice, ambient } from './audio.js'; import { voice, ambient } from './audio.js';
import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js'; import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js';
@@ -22,6 +22,8 @@ function frame(now) {
fitViewport(); fitViewport();
setupPoint(); setupPoint();
// warm the pixel HUD font so the canvas never paints a system-serif fallback frame
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
// intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play // intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play
const intro = document.getElementById('intro'); const intro = document.getElementById('intro');
@@ -55,6 +57,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon,
step: dt => update(dt), render, press: keyPressed, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+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', '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_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_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(); const img = new Image();
img.onload = () => SPR[n] = img; img.onload = () => SPR[n] = img;
img.src = 'assets/' + n + '.png'; 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 ---- // ------------------------------------------------------------------ frame ----
export function render() { export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density 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') ? (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)]; : ['0', '15', '30', '40'][Math.min(score.pts[i], 3)];
ctx.save(); ctx.translate(6, 6); ctx.save(); ctx.translate(6, 6);
const bW = 150, bH = splat ? 42 : 30, rowsB = splat ? 28 : bH - 2; const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42)
R(0, 0, bW, bH, bd.face); // panel face if (SPR.scoreboard) ctx.drawImage(SPR.scoreboard, 0, 0, bW, bH); // generated ornate board backdrop
R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi); // bevel highlight (top/left) else { // code-drawn fallback until the PNG loads
R(0, bH - 1, bW, 1, bd.lo); R(bW - 1, 0, 1, bH, bd.lo); // bevel shadow (bottom/right) R(0, 0, bW, bH, bd.face);
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 R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi);
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(0, bH - 1, bW, 1, bd.lo); R(bW - 1, 0, 1, bH, bd.lo);
R(7, 13, 84, 1, bd.lo); R(7, 14, 84, 1, bd.engHi); // engraved row divider (name→games only) 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'; ctx.textBaseline = 'middle';
const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
const px = 92, pw = bW - px - 3; // recessed points slot
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const cy = 8 + i * 11, svr = (score.server === 'P') === (i === 0), sy = cy - 5; const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands
R(px, sy, pw, 11, svr ? bd.slotSvr : bd.slot); // slot well if (svr) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(11, cy, 1.8, 0, 7); ctx.fill(); } // server pip on the purple spine
R(px, sy, pw, 1, '#05160e'); R(px, sy, 1, 11, '#05160e'); // inset shadow (top/left) ctx.textAlign = 'left'; ctx.font = F(8, svr); ctx.fillStyle = svr ? bd.cream : bd.dim;
R(px, sy + 10, pw, 1, bd.engHi); R(px + pw - 1, sy, 1, 11, svr ? bd.spineHi : bd.engHi); // inset light (bottom/right) ctx.fillText(names[i], 18, cy);
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);
ctx.textAlign = 'center'; 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 if (splat) { ctx.font = F(8, 0); ctx.fillStyle = bd.dim; ctx.fillText(String(G.sets[i]), 74, cy); } // sets
ctx.font = 'bold 9px Georgia, serif'; ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 82, cy + 0.5); // games ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games
const pt = ptTxt(i); ctx.font = 'bold ' + (pt.length > 1 ? 9 : 10) + 'px Georgia, serif'; ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot
ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(pt, px + pw / 2, cy + 0.5); // live game
} }
if (splat) { // recessed status well: kills-to-next-point pips + weapon stock if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, just under the board — no box
const y0 = 29, sy = 35; ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 2, bH + 7);
R(7, y0, bW - 9, bH - y0 - 2, '#0a2418'); R(7, y0, bW - 9, 1, '#05160e'); for (let k = 0; k < TUNE.killsPerPoint; k++) {
ctx.textAlign = 'left'; ctx.beginPath(); ctx.arc(32 + k * 8, bH + 7, 2.4, 0, 7);
if (TUNE.killPips && TUNE.killsPerPoint > 1) { // empty pips = zombies still to go before the next point if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); }
ctx.font = 'bold 6px Georgia, serif'; ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 12, sy + 0.5); else { ctx.strokeStyle = '#5a6a5a'; ctx.lineWidth = 1; ctx.stroke(); }
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(); }
}
} }
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.restore();
ctx.textBaseline = 'top'; ctx.textAlign = 'center'; 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 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 = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#c02020'; ctx.font = 'bold 16px monospace'; 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)) { 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 = '#f8e048'; ctx.font = 'bold 8px monospace';
const touch = IS_TOUCH; const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
const txt = G.mode === 'splatter' const txt = G.mode === 'splatter'
? (G.aim ? (stock ? `RELEASE FIRES THE ${stock} · DRAG BACK TO CANCEL` : 'RELEASE TO SERVE · DRAG BACK TO CANCEL') ? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT` : G.aim ? 'RELEASE TO SERVE · DRAG BACK TO CANCEL'
: touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM') : 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') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); : (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) { if (G.flash) {
ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
+8 -4
View File
@@ -32,10 +32,14 @@ export const G = {
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
wasServe: false, // the ball in flight is a serve (first bounce must hit the box) wasServe: false, // the ball in flight is a serve (first bounce must hit the box)
splatT: 0, // time survived in splatter mode — drives the difficulty ramp splatT: 0, // time survived in splatter mode — drives the difficulty ramp
bombs: 0, // bomb-ball stock; the next serve auto-consumes one (delivery 1 of the ladder) // the courtside rack: tins ACCUMULATE (capped at TUNE.wCap), and G.weapon picks which one
triples: 0, // triple-serve stock — a serve fans into three balls (delivery 2) // the next serve throws. Slot 0 = PLAIN (a normal ball — how you bank your specials).
flames: 0, // flame-ball stock — kills on any touch, full pierce (delivery 3) bombs: 0, // bomb-ball stock — a serve blasts an area (rack slot 1)
mgT: 0, // s of machine-gun time left (delivery 4) — auto-fire, no serving triples: 0, // triple-serve stock — a serve fans into three balls (slot 2)
flames: 0, // flame-ball stock — kills on any touch, full pierce (slot 3)
guns: 0, // machine-gun charges — committing one starts an mgT burst (slot 4)
weapon: 0, // selected rack slot: 0 PLAIN, 1 BOMB, 2 TRIPLE, 3 FIRE, 4 GUN
mgT: 0, // s of machine-gun burst left — auto-fire, no serving (spent from G.guns)
mgCool: 0, // s until the next pickup-mode pellet mgCool: 0, // s until the next pickup-mode pellet
deliveries: 0, // tins delivered this run — indexes the weapon ladder deliveries: 0, // tins delivered this run — indexes the weapon ladder
tinDue: false, // the rules called for new balls — deliver at the next changeover tinDue: false, // the rules called for new balls — deliver at the next changeover