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
+71
-14
@@ -3,10 +3,10 @@
|
||||
// your baseline — one crossing it is a point for THEM.
|
||||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
||||
|
||||
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal } from './gameplay.js';
|
||||
|
||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||||
@@ -50,6 +50,11 @@ export function spawnZombie(x, z, variant) {
|
||||
if (zb.hp) zb.hpMax = zb.hp;
|
||||
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
|
||||
if ('fgsdehk'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
|
||||
if (variant === 'o') { // the weapon barrel: a rolling pink target with a weapon inside it
|
||||
zb.v = 0; zb.hp = zb.hpMax = TUNE.barrelHp;
|
||||
zb.wid = BARREL_WEAPONS[Math.floor(Math.random() * BARREL_WEAPONS.length)];
|
||||
zb.dir = Math.random() < 0.5 ? -1 : 1; zb.roll = 0;
|
||||
}
|
||||
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES...
|
||||
zb.v = 0; zb.hp = zb.hpMax = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
|
||||
zb.repoT = TUNE.machRepo * (0.4 + Math.random()); // ...but it wheels itself somewhere else now and then
|
||||
@@ -57,6 +62,18 @@ export function spawnZombie(x, z, variant) {
|
||||
zombies.push(zb);
|
||||
}
|
||||
|
||||
// A WEAPON BARREL, rolled across the far court. Shoot it and you get what is inside, armed on the
|
||||
// spot. It is the only source of the four weapons that are not on the duel ladder, and it is pure
|
||||
// upside — it never attacks, never scores, and rolls off on its own if you ignore it.
|
||||
export function spawnBarrel() {
|
||||
spawnZombie((Math.random() * 2 - 1) * 3.2, 4.5 + Math.random() * 3, 'o');
|
||||
if (!G.seen.barrel) {
|
||||
G.seen.barrel = true;
|
||||
showModal({ title: 'A GLOWING BARREL', color: UI.pink,
|
||||
body: ['THERE IS A WEAPON INSIDE IT', 'SHOOT IT AND IT IS YOURS', 'PINK MEANS TAKE THE SHOT'] });
|
||||
}
|
||||
}
|
||||
|
||||
// the rogue ball machine, wheeled out onto the far court. It never moves and never scores; it just
|
||||
// shoots at you until you deal with it.
|
||||
export function spawnMachine() {
|
||||
@@ -249,6 +266,7 @@ function pickVariant() {
|
||||
// deliberately NOT one — melee/bomb/fire one-shotting a brute is a settled "power exception".
|
||||
const isBoss = zb => zb.variant === 'r' || zb.variant === 'B';
|
||||
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.variant === 'r' ? 0.7
|
||||
: zb.variant === 'o' ? 0.7 // the barrel: a wide low drum, generous on purpose
|
||||
: zb.variant === 'k' ? 0.88 : zb.variant === 'h' ? 0.68 // the roller man is wider than the brute
|
||||
: zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be
|
||||
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
|
||||
@@ -257,7 +275,22 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va
|
||||
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
|
||||
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
|
||||
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
|
||||
const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
|
||||
const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, o: 0, B: 6 }; // a chapter boss comes apart like nothing else on the court; a barrel is not meat at all
|
||||
|
||||
// A barrel bursts and hands you what was inside, armed. Pink debris rather than gore — it is a
|
||||
// container, not a body — and the modal only fires the FIRST time you see each weapon, so the tenth
|
||||
// rocket does not stop the world to explain itself.
|
||||
function popBarrel(zb) {
|
||||
boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08;
|
||||
for (let k = 0; k < 30; k++) parts.push({
|
||||
x: zb.x, y: 0.7, z: zb.z,
|
||||
vx: (Math.random() * 2 - 1) * 4, vy: 1.2 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 4,
|
||||
t: 0.35 + Math.random() * 0.5, pink: true });
|
||||
grantWeapon(zb.wid);
|
||||
const w = WEAPONS[zb.wid];
|
||||
G.flash = { text: w.label, t: 1.1 };
|
||||
if (!G.seen['w' + zb.wid]) { G.seen['w' + zb.wid] = true; weaponModal(zb.wid); }
|
||||
}
|
||||
|
||||
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
|
||||
function smokePuff(x, y, z, spread = 0.5) {
|
||||
@@ -305,6 +338,7 @@ function killZombie(i, quiet) {
|
||||
const mul = GORE_MUL[zb.variant] || 1;
|
||||
zombies.splice(i, 1);
|
||||
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
|
||||
if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING
|
||||
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
|
||||
wreckMachine(zb);
|
||||
if (!G.horde && !G.clearing) splatPoint('P', quiet);
|
||||
@@ -479,10 +513,18 @@ function spawnHordeZombie() { // all from the far baseline, across the court —
|
||||
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), v);
|
||||
}
|
||||
let mgTick = 0;
|
||||
function firePellet() {
|
||||
const a = (Math.random() * 2 - 1) * TUNE.mgSpread * Math.PI / 180, s = 26; // spread cone, straight up-court (-z)
|
||||
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 });
|
||||
if (++mgTick % 2 === 0) pock(0.25, 1400); // rapid fire tick, every 2nd round — at 12+/s every round buzzes
|
||||
// One round, for every barrel-fed weapon there is. The WEAPONS row is stamped onto the pellet, so
|
||||
// the loop below serves the machine gun, the beacon cannon, the flamethrower, the laser and the
|
||||
// rocket without a second array or a second update — the only things that ever differed were a
|
||||
// radius, a damage number, whether it stops at the first body, and what it does when it lands.
|
||||
// Called with no argument by the HORDE, which is the plain machine gun.
|
||||
export function firePellet(w = WEAPONS.gun) {
|
||||
const spread = w.life ? TUNE.mgSpread * 2.4 : TUNE.mgSpread; // a short-lived round is a CONE, not a line
|
||||
const a = (Math.random() * 2 - 1) * spread * Math.PI / 180, s = w.speed ?? 26; // straight up-court (-z)
|
||||
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s,
|
||||
t: w.life ?? 1.2, r: w.r ?? TUNE.mgPelletR, dmg: w.dmg ?? TUNE.dmgPellet, hp: w.hp ?? TUNE.dmgPelletHp,
|
||||
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0, col: w.col, glow: w.glow });
|
||||
if (++mgTick % 2 === 0) pock(0.25, w.boom ? 420 : w.pierce ? 2100 : 1400); // every 2nd round — at 12+/s every round buzzes
|
||||
}
|
||||
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
|
||||
// He stiffens as the run goes on. hpMax is stored because the wound skins and bleed() anchors read
|
||||
@@ -606,7 +648,10 @@ export function updateZombies(dt) {
|
||||
// moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and
|
||||
// `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
|
||||
G.mgT -= dt;
|
||||
if (G.phase === 'rally' && (G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
||||
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
|
||||
// kind:'shot' weapon has no rate — it fires on the TAP instead (gameplay.tryGunShot).
|
||||
const gw = WEAPONS[G.gunId] || WEAPONS.gun;
|
||||
if (G.phase === 'rally' && gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
|
||||
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
||||
}
|
||||
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns)
|
||||
@@ -715,7 +760,14 @@ export function updateZombies(dt) {
|
||||
zb.face = away;
|
||||
continue;
|
||||
}
|
||||
if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
|
||||
if (zb.variant === 'o') { // the barrel just rolls, turning at the sidelines. It never attacks.
|
||||
if (!held && G.mode === 'splatter' && !G.interlude) {
|
||||
zb.x += zb.dir * TUNE.barrelRoll * dt;
|
||||
zb.roll += TUNE.barrelRoll * dt; // drawn spin, so it reads as ROLLING
|
||||
if (Math.abs(zb.x) > 4.4) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * 4.4; }
|
||||
}
|
||||
}
|
||||
else if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
|
||||
if (!held && G.mode === 'splatter' && !G.interlude) {
|
||||
if (zb.rollTo !== undefined) {
|
||||
const d = zb.rollTo - zb.x;
|
||||
@@ -739,7 +791,7 @@ export function updateZombies(dt) {
|
||||
// CONTACT IS STILL INSTANT DEATH — settled decision, and the ball machine's chip damage does not
|
||||
// soften it. Two threats that demand two different responses is the point. The machine ('m') is
|
||||
// furniture and cannot eat you.
|
||||
if (!held && G.mode === 'splatter' && zb.variant !== 'm'
|
||||
if (!held && G.mode === 'splatter' && !'mo'.includes(zb.variant) // furniture and barrels cannot eat you
|
||||
&& Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
|
||||
killPlayer();
|
||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||
@@ -815,14 +867,19 @@ export function updateZombies(dt) {
|
||||
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||
const zb = zombies[j];
|
||||
if (!zb) continue;
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + TUNE.mgPelletR) {
|
||||
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + (pe.r ?? TUNE.mgPelletR)) {
|
||||
// Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally
|
||||
// nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so
|
||||
// three rounds take a point off and the gun is a slow, committed answer rather than a no-op.
|
||||
if (isBoss(zb)) { if (wound(zb, TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
|
||||
else if ((zb.hp -= TUNE.dmgPelletHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
|
||||
// EVERY new weapon comes through here, which is why none of them can silently no-op on a
|
||||
// boss the way three of the five once did.
|
||||
if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself
|
||||
if (isBoss(zb)) { if (wound(zb, pe.dmg ?? TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
|
||||
else if ((zb.hp -= (pe.hp ?? TUNE.dmgPelletHp)) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
|
||||
else killZombie(j, true);
|
||||
hit = true; break;
|
||||
if (pe.boom) { explode(pe.x, pe.z); hit = true; break; } // the rocket takes the crowd, not the body
|
||||
hit = !pe.pierce; // a laser keeps going down the whole lane
|
||||
if (hit) break;
|
||||
}
|
||||
}
|
||||
if (hit || pe.t <= 0) pellets.splice(i, 1);
|
||||
|
||||
Reference in new issue
Block a user