Make the pink weapons throw themselves, and let the big bodies soak
Oliver's playtest of the barrel weapons. Four notes, all four real defects. "The bigger guys were way too easy to kill at the beginning." A bug, and an old one. The ball-vs-body chain handled boss, g/m, s, f and a/b, and everything NOT named in it fell through to "splat-quality kills outright" -- so the STEWARD has died to one serve since the day he shipped with stewardHp 5 and a comment calling it a brute's worth of hp, and the three new bodies inherited that the moment they existed. Pellets and melee always read hp; only the ball skipped it. One generic hpMax > 1 branch now, and measured after: shambler 2, steward 5, brute 5, ball boy 3, bouncer 5, roller man 7 -- each exactly its hp. "I have seen all of them by round 10." The new three move 3/8/15 -> 5/12/20. The original four keep their gates: that curve was already tuned through game 12. "The laser fired only when I tapped, that was confusing." The whole tap-per-round kind is gone. A barrel weapon is now either the normal ball gesture made spectacular or something that fires itself -- nothing waits for a tap. - laser is a serve, and the RAY is the weapon: no ball anywhere in it. The preview IS the wedge, it leaves the RACKET rather than lying on the grass, the release erases the plane along its own path behind a bright front, and the edges are hairlines instead of the heaviest lines on the court. - beacon and rocket are LOBBED bursts -- `arc` 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, embers. "I got the laser almost every time." A flat 1-in-4 over four items does not feel uniform. BARREL_WEAPONS carries weight and a `from` gate now, never repeats twice running, and the beacon is what you meet first. Measured: laser 16-28%, rocket unlocked at game 12 so it is genuinely a late find. And the barrel keeps its pink halo -- that was always procedural, painted by drawBarrel, and never went near the chroma key. Three rings and a pool on the lawn now, because one flat disc read as a smudge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25f915f236
commit
282f5145a9
8 files changed
+363
-98
No files matched your search
+127
-28
@@ -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
|
||||
|
||||
Reference in new issue
Block a user