diff --git a/HANDOFF.md b/HANDOFF.md index 2b76612..be0128e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -460,6 +460,62 @@ 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: Oliver's playtest of the barrel weapons (2026-08-03, same day) + +Everything below is a fix to what shipped that morning. All four notes were real defects, which is +now seven playtests in a row where "this feels odd" was a bug and not taste. + +**"The bigger guys were way too easy to kill at the beginning."** It was a bug, and an old one. The +ball-vs-body chain handled boss, `g`/`m`, `s`, `f`, `a`/`b` — and **everything not named in it fell +through to "splat-quality kills outright"**. So the STEWARD has been dying to one serve since the day +he shipped with `stewardHp: 5` and a comment calling it "a brute's worth of hp", and the ball boy, +bouncer and roller man inherited that the moment they existed. Pellets and melee always read `hp`; +only the BALL skipped it. It is one generic `hpMax > 1` branch now — hp is the only thing that says +how many hits a body takes — and measured after: shambler 2, steward 5, brute 5, ball boy 3, +bouncer 5, roller man 7, each exactly its hp. **Anything that adds a body type must NOT need a new +branch here; if you find yourself writing one, that is the bug coming back.** + +**"I have seen all of them by round 10."** The new three moved 3/8/15 -> **5/12/20**. The original +four keep their gates exactly: the curve through ~game 12 was already tuned and the calibration rule +says not to touch it. Their hp came down with the fix landing (9 was a fine number while nobody +felt it and a long grind once they did). + +**"The laser fired only when I tapped, that was confusing."** The whole `kind: 'shot'` idea is gone. +A barrel weapon is now exactly one of two things, and this is the rule to hold: **either it is the +normal ball gesture made spectacular, or it fires itself for a while.** Nothing waits for a tap. +- **laser** is a `serve` now: pull, aim, release. **The RAY is the weapon**, so there is no ball + anywhere in it — the preview is the WEDGE itself (`drawBeamWedge` at low alpha, not a ballistic + band with a target ring), the tap path has its own `tryLaser` so it never tosses a ball it is not + going to hit, and `fireBeam` kills everything inside the cone (`beamW0` 0.35 m at the racket to + `beamW1` 4.2 m at the far end). Measured: 8 of 11 bodies in one release. + Three things Oliver corrected on the second look, all of them "it must read as light he fired": + **it leaves the RACKET** (`dragContact()`, y 2.2, settling onto the court as it goes out — drawn + flat from his feet it read as a shape lying on the grass); **the release ERASES it along the path** + (the plane is whole at the flash and is wiped away from the racket outward behind a bright front, + so the shot reads as energy travelling down the lane rather than a decal fading where it lay); and + **the edges are hairlines** — at 2.5 px they were heavier than anything else on the court and read + as a drawn outline instead of as light. + *Gotcha for testing:* `TUNE.pullMin` is 18 **PIXELS**, and `aim.fy` is NEGATIVE for the down-pull. + A synthetic `{fx, fy, mag: 0.8}` is silently below the threshold and `previewServe` returns null, + which looks exactly like the preview being broken. +- **beacon** and **rocket** are `burst` and **LOBBED** — `arc` on the WEAPONS row turns a flat pellet + into a thrown one (solved onto the nearest body ahead), and landing is itself a hit, so a rocket + that misses still goes off where it lands. The beacon changes colour every ball and drags that + colour behind it. +- **flamer** got "way more pronounced": 30 rounds/s, a ~10 m cone, fat glowing rounds with embers. +**The pale blue washed out to grey over the lawn** — a beam colour has to be saturated (`#3f74ff`), +which is the same lesson as the barrel's halo, from the opposite direction. + +**"I got the laser almost every time."** A flat 1-in-4 over four items does not feel uniform. It is +weighted and gated now (`BARREL_WEAPONS` carries `weight` and `from`), never repeats twice running, +and the beacon is what you meet first. Measured: laser 16-28% depending on the game, rocket unlocked +at game 12 so it is genuinely a late find. + +**The barrel's pink is procedural and can be as pink as it likes** — Oliver asked whether it could +still have a pink halo given the key eats magenta out of PNGs, and yes: `drawBarrel` paints it at +draw time and never goes near the keyer. Three rings plus a pool on the lawn now, because one flat +disc read as a smudge. + ## 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 diff --git a/config.js b/config.js index 34c735c..1740706 100644 --- a/config.js +++ b/config.js @@ -230,9 +230,12 @@ 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 LASER's wedge: aim and release like a serve, and a plane of light widens down the lane + beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear + beamW0: 0.35, // m half-width at the racket... + beamW1: 4.2, // ...and at the far end. It is a CONE: the spread is the point of it + beamT: 0.42, // s the wedge stays drawn + dmgBeam: 3, // hp the beam takes off anything it catches (dmgBomb is 3, and it reads the same) // ---- 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 @@ -249,9 +252,12 @@ export const TUNE = { // ---- the late roster. Three bodies that arrive as the run goes on, filling the gap between the // 2 hp shamblers and the 5 hp brute and then extending past it. Each has one wound skin per hit it // can still take, so the damage is readable without a bar (the rule the boss's missing bar set). + // These are now REAL: the ball path used to one-shot anything it had no branch for, so all three + // died to a single good serve no matter what these said. Retuned once they actually bite — 9 was + // fine as a number nobody felt and is a long grind as a number you do. ballboyHp: 3, // the ball boy ('e') — a stock-sized body that takes three, with two wound skins - bouncerHp: 6, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it - rollerHp: 9, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court + bouncerHp: 5, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it + rollerHp: 7, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court rollerStride: 0.62, // m he covers per lurch frame — longer than the brute's 0.5: a bigger body, // a longer stride, or his feet slide the way the brute's used to // fire: the flame ball no longer just kills what it touches, it starts an epidemic. @@ -422,11 +428,15 @@ export const ZWOUND = { // 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. +// kind 'serve' — arms the next serve: you aim and release it like a normal ball +// kind 'burst' — commits to a braced window that FIRES ITSELF: `len` seconds at `rate` rounds/s +// There is no third kind. A tap-per-round weapon shipped once and Oliver read it as broken — +// "the laser fired only when I tapped, that was confusing" — so a barrel weapon is now always one +// of exactly two things: the normal ball gesture made spectacular, or something that fires on its +// own for a while. Nothing in between. +// The pellet fields (r, dmg, hp, pierce, boom, ignite, arc, trail, col, speed, life) are stamped +// onto the round by firePellet, so every auto-firing weapon is the ONE pellet loop with different +// numbers — `arc` is what turns a flat round into a lobbed one. export const WEAPONS = { bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve', desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] }, @@ -438,24 +448,43 @@ export const WEAPONS = { 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', + // A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every + // shot and dragging its colour behind it. The showiest thing in the game and the one you meet first. + beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6, + r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 3, glow: 1.6, speed: 19, + desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] }, + // "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat + // glowing rounds that throw their own embers, and every one of them lights what it touches. + flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30, + r: 1.15, dmg: 2, hp: 9, speed: 15, life: 0.7, ignite: true, igniteR: 2.6, + trail: 2, col: '#ff8420', glow: 2, spread: 3.1, // life 0.7s x 15 m/s = a ~10 m cone + desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] }, + // The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a + // blue plane that widens down the court and cuts through everything standing in it. + laser: { label: 'LASER', icon: 'w_laser', tin: 6, kind: 'serve', beam: 1, // 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'] }, + col: '#3f74ff', // saturated: a pale blue washed out to grey over the lawn + desc: ['AIM AND RELEASE', 'A WIDENING WALL OF LIGHT'] }, + // Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup + // (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on". + rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 0.85, + r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, trail: 2, speed: 17, + col: '#ffd050', glow: 1.4, + desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] }, +}; +// What a pink barrel can be carrying — the four above, which you cannot win any other way. NOT a +// flat 1-in-4: on that, Oliver "got the laser almost every time" and never once saw the beacon. +// Weighted, gated, and it never hands you the same thing twice running (see pickBarrelWeapon). +// weight relative chance once it is unlocked +// from games finished before it can appear at all +export const BARREL_WEAPONS = { + beacon: { weight: 4, from: 0 }, // the showpiece, and the one you should meet first + flamer: { weight: 3, from: 2 }, + laser: { weight: 2, from: 6 }, + rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three }; -// 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 diff --git a/gameplay.js b/gameplay.js index fb08f1e..656d68d 100644 --- a/gameplay.js +++ b/gameplay.js @@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, res 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 , firePellet , spawnBarrel } from './zombies.js'; +import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. @@ -264,6 +264,15 @@ function hitServe(sv, quality, aim = null) { // 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; const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0; + // THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else + // about the gesture is untouched — the pull, the aim, the release — which is the whole point of it. + if (wid === 'laser' && armed) { + spendSlot(wsel); + fireBeam(ball.vx, ball.vz); + ball.active = false; G.phase = 'over'; G.overT = 0.5; + sv.pose = 'serve'; sv.poseT = 0; + return; + } ball.bomb = wid === 'bomb' && armed; if (ball.bomb) spendSlot(wsel); ball.flame = wid === 'fire' && armed; @@ -878,21 +887,18 @@ function racketAI(rz, dt) { } } -// 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. +// A BURST weapon isn't a ball — committing to one braces you at the baseline for a window (the same +// mode a horde uses) and it fires ITSELF for `len` seconds while your finger only steers. G.mgT is +// the mode timer, which is why the eighteen `!(G.mgT > 0)` guards across five files never changed. +// There was briefly a third kind that fired one round per tap; Oliver read it as the weapon being +// broken, so a barrel weapon is now either this or a serve. Nothing waits for a tap. 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; + if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false; 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.mgT = w.len ?? TUNE.mgPickupLen; + spendSlot(G.weapon); G.mgCool = 0; ball.active = false; G.phase = 'rally'; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; @@ -900,22 +906,7 @@ function tryArmGun() { 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; -} -// slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while -// you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss. export function serveToss() { // A NEW WIND-UP ENDS THE LAST SHOT'S FOLLOW-THROUGH. Without this he latched: hitServe sets // pose 'serve' with poseT 0, that only expires after POSE_TIME, and the pull frame is only drawn @@ -926,6 +917,22 @@ export function serveToss() { if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P); } +// The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction +// and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim +// if there is one and otherwise fires straight up-court. +function tryLaser() { + if (G.mode !== 'splatter' || G.interlude) return false; + const w = WEAPONS[slotId(G.weapon)]; + if (!w || !w.beam || slotStock(G.weapon) <= 0) return false; + const flick = takeFlickAim(); + const dx = flick ? flick.fx : 0, dz = flick ? -Math.abs(flick.fy) : -1; + spendSlot(G.weapon); + fireBeam(dx, dz || -1); + ball.active = false; G.phase = 'over'; G.overT = 0.5; + P.pose = 'serve'; P.poseT = 0; + return true; +} + // release of the slingshot aim: an instant serve to the reticle (quality fixed — placement // is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) export function dragServe(aim) { @@ -975,10 +982,6 @@ 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 @@ -992,7 +995,10 @@ 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 (tryArmGun()) return; // a GUN-like weapon selected: the gesture arms it, no ball + if (tryArmGun()) return; // a burst weapon selected: the gesture arms it, no ball + // ...and a LASER never tosses one either. The ray IS the weapon, so a two-stage toss ritual was + // throwing a ball into the air and then firing something else entirely. + if (tryLaser()) return; 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 77edb27..b08f571 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/input.js b/input.js index 3c589b3..5127346 100644 --- a/input.js +++ b/input.js @@ -47,7 +47,7 @@ export function takeFlickAim(maxAgeMs = 450) { // "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 isGunLike = () => WEAPONS[slotId(G.weapon)]?.kind === 'burst'; // a laser is SERVED, so it keeps the reticle const canAimServe = () => !G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow diff --git a/render.js b/render.js index ea89690..5ec8cb9 100644 --- a/render.js +++ b/render.js @@ -3,7 +3,7 @@ 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 , WEAPONS } from './config.js'; import { P, O, ball, score, crowd, G, court } from './state.js'; -import { predictLanding, previewServe , slotId, slotStock } from './gameplay.js'; +import { predictLanding, previewServe , slotId, slotStock , dragContact } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; // ---------------------------------------------------------------- palette ---- @@ -336,9 +336,20 @@ function drawBarrel(zb) { 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(); + // THE PINK IS ALL HERE, and it can be as pink as it likes: this is light painted at draw time and + // it never goes near the chroma key that eats magenta out of PNGs. Three rings instead of one — + // a wide soft bloom, a bright body, and a white-hot core — because one flat disc at 20 px tall + // read as a smudge on the grass rather than as something GLOWING on a dark court. + const rgb = RGB(UI.pink); + for (const [rf, af] of [[0.62, 0.16], [0.34, 0.34], [0.17, 0.55]]) { + ctx.globalAlpha = af * (0.55 + k * 0.65); + ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(2, p.s * rf * (0.85 + k * 0.35)), 0, 7); ctx.fill(); + } + // ...and a pink pool on the LAWN under it, which is what actually sells "this thing is lit up" + const gp = proj(zb.x, 0, zb.z); + ctx.globalAlpha = 0.16 + k * 0.20; + ctx.beginPath(); ctx.ellipse(gp.x, gp.y, Math.max(3, gp.s * 0.55), Math.max(1.5, gp.s * 0.20), 0, 0, 7); ctx.fill(); ctx.globalAlpha = 1; ctx.restore(); if (img) { @@ -519,7 +530,12 @@ function drawShotBand(a, u0, flying) { if (left <= 0.01) return; // cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to // see where you're pointing, you just need to know it won't kill. - const hex = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; + // The LASER re-paints the band it is about to become: blue, and widening down the court instead + // of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the + // band itself to communicate the shape of the shot. + const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0; + const hex = beamW ? WEAPONS[slotId(G.weapon)].col + : a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; // the slice of arc to draw: everything while aiming, a short streak behind the ball in flight const lo = flying ? Math.max(0, u0 - TAIL) : 0; const hi = flying ? u0 : 1; @@ -554,14 +570,57 @@ function drawShotBand(a, u0, flying) { let prev = proj(head.x, head.y, head.z); for (let i = 1; i <= N; i++) { const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z); - ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay; - ctx.lineWidth = Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1)); + ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay * (beamW ? 1.25 : 1); + // a normal band is brightest at the racket and thins away; the beam does the OPPOSITE, opening + // out toward the far end into the plane it is going to cut + ctx.lineWidth = beamW + ? Math.max(1.5, p.s * (0.05 + 0.42 * (uAt(i)))) + : Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1)); ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke(); prev = p; } ctx.globalAlpha = 1; ctx.lineWidth = 1; } +// The laser's plane on the ground, shared by the aim PREVIEW and the fired shot. `alpha` dims the +// whole thing (the preview is faint, the shot is not) and `from` is the erase front: 0 draws the +// whole wedge, 0.5 draws only its far half, 1 draws nothing. Drawn under the bodies, so the ones +// standing in it are lit from beneath instead of being covered by it. +function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { + const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; + if (from >= 1) return; + const px = -uz, pz = ux; // the lane's perpendicular, on the ground + const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u; + // It LEAVES THE RACKET. Drawn flat on the lawn it read as a shape lying at his feet rather than as + // something he fired; the plane starts at racket height and settles onto the court as it goes out, + // which is what makes the near end look attached to him. + const yAt = u => oy + (0.05 - oy) * Math.min(1, u * 2.2); + const corner = (u, w) => proj(ox + ux * (u * TUNE.beamLen) + px * w, yAt(u), + oz + uz * (u * TUNE.beamLen) + pz * w); + const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1); + const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1); + const rgb = RGB(WEAPONS.laser.col); + // White only right at the near edge; BLUE for the length of it. A gradient that ran white -> faint + // blue read as a pale grey smear on the lawn — the colour is the identity of the shot. + const g = ctx.createLinearGradient(c1.x, c1.y, c4.x, c4.y); + g.addColorStop(0, `rgba(255,255,255,${0.92 * alpha})`); + g.addColorStop(0.14, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.88 * alpha})`); + g.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.46 * alpha})`); + ctx.fillStyle = g; + ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y); ctx.fill(); + // the two burning edges: HAIRLINES. At 2.5px they were heavier than anything else on the court and + // read as a drawn outline rather than as light — "the borders of the laser are quite big". + ctx.strokeStyle = `rgba(210,232,255,${0.42 * alpha})`; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); + ctx.moveTo(c1.x, c1.y); ctx.lineTo(c4.x, c4.y); ctx.stroke(); + // ...and the erase front itself: a bright bar racing away down the lane + if (from > 0) { + ctx.strokeStyle = `rgba(255,255,255,${0.85 * alpha})`; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke(); + } + ctx.lineWidth = 1; +} + // The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the // rest of the band (which happens before the sprite pass, so a nearer body correctly occludes the // arc) hid it completely behind the very thing it was marking. That is why it was never once seen. @@ -1037,7 +1096,22 @@ export function render() { ? (G.aim ? previewServe(G.aim) : G.trail) : null; const flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent const bandU0 = flying ? band.t / band.T : 0; - if (band) drawShotBand(band, bandU0, flying); + // THE LASER PREVIEWS ITSELF. The ray IS the weapon, so an arcing ball-band (and the little ring it + // puts on a body) is describing a projectile that never exists. While the laser is armed and you + // are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just + // not yet lit. + const laserAim = G.aim && WEAPONS[slotId(G.weapon)]?.beam && !flying ? band : null; + if (laserAim) { const c = dragContact(); drawBeamWedge(laserAim.v.vx, laserAim.v.vz, c.x, c.y, c.z, 0.34, 0); } + else if (band) drawShotBand(band, bandU0, flying); + + // THE LASER's fired wedge, ERASING ALONG ITS OWN PATH: the whole plane is there at the flash and + // then it is wiped away from the racket outward, so the shot reads as the energy travelling down + // the lane rather than as a decal fading out where it lay. Oliver: "the release could erase the + // laserplane along the path." + if (G.beam) { + const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies + drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1, 1 - k); + } // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ @@ -1062,7 +1136,7 @@ export function render() { if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying }); nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); - if (band) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark + if (band && !laserAim) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark // server idly bouncing the ball while waiting if (G.phase === 'pretoss') { @@ -1100,7 +1174,8 @@ export function render() { ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill(); continue; } - ctx.fillStyle = pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore + ctx.fillStyle = pt.spark ? pt.spark // a thrown round dragging its colour + : 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); @@ -1251,7 +1326,7 @@ export function render() { 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'); + const gunLike = selW && selW.kind === 'burst'; // the only kind you UNLOAD; a laser is served // 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. diff --git a/state.js b/state.js index d64fa62..e92a98a 100644 --- a/state.js +++ b/state.js @@ -57,7 +57,7 @@ export const G = { // 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 + beam: null, // the laser's live wedge {x, z, ux, uz, t} — drawn, then gone. Not a projectile. 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 404d450..931bd18 100644 --- a/zombies.js +++ b/zombies.js @@ -6,7 +6,7 @@ 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, grantWeapon, weaponModal } from './gameplay.js'; +import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal , dragContact } 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 @@ -27,7 +27,7 @@ let spawnT = 1.5, moanT = 3; // gameplay.restartRun — a restart is in-place now, not a page reload. export function resetZombies() { for (const a of [zombies, gore, piles, parts, pellets, extras, shots]) a.length = 0; - spawnT = 1.5; moanT = 3; mgTick = 0; + spawnT = 1.5; moanT = 3; mgTick = 0; lastBarrel = null; } export function spawnZombie(x, z, variant) { @@ -52,7 +52,7 @@ export function spawnZombie(x, z, variant) { 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.wid = pickBarrelWeapon(); 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... @@ -62,6 +62,25 @@ export function spawnZombie(x, z, variant) { zombies.push(zb); } +let lastBarrel = null; +// WHICH weapon is in the barrel. A flat 1-in-4 gave Oliver the laser "almost every time" and the +// beacon never — four samples is far too few for uniform to feel uniform. Weighted, gated on the +// run so the rocket is genuinely a late-game find, and it never hands you the same thing twice in +// a row, which is the part a player actually notices. +function pickBarrelWeapon() { + const pool = []; + for (const [id, b] of Object.entries(BARREL_WEAPONS)) { + if (G.runGames < b.from) continue; + if (id === lastBarrel) continue; // never twice running... + for (let k = 0; k < b.weight; k++) pool.push(id); + } + // ...unless it is the only thing unlocked, in which case repeating beats handing out nothing + if (!pool.length) return lastBarrel || 'beacon'; + const id = pool[Math.floor(Math.random() * pool.length)]; + lastBarrel = id; + return id; +} + // 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. @@ -250,11 +269,13 @@ function pickVariant() { // to learn — so he can arrive EARLY and thicken the ordinary crowd, which the shambler mix alone // never did. He is also the only advanced body that shows up in a horde (see spawnHordeZombie). if (g >= 4) pool.push('d'); - // The late roster, in the order the crowd should learn it: a stock-sized body that no longer dies - // to two (game 3), then a bigger one (game 8), then something heavier than the brute (game 15). - if (g >= 3) pool.push('e'); - if (g >= 8) pool.push('h'); - if (g >= 15) pool.push('k'); + // The late roster, spread WIDE on purpose. They were at 3/8/15 and Oliver had met the whole cast + // by about game 10 — "let's introduce them more gradually". One new body roughly every seven + // games now, and the ORIGINAL four keep their gates exactly (the curve through ~game 12 was + // already tuned; see the calibration rule in HANDOFF). + if (g >= 5) pool.push('e'); + if (g >= 12) pool.push('h'); + if (g >= 20) pool.push('k'); const nasty = Math.min(TUNE.nastyMax, g * TUNE.nastyPerGame); if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)]; return Math.random() < 0.5 ? 'a' : 'b'; @@ -272,6 +293,32 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va : zb.variant === 'm' ? 0.8 // the machine is a wide low box : 0.55; +// THE LASER. Not a projectile: the serve gesture you already know, and instead of a ball you get a +// widening plane of light down the lane you aimed at, killing everything standing inside the wedge +// at once. Oliver asked for exactly this after a tap-per-round version read as broken — "the normal +// ball logic, but the band is blue and gets way wider at the end, like a blue plane cutting through". +// It goes through wound()/killZombie like everything else, so it cannot no-op on a boss. +export function fireBeam(dirX, dirZ) { + const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; + const c = dragContact(); // it leaves the RACKET, not his feet + G.beam = { x: c.x, y: c.y, z: c.z, ux, uz, t: TUNE.beamT }; + boom(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08; + for (let i = zombies.length - 1; i >= 0; i--) { + const zb = zombies[i]; + if (!zb) continue; + const rx = zb.x - P.x, rz = zb.z - P.z; + const along = rx * ux + rz * uz; // how far down the lane it stands + if (along < 0 || along > TUNE.beamLen) continue; // behind you, or past the end of the beam + const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane + const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen); + if (off > halfW + hitR(zb)) continue; + if (isBoss(zb)) { if (wound(zb, TUNE.dmgBeam)) killZombie(i, true); } + else if (zb.variant === 'o') killZombie(i, true); // it pops a barrel too, which is fair + else if ((zb.hpMax || 0) > 1 && (zb.hp -= TUNE.dmgBeam) > 0) woundBurst(zb); + else splatter(i); + } +} + // 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 @@ -519,13 +566,42 @@ let mgTick = 0; // 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 spread = w.spread ?? (w.life ? TUNE.mgSpread * 2.4 : TUNE.mgSpread); // a short-lived round is a CONE 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, + const pe = { 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 + pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0, + trail: w.trail || 0, col: w.col, glow: w.glow }; + // A LOBBED round: it is thrown, not sprayed. Solved so it comes down about where the crowd is, so + // "it throws itself" reads as aiming rather than as a flat stream that happens to be fat. + if (w.arc) { + const tgt = nearestAhead(); // something to actually throw AT + const dx = (tgt ? tgt.x : P.x + Math.sin(a) * 6) - P.x, dz = (tgt ? tgt.z : P.z - 12) - P.z; + const dist = Math.max(3, Math.hypot(dx, dz)), T = dist / s; + pe.vx = dx / T; pe.vz = dz / T; + pe.g = TUNE.gravity; + pe.vy = (0.35 - pe.y + 0.5 * pe.g * T * T) / T; // lands about knee height on the target + pe.t = T + 0.6; + pe.arc = 1; + } + // The BEACON changes colour every ball it throws. It is the showpiece: you should be able to tell + // it is running from the corner of your eye. + if (w.hue) pe.col = HUES[mgTick % HUES.length]; + pellets.push(pe); + if (++mgTick % 2 === 0 || w.arc) pock(0.25, w.boom ? 420 : w.arc ? 900 : w.ignite ? 300 : 1400); } +const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5']; +// the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past +function nearestAhead() { + let best = null, bd = 1e9; + for (const zb of zombies) { + if (!zb || zb.variant === 'o' || zb.z > P.z - 1.5) continue; + const d = Math.hypot(zb.x - P.x, zb.z - P.z); + if (d < bd) { bd = d; best = zb; } + } + return best; +} + 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 // a FRACTION of full health now — hardcoded `hp === 2` thresholds would leave a late-run boss @@ -649,7 +725,6 @@ export function updateZombies(dt) { // `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen. G.mgT -= dt; // 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 @@ -825,14 +900,6 @@ export function updateZombies(dt) { continue; // soft balls pass through ON PURPOSE, for racketAI to return — that rally IS the // feedback that it wasn't hard enough, so no clank here; see HANDOFF } - if (zb.variant === 'g' || zb.variant === 'm') { // brute AND the bolted-down machine: soak - if ((ball.pow || 0) >= TUNE.zSplatQ) { // splat-quality hits, never stagger back - zb.hp--; splat(0.5); pock(0.6, zb.variant === 'm' ? 700 : 220); // machine rings, meat thuds - ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; - if (zb.hp <= 0) { splatter(i); continue; } // timber - } else { pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; } // soft ball bounces off - continue; - } if (zb.variant === 's' && ball.vz < 0 && !(zb.shieldT > 0)) { // chair up, ball at its face: BLOCKED zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens zb.z -= TUNE.zPushback * 0.5; @@ -841,13 +908,24 @@ export function updateZombies(dt) { continue; } if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts - if (zb.variant === 'a' || zb.variant === 'b') { // classic shamblers soak a splat-quality wound before they drop + // ANY multi-hit body soaks, and hp is the ONLY thing that says how many. This used to be a + // chain of per-variant branches — brute+machine, then shamblers — and every body NOT named + // in it fell through to "splat-quality kills outright" below. That silently one-shot the + // steward (5 hp, commented "a brute's worth of hp") from the day he shipped, and then the + // ball boy, bouncer and roller man the moment they existed: Oliver's "the bigger guys were + // way too easy to kill". Pellets and melee always read hp properly; only the BALL skipped it. + if ((zb.hpMax || 0) > 1) { + const meat = zb.variant !== 'm'; // the machine rings and does not bleed + const heavy = 'gmhk'.includes(zb.variant); // a heavy body is not shoved back by a ball if ((ball.pow || 0) >= TUNE.zSplatQ) { - if (--zb.hp <= 0) { splatter(i); continue; } // the second splat is the killing blow - woundBurst(zb); splat(0.5); pock(0.6, 280); // first splat: a bloody wound, not a death - zb.z -= TUNE.zPushback * 0.5; ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound + if (--zb.hp <= 0) { splatter(i); continue; } // the killing blow + if (meat) woundBurst(zb); // a bloody wound, not a death + splat(0.5); pock(0.6, meat ? (heavy ? 220 : 280) : 700); // meat thuds, machine rings + if (!heavy) zb.z -= TUNE.zPushback * 0.5; + ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound } - zb.z -= TUNE.zPushback; pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; // soft ball just staggers + if (!heavy) zb.z -= TUNE.zPushback; // soft ball staggers the light ones, bounces off the rest + pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; } // chair-down shield zombies (and anything hit from behind) obey the normal rules: if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; } @@ -860,10 +938,23 @@ export function updateZombies(dt) { if (zb.z >= COURT.L + 0.7) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) } - for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills + for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc const pe = pellets[i]; pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt; - let hit = false; + let hit = false, landed = false; + if (pe.arc) { // a thrown round: it falls, and landing is a hit + pe.vy -= pe.g * dt; pe.y += pe.vy * dt; + if (pe.y <= 0.05) { pe.y = 0.05; landed = true; } + } + // a colour dragged behind it — the beacon and the rocket are supposed to be seen from across + // the court, and this is what makes a lobbed round read as a THROWN object rather than a dot + if (pe.trail && parts.length < 220) + for (let k = 0; k < pe.trail; k++) parts.push({ + x: pe.x + (Math.random() * 2 - 1) * 0.12, y: pe.y + (Math.random() * 2 - 1) * 0.12, + z: pe.z + (Math.random() * 2 - 1) * 0.12, + vx: (Math.random() * 2 - 1) * 0.5, vy: (Math.random() * 2 - 1) * 0.5 + 0.3, + vz: (Math.random() * 2 - 1) * 0.5, + t: 0.18 + Math.random() * 0.22, spark: pe.col || '#fff' }); for (let j = zombies.length - 1; j >= 0; j--) { const zb = zombies[j]; if (!zb) continue; @@ -882,8 +973,16 @@ export function updateZombies(dt) { if (hit) break; } } + // A thrown round that reaches the ground goes off THERE, whether or not it touched a body — + // "like a bomb, the projectile is a rocket". Without this a rocket that missed simply expired. + if (!hit && landed) { + if (pe.boom) explode(pe.x, pe.z); + else if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); + hit = true; + } if (hit || pe.t <= 0) pellets.splice(i, 1); } + if (G.beam) { G.beam.t -= dt; if (G.beam.t <= 0) G.beam = null; } // the laser's wedge fades and is gone // ---- INCOMING hostile balls (ball machine, chapter boss volleys). Three outcomes, in this order: // a timed swing inside the volley window RETURNS it as your own ball flying up-court, the panic