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
+101
-41
@@ -1,11 +1,11 @@
|
||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||||
|
||||
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST } from './config.js';
|
||||
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
||||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
|
||||
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies } from './zombies.js';
|
||||
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel } from './zombies.js';
|
||||
|
||||
// ------------------------------------------------------------- ballistics ----
|
||||
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
|
||||
@@ -263,12 +263,13 @@ function hitServe(sv, quality, aim = null) {
|
||||
// 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 = wsel === 3 && G.flames > 0;
|
||||
if (ball.flame) G.flames--;
|
||||
if (wsel === 2 && G.triples > 0) { // one serve, three balls: clones fanned ±6°
|
||||
G.triples--;
|
||||
const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
|
||||
ball.bomb = wid === 'bomb' && armed;
|
||||
if (ball.bomb) spendSlot(wsel);
|
||||
ball.flame = wid === 'fire' && armed;
|
||||
if (ball.flame) spendSlot(wsel);
|
||||
if (wid === 'triple' && armed) { // one serve, three balls: clones fanned ±6°
|
||||
spendSlot(wsel);
|
||||
for (const deg of [-6, 6]) {
|
||||
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
|
||||
// g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat
|
||||
@@ -278,7 +279,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
|
||||
// (spendSlot already hands you back plain balls when the last one goes)
|
||||
// tennis only, same reason as doHit's: a splatter serve lands at a shambler's feet, and the ring
|
||||
// sat there in yellow for 1.3 s saying nothing — right where the aim mark is trying to be read.
|
||||
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
|
||||
@@ -376,7 +377,9 @@ function pointTo(side, reason) {
|
||||
// Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the
|
||||
// ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block
|
||||
// progression would turn "decide what to prioritise" into "clear the chore list".
|
||||
const blocksClear = z => z.variant !== 'r' && z.variant !== 'm';
|
||||
// A barrel must NOT gate the hand-clear: it is optional loot, and making it block progression would
|
||||
// turn "decide whether that is worth a shot" into a chore every time one is on court.
|
||||
const blocksClear = z => !'rmo'.includes(z.variant);
|
||||
|
||||
export function splatPoint(side, quiet = false) {
|
||||
if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
|
||||
@@ -414,6 +417,11 @@ export function splatPoint(side, quiet = false) {
|
||||
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
|
||||
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
|
||||
spawnMachine();
|
||||
// ...and a weapon barrel on its own schedule. One at a time: two glowing pink things on court at
|
||||
// once would out-shout everything the palette uses to say "this one is dangerous".
|
||||
if (side === 'P' && G.runGames % TUNE.barrelEvery === 0
|
||||
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
|
||||
spawnBarrel();
|
||||
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
|
||||
// play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear()
|
||||
// fires (see update()). If the winning shot already emptied the court, proceed at once.
|
||||
@@ -469,41 +477,66 @@ function startChangeover() {
|
||||
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
|
||||
}
|
||||
|
||||
// ---- 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];
|
||||
// ---- the courtside rack. Five slots still, but only slot 0 is fixed: it is BALL (infinite), or a
|
||||
// FIFTH gathered weapon riding it temporarily. Slots 1..4 are whatever you have picked up, in order.
|
||||
// Everything reads through these three, so nothing else has to know how the rack is stored.
|
||||
export const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null;
|
||||
export const slotId = i => slotEntry(i)?.id || (i === 0 ? 'ball' : null);
|
||||
export const slotStock = i => { const e = slotEntry(i); return e ? e.n : (i === 0 ? Infinity : 0); };
|
||||
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);
|
||||
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
|
||||
G.weapon = i; pock(0.3, 1100); // temp has just been cleared, and then it is BALL again)
|
||||
}
|
||||
|
||||
// Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides
|
||||
// slot 0 as `temp` — Oliver's rule: a fifth is always allowed, because a barrel arms on the spot and
|
||||
// is spent quickly, so refusing it would mean a barrel you shot did nothing.
|
||||
export function grantWeapon(id, n = WEAPONS[id].tin) {
|
||||
const have = G.rack.find(e => e.id === id);
|
||||
if (have) { have.n = Math.min(TUNE.wCap, have.n + n); G.weapon = G.rack.indexOf(have) + 1; }
|
||||
else if (G.rack.length < RACK_MAX) { G.rack.push({ id, n }); G.weapon = G.rack.length; }
|
||||
else if (G.temp?.id === id) { G.temp.n = Math.min(TUNE.wCap, G.temp.n + n); G.weapon = 0; }
|
||||
else { G.temp = { id, n }; G.weapon = 0; }
|
||||
return G.weapon;
|
||||
}
|
||||
|
||||
// Spend one from a slot, and hand you back plain balls when it runs dry. The temp weapon does not
|
||||
// leave an empty dimmed slot behind the way a rack entry does — slot 0 simply becomes BALL again.
|
||||
export function spendSlot(i) {
|
||||
const e = slotEntry(i);
|
||||
if (!e) return;
|
||||
if (--e.n > 0) return;
|
||||
// Spent, so it LEAVES — a slot is for what you are carrying, and an exhausted weapon sitting in
|
||||
// one is a quarter of the rack you can never pick anything up into. (The old fixed rack kept its
|
||||
// empty slots dimmed, which was right when the slot meant "BOMB goes here" and is wrong now.)
|
||||
if (i === 0) G.temp = null; // slot 0 goes back to being the infinite ball
|
||||
else G.rack.splice(i - 1, 1);
|
||||
G.weapon = 0;
|
||||
}
|
||||
|
||||
// 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.
|
||||
const WDESC = { // what the tin you just won actually does — one line, shown on the pickup modal
|
||||
BOMB: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'],
|
||||
TRIPLE: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'],
|
||||
FIRE: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'],
|
||||
GUN: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'],
|
||||
};
|
||||
const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out
|
||||
export function deliverTin(healLine = null) {
|
||||
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 + TUNE.tinTriples);
|
||||
else if (w === 2) G.flames = add(G.flames + TUNE.tinFlames);
|
||||
else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — 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)
|
||||
const id = TINS[G.deliveries++ % TINS.length];
|
||||
const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking)
|
||||
grantWeapon(id);
|
||||
if (wasPlain) G.weapon = 0;
|
||||
sendBoy(); voice('new_balls', 0.6);
|
||||
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
|
||||
|
||||
const name = SLOTS[w + 1];
|
||||
// healLine folds the trainer's visit INTO this popup. `showModal` deliberately never clobbers an
|
||||
// open modal (a kill cascade can try to open two in one frame), so when both happened the heal
|
||||
// modal opened first and the weapon one was lost entirely — Oliver: "the weapon message is lost".
|
||||
showModal({ title: name, body: healLine ? [...WDESC[name], '', healLine] : WDESC[name],
|
||||
icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w], iconH: 40, color: UI.warm });
|
||||
weaponModal(id, healLine);
|
||||
}
|
||||
|
||||
// The one-per-run "what this thing does" popup, shared by the duel ladder and the barrels.
|
||||
export function weaponModal(id, healLine = null) {
|
||||
const w = WEAPONS[id];
|
||||
showModal({ title: w.label, body: healLine ? [...w.desc, '', healLine] : w.desc,
|
||||
icon: w.icon, iconH: 40, color: UI.warm });
|
||||
}
|
||||
|
||||
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
|
||||
@@ -845,16 +878,39 @@ 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;
|
||||
// A GUN-like weapon isn't a ball — committing to one braces you at the baseline for a window (the
|
||||
// same mode a horde uses) and the serve gesture spends it instead of serving. Generalised from
|
||||
// tryGunBurst: 'burst' weapons auto-fire at their own rate for `len` seconds, 'shot' weapons hold
|
||||
// the same stance but fire one per TAP until the magazine is out. G.mgT stays the mode timer for
|
||||
// BOTH, which is why the eighteen `!(G.mgT > 0)` guards across five files did not have to change.
|
||||
function tryArmGun() {
|
||||
if (G.mode !== 'splatter') return false;
|
||||
const id = slotId(G.weapon), w = WEAPONS[id];
|
||||
if (!w || (w.kind !== 'burst' && w.kind !== 'shot') || slotStock(G.weapon) <= 0) return false;
|
||||
const slot = G.weapon;
|
||||
G.gunId = id;
|
||||
if (w.kind === 'burst') { G.mgT = w.len ?? TUNE.mgPickupLen; G.gunShots = 0; spendSlot(slot); }
|
||||
// a 'shot' weapon spends per ROUND, not on commit, so the magazine IS the stock: hold the stance
|
||||
// for as long as it could take to empty it, and gunShots ends the window early when it runs out.
|
||||
else { G.gunShots = slotStock(slot); G.mgT = TUNE.shotHoldLen; }
|
||||
G.mgCool = 0;
|
||||
ball.active = false; G.phase = 'rally';
|
||||
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
|
||||
P.z = HOME.P.z; // brace at the baseline for the burst (finger steers x only)
|
||||
G.flash = { text: 'GUN', t: 1.0 };
|
||||
G.flash = { text: w.label, t: 1.0 };
|
||||
return true;
|
||||
}
|
||||
// a tap while a 'shot' weapon is braced fires ONE round. Returns true if it consumed the tap.
|
||||
export function tryGunShot() {
|
||||
const w = WEAPONS[G.gunId];
|
||||
if (!(G.mgT > 0) || !w || w.kind !== 'shot' || G.gunShots <= 0) return false;
|
||||
firePellet(w);
|
||||
G.gunShots--;
|
||||
// the magazine lives in the slot it came from, so a half-spent laser you never finish stays half
|
||||
// spent on the rack rather than silently refilling
|
||||
const slot = [0, 1, 2, 3, 4].find(i => slotId(i) === G.gunId);
|
||||
if (slot !== undefined) spendSlot(slot);
|
||||
if (G.gunShots <= 0) G.mgT = 0; // out of rounds: the window closes, updateZombies resumes serving
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -877,7 +933,7 @@ export function dragServe(aim) {
|
||||
if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle
|
||||
if (G.phase === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now
|
||||
if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return;
|
||||
if (tryGunBurst()) return;
|
||||
if (tryArmGun()) return;
|
||||
if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent
|
||||
// Firing while the last ball still flies is fine again — the brake is the pull itself, not the
|
||||
// rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that
|
||||
@@ -919,6 +975,10 @@ export function keyPressed(code) {
|
||||
swish(0.3);
|
||||
return;
|
||||
}
|
||||
// A braced 'shot' weapon (laser, rocket) fires ONE round per tap — that is the whole difference
|
||||
// between it and a burst weapon, and it has to sit above the auto-fire guard below, which would
|
||||
// otherwise swallow the tap as a swish.
|
||||
if (tryGunShot()) return;
|
||||
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
|
||||
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
|
||||
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
|
||||
@@ -932,7 +992,7 @@ export function keyPressed(code) {
|
||||
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check
|
||||
}
|
||||
if (G.phase === 'pretoss' && score.server === 'P') {
|
||||
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
|
||||
if (tryArmGun()) return; // a GUN-like weapon selected: the gesture arms it, no ball
|
||||
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
|
||||
}
|
||||
if (G.phase === 'toss' && score.server === 'P') {
|
||||
|
||||
Reference in new issue
Block a user