Fill the rack yourself, and roll barrels out that fill it

The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.

A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)

Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.

G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.

The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.

The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.

tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 12:09:28 +02:00
1 parent 4cda6bde76
commit 25f915f236
16 files changed
+385 -97

No files matched your search

+72 -20
View File
@@ -1,9 +1,9 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH } from './config.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS } from './config.js';
import { P, O, ball, score, crowd, G, court } from './state.js';
import { predictLanding, previewServe } from './gameplay.js';
import { predictLanding, previewServe , slotId, slotStock } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
// ---------------------------------------------------------------- palette ----
@@ -181,7 +181,7 @@ function drawRunOff() {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 80; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 81; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
@@ -215,6 +215,8 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'z_c3','z_c4', // ...and the turned CPU walks on four frames too
'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
'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel
'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS
'scoreboard']) {
const img = new Image();
img.onload = () => SPR[n] = img;
@@ -324,6 +326,36 @@ const ZSTRIDE = { g: TUNE.bruteStride, k: TUNE.rollerStride };
// 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.
// The weapon barrel. The DRUM is a sprite (it is an object, and a procedural one would read as
// "naively painted" the way the machine's first version did); the GLOW is procedural, because light
// is one of the three things that rule explicitly exempts, along with blood and fire. It pulses, so
// it separates itself from the static ads and the crowd behind it at 20 px tall.
function drawBarrel(zb) {
const img = SPR.z_barrel1;
const h = TUNE.barrelH * (img ? img.height / SPRITE_H : 1);
const p = proj(zb.x, h * 0.5, zb.z);
const k = 0.55 + 0.45 * Math.sin((zb.roll || 0) * 3.1); // the pulse rides the roll, not a clock
ctx.save();
ctx.globalAlpha = 0.20 + k * 0.30; // the halo
ctx.fillStyle = UI.pink;
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(3, p.s * 0.30 * (0.8 + k * 0.4)), 0, 7); ctx.fill();
ctx.globalAlpha = 1;
ctx.restore();
if (img) {
// two frames alternating off GROUND COVERED is what makes it read as rolling rather than sliding
const n = SPR.z_barrel2 && stepFrame((zb.roll || 0) / 0.5, 2) ? 'z_barrel2' : 'z_barrel1';
drawStatic(n, zb.x, zb.z, TUNE.barrelH * (SPR[n].height / SPRITE_H));
} else { // until the PNG lands, a lit drum
ctx.fillStyle = UI.pink;
const w = Math.max(2, p.s * 0.34), hh = Math.max(3, p.s * 0.44);
ctx.fillRect(Math.round(p.x - w / 2), Math.round(p.y - hh / 2), Math.round(w), Math.round(hh));
}
ctx.globalAlpha = 0.5 + k * 0.5; // the core, brightest at the pulse peak
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(1, p.s * 0.05), 0, 7); ctx.fill();
ctx.globalAlpha = 1;
}
function drawMachine(zb) {
const firing = zb.windT > 0;
// It wears its damage like every other multi-hit body on court: dented at two thirds, split open
@@ -348,6 +380,7 @@ function drawMachine(zb) {
const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres
function drawZombie(zb) {
if (zb.variant === 'o') return drawBarrel(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)
@@ -735,20 +768,25 @@ export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot inde
}
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
// Built from what you are CARRYING, not from three fixed arrays. Slot 0 is BALL unless a fifth
// gathered weapon is riding it (G.temp), and an empty slot is a blank socket rather than a
// permanently dimmed weapon you never had.
const W_ = i => WEAPONS[slotId(i)];
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;
const w = W_(i), stockI = slotStock(i), temp = i === 0 && G.temp;
const x = g.x0 + i * g.tw, sel = G.weapon === i, dim = stockI <= 0;
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null;
// rounded slots: five separate tiles read as five buttons, which is what they are
panel(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3, 4,
sel ? '#123a24' : '#0a2418', // sunk slot
sel ? UI.pick : '#05160e'); // 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;
if (!icon) { ctx.globalAlpha = 1; continue; } // an empty socket: nothing gathered for it yet
ctx.globalAlpha = dim ? 0.35 : 1;
const spr = SPR[WICONS[i]];
const spr = SPR[icon];
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);
@@ -758,11 +796,12 @@ function drawRack() {
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.fillText(stockI === Infinity ? '∞' : '×' + stockI, tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : temp ? UI.pink : '#8fa89a';
ctx.fillText(label, 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
// the burst countdown belongs to whichever slot is holding the armed weapon now, not to slot 4
if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); }
}
ctx.restore();
}
@@ -1061,7 +1100,8 @@ export function render() {
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
continue;
}
ctx.fillStyle = pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
ctx.fillStyle = pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore
: 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);
}
@@ -1069,10 +1109,19 @@ export function render() {
// they take the perspective like everything else. A fixed 4x4 fillRect stayed exactly as big at
// the far baseline as at your feet, which read as square confetti pinned to the screen rather than
// as something travelling away from you. Radius is a shade over half the match ball's 0.09.
// A barrel weapon's round carries its own colour and size (see zombies.firePellet), so a beacon
// shell is a fat cyan ball and a laser bolt a thin magenta one — told apart at a glance, which
// matters because they are all the same projectile underneath.
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), r = Math.max(1, pp.s * 0.05);
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill();
const sc = (pe.r ?? TUNE.mgPelletR) / TUNE.mgPelletR;
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04 * sc), Math.max(1, sh.s * 0.02 * sc), 0.25);
const pp = proj(pe.x, pe.y, pe.z), r = Math.max(1, pp.s * 0.05 * sc);
if (pe.glow) { // the exotic rounds carry their own light, the plain pellet does not
ctx.globalAlpha = 0.35; ctx.fillStyle = pe.col || UI.ball;
ctx.beginPath(); ctx.arc(pp.x, pp.y, r * 2.1, 0, 7); ctx.fill();
ctx.globalAlpha = 1;
}
ctx.fillStyle = pe.col || UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill();
ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.5, r * 0.45), 0, 7); ctx.fill(); // hot core: it reads sharp, not soft
}
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
@@ -1199,17 +1248,20 @@ export function render() {
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // 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 selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN';
// "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`,
// which is meaningless once the slots hold whatever you gathered
const gunLike = selW && (selW.kind === 'burst' || selW.kind === 'shot');
// The DUEL is played under tennis rules — no slingshot, no rack, serve to the box (see the
// !G.interlude guards in solveShot and the aim reticle). It was still being told to pull back
// to aim, which is the one thing that does nothing there.
// after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words —
// so this slot shuts up rather than printing the same instruction a second time 40 px below it
const demo = G.mode === 'splatter' && !G.interlude && G.weapon !== 4 && G.idleHintT > TUNE.pullHintAfter;
const demo = G.mode === 'splatter' && !G.interlude && !gunLike && G.idleHintT > TUNE.pullHintAfter;
const txt = G.mode === 'splatter' && !G.interlude
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`)
: G.aim ? 'PULL DOWN: FURTHER = HARDER'
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
: slotId(G.weapon) !== 'ball' ? `SERVE FIRES: ${sel} · ${switchHint}`
: demo ? ''
: touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')