Cut them with the laser plane instead of painting over them

The beam is a real plane now, holding TUNE.beamY (1.15 m) all the way out instead
of sloping to the ground. That one change is what makes the rest possible: sloping,
it lay flat on the grass across the whole far half, so there was nothing to cut
exactly where the crowd stands.

One flat translucent triangle, apex at the player's CHEST, opening out down the
court with the alpha falling away. It was four nested wedges composited 'lighter'
and that layering WAS the banding -- additive light blowing out to white where the
layers overlapped. Flat alpha also means a body under the plane is tinted rather
than brightened, which is what lets the cut read at all.

Bodies are sliced by it: the part above the plane is redrawn clipped so it sits on
top, the part below stays tinted underneath, and it happens while AIMING as well as
on the shot. Measured, the plane takes a shambler at 64% of its height, a brute at
43% and the roller man at 36% -- bigger bodies visibly take it lower.

The cut line follows the SPRITE rather than a rectangle. A fillRect spanned the
body's bounding width, so it crossed the gaps between the legs and under a held-out
arm and read as a bar lying behind him; the line is the sprite itself, redrawn
clipped to a thin band and flattened to one colour by ctx.filter. It thins with
distance on s squared, because this camera's scale only spans about 1.5x across the
court and a linear falloff was invisible.

Aiming a filter at a specific colour turned out to be a measurement, not a guess:
brightness(0) invert(1) goes to WHITE, and sepia adds almost no chroma to white, so
the first chain rendered a grey. Leaving a mid grey for sepia to tint lands on
(32,154,255) against the laser icon's (29,153,232). The probe fills a rect and reads
the pixel back.

The pass is bounded to the eight nearest bodies above 14 px, because it draws each
one twice more through a filter and runs every frame you are aiming. 0.27 ms/frame
with 20 bodies on desktop, against a 16.7 ms budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 15:48:08 +02:00
1 parent da9cca2f23
commit 337def8526
5 files changed
+176 -60

No files matched your search

+35
View File
@@ -418,6 +418,41 @@ free to be the KILL range instead of a number tuned to one camera.
**Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket, **Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket,
`whoosh`), otherwise the gun's every-other-round tick. `whoosh`), otherwise the gun's every-other-round tick.
## The laser plane, and cutting bodies with it (2026-08-03)
Settled with Oliver in discussion, then built. **The beam is a real plane at `TUNE.beamY` (1.15 m)**
that holds that height all the way out instead of sloping to the ground — that single change is what
makes the rest possible. Sloping, it lay flat on the grass across the whole far half, so there was
nothing for it to cut exactly where the crowd stands.
- **One flat translucent triangle**, apex at the player's CHEST (`beamOriginY` 1.25 — emitted from
the racket head at 2.2 m the apex hung in the air and read as detached), opening out down the
court, alpha falling with distance. It was four nested wedges composited `lighter`, and that
layering WAS the banding: additive light blowing out to white where layers overlapped. Flat alpha
also means a body under the plane is TINTED rather than brightened, which is what makes the cut read.
- **Bodies are sliced by it.** `drawBeamCuts` runs after the beam: for each body in the cone, the
part above `proj(zb.x, beamY, zb.z).y` is redrawn clipped, so it sits ON TOP of the plane while the
part below stays tinted underneath. `drawZombie` mutates nothing, so drawing it twice is safe.
Measured: the plane takes a shambler at 64% of its height (sternum), a brute at 43% (belly), the
roller man at 36% (thigh) — bigger bodies visibly take it lower.
- **The cut line follows the SPRITE, not a rectangle.** A `fillRect` spanned the body's bounding
width and so crossed the empty gaps between the legs and under a held-out arm, reading as a bar
lying behind him. The line is the sprite itself, redrawn clipped to a thin band and recoloured flat
by `ctx.filter`. Thickness scales with `h * s²` so it is a hairline at the ads and a real band at
your feet — the camera's scale only spans ~1.5x across the court, so a linear falloff was invisible.
- **Getting a filter to a specific colour is a MEASUREMENT, not a guess.** The chain has to leave a
MID grey for `sepia` to tint: `brightness(0) invert(1)` goes to white, and sepia adds almost no
chroma to white while saturating a colourless pixel does nothing — the first attempt rendered
#a0b4c3, a grey. `brightness(0) invert(0.45) sepia(1) saturate(1600%) hue-rotate(185deg)` renders
(32,154,255) against the laser icon's (29,153,232). Probe candidate chains by filling a rect and
reading the pixel back; do not eyeball it.
- **`HAS_FILTER` is feature-detected** and the line falls back to a narrow bar without it (older
WebKit has no `ctx.filter`).
- **The cut pass is BOUNDED** (`CUT_MAX` 8 nearest bodies, `CUT_MIN_PX` 14): it draws each body twice
more and the second pass runs through a filter, which is slow on iOS, and it runs every frame you
are AIMING rather than only on the shot. Measured on desktop: 0.27 ms/frame with 20 bodies against
a 16.7 ms budget.
## TEST MODE — triple-tap the scoreboard ## TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the A sandbox for trying new content without playing twenty games to reach it. Three taps on the
+9
View File
@@ -243,6 +243,15 @@ export const TUNE = {
beamLen: 34, // m it reaches. Was 26, which stopped at the far baseline and read as the beam beamLen: 34, // m it reaches. Was 26, which stopped at the far baseline and read as the beam
// being CUT OFF in mid-court; the player stands at z~24, so this carries it past // being CUT OFF in mid-court; the player stands at z~24, so this carries it past
// the baseline and up into the courtside ads, which is where light should stop. // the baseline and up into the courtside ads, which is where light should stop.
beamY: 1.15, // m — the height the plane HOLDS down the court, and the line it cuts bodies at.
// It used to slope to the ground by about 20 m out, which meant that across the
// whole far half it lay flat on the grass: nothing to cut, and the split would
// have been invisible exactly where the crowd stands. Chest on a shambler, belly
// on a brute, thigh on the roller man — so a bigger body visibly takes it lower.
beamOriginY: 1.25,// m — it leaves the PLAYER at chest height, not the racket head. Emitted from
// the racket (2.2 m) the apex hung in the air above him and read as detached;
// out of his chest, level with the plane it becomes, it reads as HIS shot.
beamRise: 0.14, // fraction of the length it takes to settle from the origin to that height
beamW0: 0.06, // m half-width at the racket — a POINT. At 0.35 it left the racket as a 0.7 m beamW0: 0.06, // m half-width at the racket — a POINT. At 0.35 it left the racket as a 0.7 m
// bar hanging in the air rather than as something emitted from the strings. // bar hanging in the air rather than as something emitted from the strings.
beamW1: 5.0, // ...and at the far end. It is a CONE: the spread is the point of it beamW1: 5.0, // ...and at the far end. It is a CONE: the spread is the point of it
+1 -1
View File
@@ -179,6 +179,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=103"></script> <script type="module" src="main.js?v=104"></script>
</body> </body>
</html> </html>
+127 -56
View File
@@ -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 , dragContact } from './gameplay.js'; import { predictLanding, previewServe, slotId, slotStock } 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 ----
@@ -586,64 +586,130 @@ function drawShotBand(a, u0, flying) {
// whole thing (the preview is faint, the shot is not) and `from` is the erase front: 0 draws 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 // 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. // standing in it are lit from beneath instead of being covered by it.
// The laser's plane: ONE flat translucent triangle, apex at the strings, opening out down the court
// and fading with distance. It was four nested wedges composited `lighter` — and that layering WAS
// the banding, additive light blowing out to white where the layers overlapped. Flat alpha also
// means a body standing under it is TINTED by it rather than brightened, which is what makes the
// split below read. Returns the length it drew, so the split can use the same geometry.
function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; 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 px = -uz, pz = ux; // the lane's perpendicular, on the ground
// How far it runs is solved, not tuned: walk out along the lane until the plane's own projection
// reaches the middle of the ad strip. Solving it (rather than pinning the corners afterwards)
// keeps the drawn triangle a TRUE projection of the plane, which is what lets the per-body cut
// line below agree with the edge you can see.
const target = proj(0, 0, -6).y - AD_H / 2;
let lo = 4, hi = 70;
for (let k = 0; k < 18; k++) {
const m = (lo + hi) / 2;
if (proj(ox + ux * m, TUNE.beamY, oz + uz * m).y > target) lo = m; else hi = m;
}
const L = (lo + hi) / 2;
if (from >= 1) return L;
// it leaves the racket high and settles to the cutting height within the first stretch
const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise);
const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u; const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u;
// It LEAVES THE RACKET as a POINT and opens out. Drawn flat on the lawn it read as a shape lying const corner = (u, w) => proj(ox + ux * (u * L) + px * w, yAt(u), oz + uz * (u * L) + pz * w);
// at his feet; starting wide it read as a bar hanging in mid-air. Narrow at the strings, settling const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1);
// onto the court as it goes, is what makes the near end look attached to him. const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
const yAt = u => oy + (0.05 - oy) * Math.min(1, u * 1.7); const [r, g_, b] = RGB(WEAPONS.laser.col);
const corner = (u, w) => proj(ox + ux * (u * TUNE.beamLen) + px * w, yAt(u), // DEPTH: strongest at the strings, thinning away down the court. Never to zero, though — a stop
oz + uz * (u * TUNE.beamLen) + pz * w); // at alpha 0 made the beam invisible before its own far edge, so the visible end sat short of
const u0 = Math.max(0, from); // where it was drawn and moving the geometry appeared to change nothing at all.
const rgb = RGB(WEAPONS.laser.col); const grad = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2,
// WHERE THE FAR EDGE SITS is a screen-space decision, not a world one. Run out to beamLen on the (c3.x + c4.x) / 2, (c3.y + c4.y) / 2);
// ground and it lands on the ads' TOP line; the beam should die at their MIDDLE. Pinning the far grad.addColorStop(0, `rgba(${r},${g_},${b},${0.34 * alpha})`);
// corners' y here keeps that exact in both orientations and leaves beamLen free to be the KILL grad.addColorStop(0.45, `rgba(${r},${g_},${b},${0.20 * alpha})`);
// range rather than a number tuned to one camera. grad.addColorStop(1, `rgba(${r},${g_},${b},${0.10 * alpha})`);
const farY = proj(0, 0, -6).y - AD_H / 2; // proj(0,0,-6) is the same line the ad strip sits on ctx.fillStyle = grad;
// THREE NESTED WEDGES: a wide violet-blue bloom, a cyan body, and a white-hot core down the 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();
// middle. One flat quad with a gradient along its length was a coloured shape; layers that share if (from > 0) { // the erase front: a bright bar racing away down the lane
// an axis and get brighter toward the centre are what read as sci-fi light. ctx.strokeStyle = `rgba(255,255,255,${0.85 * alpha})`; ctx.lineWidth = 1.5;
const layers = [ ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke();
{ k: 1.00, col: [90, 60, 255], a: 0.30 }, // outer bloom, violet
{ k: 0.62, col: rgb, a: 0.50 }, // the body, in the weapon's blue
{ k: 0.28, col: [130, 235, 255], a: 0.62 }, // inner, cyan
{ k: 0.09, col: [255, 255, 255], a: 0.92 }, // the core
];
ctx.save();
ctx.globalCompositeOperation = 'lighter'; // light ADDS — this is the whole sci-fi trick
for (const L of layers) {
const w0 = wAt(u0) * L.k, w1 = wAt(1) * L.k;
const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
c3.y = c4.y = farY; // ...the x's keep their perspective, the y is pinned
// It must NOT fade to nothing along its length. The far corners are pinned to the ads' middle,
// but a gradient ending at alpha 0 made the beam INVISIBLE for its last stretch — so what you saw
// ended well short of where it was drawn, and no amount of moving the geometry changed it. The
// far end stays lit, so the visible edge and the pinned edge are the same line.
const g = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2,
(c3.x + c4.x) / 2, (c3.y + c4.y) / 2);
// DEPTH: brightest at the strings, thinning away down the court, because that is what a beam
// travelling away from you does. It must NOT reach zero though — a stop at alpha 0 made the beam
// invisible before its far edge, so the visible end sat short of the pinned one and moving the
// geometry appeared to change nothing at all.
g.addColorStop(0, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * alpha})`);
g.addColorStop(0.45, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * 0.58 * alpha})`);
g.addColorStop(1, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * 0.32 * 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 erase front: a bright bar racing away down the lane
if (from > 0) {
const wf = wAt(u0);
const f1 = corner(u0, -wf), f2 = corner(u0, wf);
ctx.strokeStyle = `rgba(255,255,255,${0.9 * alpha})`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(f1.x, f1.y); ctx.lineTo(f2.x, f2.y); ctx.stroke();
}
ctx.restore();
ctx.lineWidth = 1; ctx.lineWidth = 1;
return L;
}
// The cut line: BLUEISH and faint. Near-white and bold, it read as a HUD marker stamped on the
// sprite rather than as light crossing a body — it has to whisper, not label.
const CUT_COL = 'rgba(29,153,232,0.55)'; // the laser icon's blue, for the no-filter fallback
const CUT_MAX = 8; // bodies that get a cut line in one frame (nearest first)
const CUT_MIN_PX = 14; // ...and only if it is drawn at least this tall
// canvas filters are what let the cut line follow the sprite's own silhouette; older WebKit has no
// ctx.filter at all, and there the line falls back to a narrow bar (see drawBeamCuts)
const HAS_FILTER = (() => {
try { const c = document.createElement('canvas').getContext('2d'); c.filter = 'invert(1)'; return c.filter === 'invert(1)'; }
catch { return false; }
})();
// THE CUT. The beam is a plane at TUNE.beamY, so a body standing in it is sliced at the height that
// plane passes through it: everything below stays where it was drawn (and is tinted by the
// translucent beam laid over it), and everything above is redrawn on TOP of the beam, clipped.
// drawZombie is a pure draw — it mutates nothing — so calling it a second time is safe.
function drawBeamCuts(dirX, dirZ, ox, oz, L) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
// BOUNDED, because this is the one hot path in the renderer that costs real time: each body in the
// beam is drawn twice more, and the second pass runs through a canvas FILTER, which is slow on
// iOS. It runs every frame you are aiming, not just on the shot. Nearest first, capped, and tiny
// far-away bodies are skipped — the cut is unreadable at that size anyway.
let budget = CUT_MAX;
for (const zb of [...zombies].sort((a, b) => b.z - a.z)) {
if (budget <= 0) break;
if (!zb) continue;
const rx = zb.x - ox, rz = zb.z - oz;
const along = rx * ux + rz * uz;
if (along < 0.5 || along > L) continue; // behind the racket, or past the end
const off = Math.abs(rx * uz - rz * ux);
const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L);
if (off > halfW + 0.8) continue;
// whole device pixels: a clip edge on a fractional y crawls under nearest-neighbour scaling
const cutY = Math.round(proj(zb.x, TUNE.beamY, zb.z).y);
ctx.save();
ctx.beginPath(); ctx.rect(0, 0, W, cutY); ctx.clip();
drawZombie(zb);
ctx.restore();
// ...and the CUT ITSELF, solid, straight across the body at that height, so there is no reading
// required: this is the line it comes apart on. Width is taken from the body's own drawn size
// (a sprite is about 0.6 as wide as it is tall), so a roller man gets a long one and a ball boy
// a short one, and it never runs past the silhouette into the grass.
const gp = proj(zb.x, 0, zb.z), h = zombieH(zb);
// only if the plane actually passes THROUGH it: a body the beam clears over the head of, or
// passes under the feet of, gets no line
if (cutY < proj(zb.x, h, zb.z).y || cutY > gp.y) continue;
// THE LINE IS DRAWN THROUGH THE SPRITE, not across a rectangle. A plain fillRect spanned the
// body's whole bounding width, so it also crossed the empty gaps — between the legs, under a
// held-out arm — and read as a bar lying behind him. Redrawing the sprite clipped to a one-pixel
// band, recoloured to flat blue by a canvas filter, puts the line on the BODY and nowhere else.
// THICKNESS FOLLOWS PERSPECTIVE: a share of the body's own drawn height, so a shambler at the
// far baseline gets a hairline and one at your feet gets a real band. A fixed 1 px was the same
// weight on a 3.2 m roller man in front of you as on a body by the ads.
// ...squared, because this camera's scale only spans about 1.5x across the whole court and a
// linear falloff was barely a pixel of difference end to end
const bandH = Math.max(0.3, h * gp.s * gp.s * 0.0009);
if (h * gp.s < CUT_MIN_PX) continue; // too small on screen for a line to say anything
budget--;
ctx.save();
ctx.beginPath(); ctx.rect(0, cutY - bandH / 2, W, bandH); ctx.clip();
if (HAS_FILTER) {
// black -> white keeps the alpha and flattens the art; the sepia/saturate/hue chain turns that
// white into one solid blue, so every lit pixel in the band is the same colour
// aimed at the blue in the LASER ICON (#1d99e8, hue ~202): black -> white flattens the art and
// keeps the alpha, sepia+saturate turns that white into a fully saturated hue, and hue-rotate
// walks it round to the icon's. Verified by sampling rendered pixels, not by eye.
// Chain found by MEASURING, not guessing: it renders (32,154,255) against the icon's
// (29,153,232). The order matters — `invert` has to leave a MID grey for `sepia` to have
// anything to tint; a chain that inverted all the way to white came out near-white, because
// sepia adds almost no chroma to white and saturating a colourless pixel does nothing.
ctx.filter = 'brightness(0) invert(0.45) sepia(1) saturate(1600%) hue-rotate(185deg)';
ctx.globalAlpha = 0.85; // brighter: at 0.6 it disappeared into the sprite it was crossing
drawZombie(zb);
} else { // no canvas filter: fall back to a narrow bar inside the silhouette
const hw = h * 0.26 * gp.s;
ctx.fillStyle = CUT_COL;
ctx.fillRect(Math.round(gp.x - hw), cutY - bandH / 2, Math.round(hw * 2), bandH);
}
ctx.restore();
}
} }
// 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
@@ -1116,8 +1182,7 @@ export function render() {
// are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just // are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just
// not yet lit. // not yet lit.
const laserAim = G.aim && WEAPONS[slotId(G.weapon)]?.beam && !flying ? band : null; 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); } if (!laserAim && band) drawShotBand(band, bandU0, flying); // the laser previews itself below, over the court
else if (band) drawShotBand(band, bandU0, flying);
// 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 = [
@@ -1145,9 +1210,15 @@ export function render() {
// THE LASER's fired wedge, ERASING ALONG ITS OWN PATH. Drawn HERE — after the net and after every // THE LASER's fired wedge, ERASING ALONG ITS OWN PATH. Drawn HERE — after the net and after every
// body — because it is light in the air, not a decal on the grass. Drawn before them it went // body — because it is light in the air, not a decal on the grass. Drawn before them it went
// UNDER the net and behind the crowd, which is exactly why it read as lying on the floor. // UNDER the net and behind the crowd, which is exactly why it read as lying on the floor.
// The fired beam, and the AIM PREVIEW, both draw here: after the net and after every body, because
// this is light in the air. Then the bodies standing in it are re-cut on top of it.
if (G.beam) { if (G.beam) {
const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies 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); const L = drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1, 1 - k);
drawBeamCuts(b.ux, b.uz, b.x, b.z, L);
} else if (laserAim) {
const L = drawBeamWedge(laserAim.v.vx, laserAim.v.vz, P.x, TUNE.beamOriginY, P.z, 0.8, 0); // faint plane, so the preview leans on more of it
drawBeamCuts(laserAim.v.vx, laserAim.v.vz, P.x, P.z, L); // "already while aiming"
} }
if (band && !laserAim) 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
+4 -3
View File
@@ -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 , whoosh } from './audio.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal , dragContact , endBurst } from './gameplay.js'; import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal, endBurst } 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
@@ -302,8 +302,9 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va
// It goes through wound()/killZombie like everything else, so it cannot no-op on a boss. // It goes through wound()/killZombie like everything else, so it cannot no-op on a boss.
export function fireBeam(dirX, dirZ) { export function fireBeam(dirX, dirZ) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
const c = dragContact(); // it leaves the RACKET, not his feet // out of the PLAYER at chest height — same point the cone is measured from, so what you see and
G.beam = { x: c.x, y: c.y, z: c.z, ux, uz, t: TUNE.beamT }; // what it kills are one geometry
G.beam = { x: P.x, y: TUNE.beamOriginY, z: P.z, ux, uz, t: TUNE.beamT };
boom(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08; boom(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08;
for (let i = zombies.length - 1; i >= 0; i--) { for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i]; const zb = zombies[i];