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
+56
@@ -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
|
- **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.
|
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)
|
## 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
|
The second half of the same ask. **The rack no longer has fixed slots.** It used to be five hardcoded
|
||||||
|
|||||||
@@ -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."
|
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
|
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
|
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
|
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
|
||||||
// closes on its own. Running the magazine out ends it earlier, which is the
|
beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear
|
||||||
// normal way; this only stops a forgotten rocket freezing the serve loop.
|
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
|
// ---- 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)
|
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
|
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
|
// ---- 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
|
// 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).
|
// 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
|
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
|
bouncerHp: 5, // 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
|
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,
|
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
|
// 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.
|
// 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).
|
// 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
|
// 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.
|
// 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 'serve' — arms the next serve: you aim and release it like a normal ball
|
||||||
// kind 'burst' — commits to a braced auto-fire window: `len` seconds at `rate` rounds/s
|
// kind 'burst' — commits to a braced window that FIRES ITSELF: `len` seconds at `rate` rounds/s
|
||||||
// kind 'shot' — same braced stance, but each TAP fires one and `tin` is the magazine
|
// There is no third kind. A tap-per-round weapon shipped once and Oliver read it as broken —
|
||||||
// The pellet fields (r, dmg, pierce, boom, ignite, col, speed) are stamped onto the round by
|
// "the laser fired only when I tapped, that was confusing" — so a barrel weapon is now always one
|
||||||
// firePellet, so all four new weapons are the ONE existing pellet loop with different numbers.
|
// 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 = {
|
export const WEAPONS = {
|
||||||
bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve',
|
bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve',
|
||||||
desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] },
|
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,
|
r: 0.5, dmg: 0.34, hp: 2, speed: 26,
|
||||||
desc: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'] },
|
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.
|
// ---- 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,
|
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
|
||||||
r: 1.1, dmg: 3, hp: 9, speed: 17, col: '#7fefff', glow: 1,
|
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
|
||||||
desc: ['A SLOW BEAT OF BIG', 'GLOWING SHELLS'] },
|
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6,
|
||||||
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 5, rate: 16,
|
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 3, glow: 1.6, speed: 19,
|
||||||
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: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
|
||||||
desc: ['A CONE OF FIRE', 'AND THE FIRE SPREADS'] },
|
// "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat
|
||||||
laser: { label: 'LASER', icon: 'w_laser', tin: 10, kind: 'shot',
|
// 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
|
// 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
|
// (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.
|
// 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,
|
col: '#3f74ff', // saturated: a pale blue washed out to grey over the lawn
|
||||||
desc: ['TEN SHOTS, AND EACH ONE', 'GOES THROUGH THE WHOLE LANE'] },
|
desc: ['AIM AND RELEASE', 'A WIDENING WALL OF LIGHT'] },
|
||||||
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 3, kind: 'shot',
|
// Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup
|
||||||
r: 0.6, dmg: 3, hp: 9, speed: 22, boom: true, col: '#ffd050', glow: 1,
|
// (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on".
|
||||||
desc: ['THREE ROCKETS', 'EACH ONE TAKES THE CROWD'] },
|
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 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
|
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
|
||||||
|
|||||||
+38
-32
@@ -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 { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
|
||||||
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.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 { 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 ----
|
// ------------------------------------------------------------- ballistics ----
|
||||||
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
|
// 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.
|
// 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 wsel = G.mode === 'splatter' ? G.weapon : 0;
|
||||||
const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 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;
|
ball.bomb = wid === 'bomb' && armed;
|
||||||
if (ball.bomb) spendSlot(wsel);
|
if (ball.bomb) spendSlot(wsel);
|
||||||
ball.flame = wid === 'fire' && armed;
|
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
|
// A BURST weapon isn't a ball — committing to one braces you at the baseline for a window (the same
|
||||||
// same mode a horde uses) and the serve gesture spends it instead of serving. Generalised from
|
// mode a horde uses) and it fires ITSELF for `len` seconds while your finger only steers. G.mgT is
|
||||||
// tryGunBurst: 'burst' weapons auto-fire at their own rate for `len` seconds, 'shot' weapons hold
|
// the mode timer, which is why the eighteen `!(G.mgT > 0)` guards across five files never changed.
|
||||||
// the same stance but fire one per TAP until the magazine is out. G.mgT stays the mode timer for
|
// There was briefly a third kind that fired one round per tap; Oliver read it as the weapon being
|
||||||
// BOTH, which is why the eighteen `!(G.mgT > 0)` guards across five files did not have to change.
|
// broken, so a barrel weapon is now either this or a serve. Nothing waits for a tap.
|
||||||
function tryArmGun() {
|
function tryArmGun() {
|
||||||
if (G.mode !== 'splatter') return false;
|
if (G.mode !== 'splatter') return false;
|
||||||
const id = slotId(G.weapon), w = WEAPONS[id];
|
const id = slotId(G.weapon), w = WEAPONS[id];
|
||||||
if (!w || (w.kind !== 'burst' && w.kind !== 'shot') || slotStock(G.weapon) <= 0) return false;
|
if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false;
|
||||||
const slot = G.weapon;
|
|
||||||
G.gunId = id;
|
G.gunId = id;
|
||||||
if (w.kind === 'burst') { G.mgT = w.len ?? TUNE.mgPickupLen; G.gunShots = 0; spendSlot(slot); }
|
G.mgT = w.len ?? TUNE.mgPickupLen;
|
||||||
// a 'shot' weapon spends per ROUND, not on commit, so the magazine IS the stock: hold the stance
|
spendSlot(G.weapon);
|
||||||
// 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;
|
G.mgCool = 0;
|
||||||
ball.active = false; G.phase = 'rally';
|
ball.active = false; G.phase = 'rally';
|
||||||
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
|
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
|
||||||
@@ -900,22 +906,7 @@ function tryArmGun() {
|
|||||||
G.flash = { text: w.label, t: 1.0 };
|
G.flash = { text: w.label, t: 1.0 };
|
||||||
return true;
|
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() {
|
export function serveToss() {
|
||||||
// A NEW WIND-UP ENDS THE LAST SHOT'S FOLLOW-THROUGH. Without this he latched: hitServe sets
|
// 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
|
// 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);
|
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
|
// 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)
|
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
|
||||||
export function dragServe(aim) {
|
export function dragServe(aim) {
|
||||||
@@ -975,10 +982,6 @@ export function keyPressed(code) {
|
|||||||
swish(0.3);
|
swish(0.3);
|
||||||
return;
|
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.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.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
|
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 (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check
|
||||||
}
|
}
|
||||||
if (G.phase === 'pretoss' && score.server === 'P') {
|
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)
|
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
|
||||||
}
|
}
|
||||||
if (G.phase === 'toss' && score.server === 'P') {
|
if (G.phase === 'toss' && score.server === 'P') {
|
||||||
|
|||||||
+1
-1
@@ -179,6 +179,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=98"></script>
|
<script type="module" src="main.js?v=99"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -47,7 +47,7 @@ export function takeFlickAim(maxAgeMs = 450) {
|
|||||||
// "drag walks the player" mode between shots was invisible state and read as a bug.
|
// "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
|
// 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.
|
// 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 = () =>
|
const canAimServe = () =>
|
||||||
!G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow
|
!G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { cv, ctx } from './dom.js';
|
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 { 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 { 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';
|
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------- palette ----
|
// ---------------------------------------------------------------- palette ----
|
||||||
@@ -336,9 +336,20 @@ function drawBarrel(zb) {
|
|||||||
const p = proj(zb.x, h * 0.5, zb.z);
|
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
|
const k = 0.55 + 0.45 * Math.sin((zb.roll || 0) * 3.1); // the pulse rides the roll, not a clock
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = 0.20 + k * 0.30; // the halo
|
// THE PINK IS ALL HERE, and it can be as pink as it likes: this is light painted at draw time and
|
||||||
ctx.fillStyle = UI.pink;
|
// it never goes near the chroma key that eats magenta out of PNGs. Three rings instead of one —
|
||||||
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();
|
// 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.globalAlpha = 1;
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
if (img) {
|
if (img) {
|
||||||
@@ -519,7 +530,12 @@ function drawShotBand(a, u0, flying) {
|
|||||||
if (left <= 0.01) return;
|
if (left <= 0.01) return;
|
||||||
// cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to
|
// 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.
|
// 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
|
// 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 lo = flying ? Math.max(0, u0 - TAIL) : 0;
|
||||||
const hi = flying ? u0 : 1;
|
const hi = flying ? u0 : 1;
|
||||||
@@ -554,14 +570,57 @@ function drawShotBand(a, u0, flying) {
|
|||||||
let prev = proj(head.x, head.y, head.z);
|
let prev = proj(head.x, head.y, head.z);
|
||||||
for (let i = 1; i <= N; i++) {
|
for (let i = 1; i <= N; i++) {
|
||||||
const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z);
|
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.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay * (beamW ? 1.25 : 1);
|
||||||
ctx.lineWidth = Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 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();
|
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
||||||
prev = p;
|
prev = p;
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1; ctx.lineWidth = 1;
|
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
|
// 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
|
// 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.
|
// 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;
|
? (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 flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent
|
||||||
const bandU0 = flying ? band.t / band.T : 0;
|
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
|
// far-side drawables in z order (far → near), then the net, then the near side
|
||||||
const farSide = [
|
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
|
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.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying });
|
||||||
nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
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
|
// server idly bouncing the ball while waiting
|
||||||
if (G.phase === 'pretoss') {
|
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();
|
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
|
||||||
continue;
|
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.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
|
||||||
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
|
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
|
||||||
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
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';
|
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`,
|
// "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
|
// 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
|
// 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
|
// !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.
|
// to aim, which is the one thing that does nothing there.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const G = {
|
|||||||
// and it doubles as the window a tap-fired laser or rocket is held for)
|
// 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
|
mgCool: 0, // s until the next pellet in that burst
|
||||||
gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour
|
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
|
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
|
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
|
afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves
|
||||||
|
|||||||
+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 { 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 { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||||
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.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 zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
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.
|
// gameplay.restartRun — a restart is in-place now, not a page reload.
|
||||||
export function resetZombies() {
|
export function resetZombies() {
|
||||||
for (const a of [zombies, gore, piles, parts, pellets, extras, shots]) a.length = 0;
|
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) {
|
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 ('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
|
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.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;
|
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...
|
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);
|
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
|
// 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
|
// 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.
|
// 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
|
// 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).
|
// never did. He is also the only advanced body that shows up in a horde (see spawnHordeZombie).
|
||||||
if (g >= 4) pool.push('d');
|
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
|
// The late roster, spread WIDE on purpose. They were at 3/8/15 and Oliver had met the whole cast
|
||||||
// to two (game 3), then a bigger one (game 8), then something heavier than the brute (game 15).
|
// by about game 10 — "let's introduce them more gradually". One new body roughly every seven
|
||||||
if (g >= 3) pool.push('e');
|
// games now, and the ORIGINAL four keep their gates exactly (the curve through ~game 12 was
|
||||||
if (g >= 8) pool.push('h');
|
// already tuned; see the calibration rule in HANDOFF).
|
||||||
if (g >= 15) pool.push('k');
|
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);
|
const nasty = Math.min(TUNE.nastyMax, g * TUNE.nastyPerGame);
|
||||||
if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)];
|
if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)];
|
||||||
return Math.random() < 0.5 ? 'a' : 'b';
|
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
|
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
|
||||||
: 0.55;
|
: 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 —
|
// 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.
|
// 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
|
// 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.
|
// 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.
|
// Called with no argument by the HORDE, which is the plain machine gun.
|
||||||
export function firePellet(w = WEAPONS.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)
|
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,
|
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 });
|
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0,
|
||||||
if (++mgTick % 2 === 0) pock(0.25, w.boom ? 420 : w.pierce ? 2100 : 1400); // every 2nd round — at 12+/s every round buzzes
|
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
|
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
|
// 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
|
// 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 > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
|
||||||
G.mgT -= dt;
|
G.mgT -= dt;
|
||||||
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
|
// 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;
|
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.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.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
|
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
|
// 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
|
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.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens
|
||||||
zb.z -= TUNE.zPushback * 0.5;
|
zb.z -= TUNE.zPushback * 0.5;
|
||||||
@@ -841,13 +908,24 @@ export function updateZombies(dt) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts
|
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 ((ball.pow || 0) >= TUNE.zSplatQ) {
|
||||||
if (--zb.hp <= 0) { splatter(i); continue; } // the second splat is the killing blow
|
if (--zb.hp <= 0) { splatter(i); continue; } // the killing blow
|
||||||
woundBurst(zb); splat(0.5); pock(0.6, 280); // first splat: a bloody wound, not a death
|
if (meat) woundBurst(zb); // 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
|
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:
|
// chair-down shield zombies (and anything hit from behind) obey the normal rules:
|
||||||
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
|
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)
|
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];
|
const pe = pellets[i];
|
||||||
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
|
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--) {
|
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||||
const zb = zombies[j];
|
const zb = zombies[j];
|
||||||
if (!zb) continue;
|
if (!zb) continue;
|
||||||
@@ -882,8 +973,16 @@ export function updateZombies(dt) {
|
|||||||
if (hit) break;
|
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 (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:
|
// ---- 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
|
// 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