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:
1 parent
4cda6bde76
commit
25f915f236
16 files changed
+385
-97
No files matched your search
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user