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

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

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

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

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

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

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

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

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

No files matched your search

+46
View File
@@ -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
+14
View File
@@ -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."
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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