diff --git a/HANDOFF.md b/HANDOFF.md index 9ad3c41..2b76612 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -438,6 +438,16 @@ Clear the intro first: `document.getElementById('intro')?.remove()`. only when `ref_only` is unused — with `ref_only` set, list the throwaway as an extra sprite (`p_pull0`) and delete the PNG afterwards. +15. **NOTHING IN A SPRITE MAY BE PINK, MAGENTA, VIOLET OR PURPLE.** The chroma key does not merely + match the corner colour, it deletes every pixel where red AND blue both beat green by 40 + (`_key.py`: `min(r, b) > g + 40`, and `_fine.py` de-fringes the key twice). So a "vivid magenta + drum" and a "magenta laser pistol with a violet coil" both came back as hollow outlines — 19% and + 15% solid pixels against 50-56% for the icons drawn in other colours. Measure `alpha > 128` as a + fraction of the box if a sprite looks thin; that is the whole diagnosis. **Pink can only ever be + PROCEDURAL** — the weapon barrel is grey steel with yellow chevrons in its PNG, and every bit of + its pink is the halo `drawBarrel` paints around it, which never goes near the keyer. State the + forbidden colours in the prompt explicitly; "make it pink" is a request the pipeline cannot fill. + Known generator quirks, all still true: - It sometimes ignores the magenta (harmless — the key uses the corner colour). - It draws near-identical animation frames unless you describe strongly contrasted poses. @@ -450,6 +460,42 @@ Known generator quirks, all still true: - **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel linear map onto the original's mean and sd, then verify with a mean-difference number. +## Session log: a rack you fill yourself, and barrels that fill it (2026-08-03) + +The second half of the same ask. **The rack no longer has fixed slots.** It used to be five hardcoded +positions where the index WAS the weapon — `SLOTS`, `slotStock`, `WDESC`, `drawRack`'s three parallel +arrays, `deliverTin`'s `w + 1`, and bare `wsel === 1|2|3` / `G.weapon === 4` tests in five files. Now +`config.WEAPONS` is the single table, `G.rack` holds up to four `{id, n}` in the order you GATHERED +them, and slot 0 is BALL. Read it through `slotId` / `slotStock` / `slotEntry` and nothing else needs +to know how it is stored. + +**A fifth weapon is always allowed** (Oliver's rule): it rides slot 0 as `G.temp` until spent, then +slot 0 goes back to being the infinite ball. A barrel arms on the spot and is spent quickly, so +refusing it would mean a barrel you shot did nothing. **A spent weapon LEAVES its slot** — with +gathered slots, an exhausted weapon sitting in one is a quarter of the rack you can never pick up +into. (The old rack kept empty slots dimmed, which was right when a slot meant "BOMB goes here".) + +**Four new weapons, and they are all the one 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. Every one of them goes 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 16 m+, the flamer is a deliberate ~6 m cone that ignites. + +**`G.mgT` stayed the mode timer for all of them**, which is why 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'` (laser 10, rocket 3) holds the same braced stance and fires one per TAP. + +**The barrels** are the ball machine's shape reused: a rolling, shootable non-body. Three +registrations are silent if forgotten and all three are done — `blocksClear` (a barrel must NOT gate +the hand-clear; it is optional loot), the contact-kill guard (furniture cannot eat you), and +`hitR`/`GORE_MUL`/`zombieH`. It scores nothing. + +**The pink is procedural, and it has to be** — see pipeline note 15. A magenta drum and a magenta +laser pistol were both eaten by the chroma key. + +`TUNE.tinTriples` 3 -> 10, as asked. + ## Session log: the roster grows — moving bosses, six of them, three new bodies (2026-08-03) Oliver asked for five things. This is the first half: **enemies first, weapons second**, his call, so diff --git a/assets/_regen_batches.json b/assets/_regen_batches.json index e314e17..d660711 100644 --- a/assets/_regen_batches.json +++ b/assets/_regen_batches.json @@ -754,5 +754,19 @@ "check": "loose", "subject": "The reference shows the SAME boss in four states: undamaged, then three worsening stages of damage. Draw all FOUR of those exact states again, in the same order, same size, same costume, same wounds \u2014 but each one caught MID-THROW: the whole body wound up and twisted, the surviving arm drawn back high over the shoulder about to hurl a tennis ball straight at the viewer, weight on the back foot. The wounds must stay clearly visible in every frame. Output exactly four figures in one row.", "state": true + }, + "barrel": { + "sprites": [ + "z_barrel1", + "z_barrel2" + ], + "ref_only": [ + "z_mach1" + ], + "anchor": 0, + "check": "any", + "newart": true, + "h": 150, + "subject": "The reference is a piece of tennis-court equipment drawn as pixel art, seen from the side. Draw TWO frames of a DIFFERENT object in that same style: a squat STEEL DRUM lying on its side like a barrel about to be rolled, banded with two raised hoops, its curved end-cap facing the viewer. The drum is DARK GUNMETAL GREY steel, heavily dented and streaked with rust, with bold YELLOW AND BLACK hazard chevrons stencilled around the middle band and a thin band of glowing PALE CYAN-WHITE light leaking from the seams between the staves. IMPORTANT: no part of the drum may be pink, magenta, purple or violet \u2014 use only grey steel, rust brown, yellow, black and pale cyan-white. It is a wide low object, clearly wider than it is tall, and it sits ON THE GROUND. The two frames are the same drum rotated part of a turn, so the hoops, chevrons and dents sit at visibly DIFFERENT angles \u2014 frame two is rolled about a third of a turn from frame one. Output exactly two objects in one row, evenly spaced." } } \ No newline at end of file diff --git a/assets/w_beacon.png b/assets/w_beacon.png new file mode 100644 index 0000000..4db01af Binary files /dev/null and b/assets/w_beacon.png differ diff --git a/assets/w_flamer.png b/assets/w_flamer.png new file mode 100644 index 0000000..7cb028f Binary files /dev/null and b/assets/w_flamer.png differ diff --git a/assets/w_laser.png b/assets/w_laser.png new file mode 100644 index 0000000..fca718c Binary files /dev/null and b/assets/w_laser.png differ diff --git a/assets/w_rocket.png b/assets/w_rocket.png new file mode 100644 index 0000000..14084e2 Binary files /dev/null and b/assets/w_rocket.png differ diff --git a/assets/z_barrel1.png b/assets/z_barrel1.png new file mode 100644 index 0000000..a6e3d73 Binary files /dev/null and b/assets/z_barrel1.png differ diff --git a/assets/z_barrel2.png b/assets/z_barrel2.png new file mode 100644 index 0000000..ecfa92f Binary files /dev/null and b/assets/z_barrel2.png differ diff --git a/config.js b/config.js index f1e5188..34c735c 100644 --- a/config.js +++ b/config.js @@ -211,10 +211,9 @@ export const TUNE = { // on its own so there is always a chance at one. Fires off the next hand-cleared wave. duelFirst: 2, // game after which the first scheduled sudden-death duel is called duelEvery: 3, // games between scheduled duels after the first - bombMax: 3, // bomb balls in a fresh tin - tinTriples: 3, // triple-serves in a fresh tin - tinFlames: 3, // flame balls in a fresh tin - tinGuns: 1, // machine-gun charges in a fresh tin + // How many a fresh tin holds now lives on its WEAPONS row (`tin`), because the rack is no longer + // four fixed types — bombMax/tinTriples/tinFlames/tinGuns lived here and nothing read them after + // the table landed, which is a value you can tune in the panel all day for no effect. wCap: 9, // per-type cap on the courtside rack — tins accumulate, but only so many rackH: 30, // px height of the bottom weapon rack (feel knob: how much court it covers). // 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which @@ -231,6 +230,14 @@ export const TUNE = { mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage." dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes + shotHoldLen: 14, // s you stay braced with a TAP-fired weapon (laser, rocket) before the window + // closes on its own. Running the magazine out ends it earlier, which is the + // normal way; this only stops a forgotten rocket freezing the serve loop. + // ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder + barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery) + barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led + barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body + barrelH: 1.15, // drawn height (a body is 1.8) fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable @@ -293,7 +300,10 @@ export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs arou // desaturated from the original arcade primaries: they were shouting over the artwork. // (NOT the gore — blood and piles keep their loud reds; only text and interactive UI.) export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc', - dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' }; + dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063', + // the weapon barrel's glow, and the only pink on the court: nothing else in the + // palette is near it, so "pink means a weapon is in there" needs no explaining + pink: '#ff5cf0' }; // ------------------------------------------------------------------ courts ---- // A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render(). @@ -408,13 +418,54 @@ export const ZWOUND = { k: ['z_kw', 'z_kw2', 'z_kw3', 'z_kw4'], }; +// ---- THE ARSENAL. One table, keyed by id, replacing the five places the roster used to be spelled +// out (SLOTS, slotStock, WDESC, drawRack's three parallel arrays, and deliverTin's index mapping). +// The rack no longer has a fixed weapon per slot: G.rack holds up to four of these in the order you +// GATHERED them, and slot 0 stays BALL. A weapon is defined entirely by its row. +// kind 'serve' — arms the next serve (the four original tins) +// kind 'burst' — commits to a braced auto-fire window: `len` seconds at `rate` rounds/s +// kind 'shot' — same braced stance, but each TAP fires one and `tin` is the magazine +// The pellet fields (r, dmg, pierce, boom, ignite, col, speed) are stamped onto the round by +// firePellet, so all four new weapons are the ONE existing pellet loop with different numbers. +export const WEAPONS = { + bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve', + desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] }, + triple: { label: 'TRIPLE', icon: 'w_triple', tin: 10, kind: 'serve', // was 3 — Oliver asked for ten + desc: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'] }, + fire: { label: 'FIRE', icon: 'w_fire', tin: 3, kind: 'serve', + desc: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'] }, + gun: { label: 'GUN', icon: 'w_gun', tin: 1, kind: 'burst', len: 8, rate: 12, + r: 0.5, dmg: 0.34, hp: 2, speed: 26, + desc: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'] }, + // ---- the four that come out of a barrel. Won by shooting one, never delivered. + beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 10, rate: 3, + r: 1.1, dmg: 3, hp: 9, speed: 17, col: '#7fefff', glow: 1, + desc: ['A SLOW BEAT OF BIG', 'GLOWING SHELLS'] }, + flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 5, rate: 16, + r: 0.8, dmg: 2, hp: 9, speed: 11, life: 0.55, ignite: true, col: '#ffa040', glow: 1, // life 0.55s x 11 m/s = a ~6 m cone + desc: ['A CONE OF FIRE', 'AND THE FIRE SPREADS'] }, + laser: { label: 'LASER', icon: 'w_laser', tin: 10, kind: 'shot', + // NOT magenta: the icon art is chroma-keyed and the key eats every pink/purple + // (min(r,b) > g+40), so a magenta laser came back as a hollow outline. Pink stays + // the BARREL's identity, where it is a procedural glow and never goes near the key. + r: 0.55, dmg: 3, hp: 9, speed: 70, pierce: true, col: '#8ab6ff', glow: 1, + desc: ['TEN SHOTS, AND EACH ONE', 'GOES THROUGH THE WHOLE LANE'] }, + rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 3, kind: 'shot', + r: 0.6, dmg: 3, hp: 9, speed: 22, boom: true, col: '#ffd050', glow: 1, + desc: ['THREE ROCKETS', 'EACH ONE TAKES THE CROWD'] }, +}; +// what a pink barrel can be carrying — the four above that you cannot win any other way +export const BARREL_WEAPONS = ['beacon', 'laser', 'rocket', 'flamer']; +export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent + export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture // (a body is PLAYER_H 1.88), but big enough to be a thing on the court. // Drawn height of a body on the far side, by variant. render draws to it AND the serve aims at a // fraction of it, so the two cannot drift — a shot at a brute has to arrive at the brute's chest, // not at the height a shambler's chest happens to be. -export const zombieH = zb => zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15 +export const zombieH = zb => zb.variant === 'o' ? TUNE.barrelH + : zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15 : zb.variant === 'k' ? 3.2 : zb.variant === 'h' ? 2.4 : zb.variant === 'r' ? 1.85 : zb.variant === 'm' ? MACH_H : zb.variant === 'B' ? 1.8 * (zb.scale || 1.5) : 1.8; diff --git a/gameplay.js b/gameplay.js index 8207e7a..fb08f1e 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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') { diff --git a/index.html b/index.html index 42ed132..77edb27 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/input.js b/input.js index 077d91b..3c589b3 100644 --- a/input.js +++ b/input.js @@ -4,9 +4,9 @@ import { cv } from './dom.js'; import { audio, pock } from './audio.js'; -import { TUNE } from './config.js'; +import { TUNE, WEAPONS } from './config.js'; import { G, score } from './state.js'; -import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal } from './gameplay.js'; +import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal, slotId } from './gameplay.js'; import { rackHit } from './render.js'; import { togglePanel } from './panel.js'; @@ -45,10 +45,14 @@ export function takeFlickAim(maxAgeMs = 450) { // (G.aim, drawn by render); release fires the serve there, dragging back home cancels. // EVERY phase of splatter play, so the drag never silently turns into something else. The old // "drag walks the player" mode between shots was invisible state and read as a bug. +// a weapon you UNLOAD (burst or shot) rather than serve — it takes the tap directly, so the +// slingshot reticle must stay out of its way. Was `G.weapon !== 4` back when slot 4 was always GUN. +const isGunLike = () => { const w = WEAPONS[slotId(G.weapon)]; return !!w && (w.kind === 'burst' || w.kind === 'shot'); }; + const canAimServe = () => !G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow && G.mode === 'splatter' // every phase of it; 'eaten' is its own mode and never gets here - && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle + && score.server === 'P' && !isGunLike() // a gun-like weapon pops on tap, no reticle && !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it diff --git a/main.js b/main.js index 3327dd2..5cec911 100644 --- a/main.js +++ b/main.js @@ -5,10 +5,10 @@ import { TUNE, COURT, COURTS, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { P, O, ball, score, G, setupPoint, selectCourt } from './state.js'; -import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js'; +import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js'; import { fitViewport, render, proj, RES } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; -import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer } from './zombies.js'; +import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { cv } from './dom.js'; import './input.js'; @@ -184,6 +184,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 8b48ac4..ea89690 100644 --- a/render.js +++ b/render.js @@ -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/.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') diff --git a/state.js b/state.js index 70bb977..d64fa62 100644 --- a/state.js +++ b/state.js @@ -45,15 +45,19 @@ export const G = { aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction wasServe: false, // the ball in flight is a serve (first bounce must hit the box) splatT: 0, // time survived in splatter mode — drives the difficulty ramp - // the courtside rack: tins ACCUMULATE (capped at TUNE.wCap), and G.weapon picks which one - // the next serve throws. Slot 0 = PLAIN (a normal ball — how you bank your specials). - bombs: 0, // bomb-ball stock — a serve blasts an area (rack slot 1) - triples: 0, // triple-serve stock — a serve fans into three balls (slot 2) - flames: 0, // flame-ball stock — kills on any touch, full pierce (slot 3) - guns: 0, // machine-gun charges — committing one starts an mgT burst (slot 4) - weapon: 0, // selected rack slot: 0 PLAIN, 1 BOMB, 2 TRIPLE, 3 FIRE, 4 GUN - mgT: 0, // s of machine-gun burst left — auto-fire, no serving (spent from G.guns) - mgCool: 0, // s until the next pickup-mode pellet + // The courtside rack. Slots 1-4 are NOT fixed weapon types anymore: they hold what you have + // GATHERED, in pickup order, so which four you are carrying is a run-by-run thing. Stock still + // ACCUMULATES per weapon (capped at TUNE.wCap). Slot 0 stays BALL — infinite, and how you bank a + // special instead of spending it. + rack: [], // up to RACK_MAX { id, n } — the gathered weapons, in the order they arrived + temp: null, // { id, n } — a FIFTH weapon, riding slot 0 until it is spent. A barrel arms + // itself on the spot, so it always has somewhere to go even with a full rack. + weapon: 0, // selected slot: 0 = BALL (or temp, if one is riding it), 1..4 = G.rack[i-1] + mgT: 0, // s of braced auto-fire left — no serving while it runs (gun/beacon/flamer, + // and it doubles as the window a tap-fired laser or rocket is held for) + mgCool: 0, // s until the next pellet in that burst + gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour + gunShots: 0, // rounds left for a kind:'shot' weapon (laser 10, rocket 3); 0 ends the window deliveries: 0, // tins delivered this run — indexes the weapon ladder duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves diff --git a/zombies.js b/zombies.js index d0e42cb..404d450 100644 --- a/zombies.js +++ b/zombies.js @@ -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);