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

+44 -16
View File
@@ -146,14 +146,15 @@ function hitServe(sv, quality, aim = null) {
ball.lastHit = sv.side; ball.bounces = 0;
ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts
// specials ride only DELIBERATE serves: a flick (or arrow-aimed keyboard serve) commits the
// tin, a plain tap keeps it in stock — so bombs wait for the enemy you mean them for
const armed = G.mode === 'splatter' && !!aim;
ball.bomb = armed && G.bombs > 0;
// the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your
// specials for later. Placement still follows the flick — only WHICH ball flies is the
// rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve.
const wsel = G.mode === 'splatter' ? G.weapon : 0;
ball.bomb = wsel === 1 && G.bombs > 0;
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 (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--;
for (const deg of [-6, 6]) {
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 });
}
}
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 };
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;
@@ -265,17 +267,28 @@ function startChangeover() {
} 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 —
// a fresh delivery replaces any unspent stock (simpler than stacking, and it keeps you spending).
function deliverTin() {
const w = G.deliveries++ % 4;
G.bombs = G.triples = G.flames = 0; G.mgT = 0;
if (w === 0) G.bombs = TUNE.bombMax;
else if (w === 1) G.triples = 3;
else if (w === 2) G.flames = 3;
else G.mgT = TUNE.mgPickupLen; // starts firing when play resumes
// ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is
// infinite plain balls. selectWeapon is the tap/key target — an empty special can't be picked.
export const SLOTS = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'];
export const slotStock = i => [Infinity, G.bombs, G.triples, G.flames, G.guns][i];
export function selectWeapon(i) {
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
if (i > 0 && slotStock(i) <= 0) return; // dud: no tins of that kind on the rack
G.weapon = i; pock(0.3, 1100);
}
// 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);
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.
@@ -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
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
export function dragServe(aim) {
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 (tryGunBurst()) return;
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;
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
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
if (G.phase === 'pretoss' && score.server === 'P') {
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
if (G.mode === 'splatter') {
const flick = takeFlickAim();
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)
&& 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
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
zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4);