diff --git a/HANDOFF.md b/HANDOFF.md index a081050..ebcfc73 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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, `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 A sandbox for trying new content without playing twenty games to reach it. Three taps on the diff --git a/config.js b/config.js index 45b1367..25605e3 100644 --- a/config.js +++ b/config.js @@ -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 // 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. + 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 // 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 diff --git a/index.html b/index.html index eef9520..dc95043 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/render.js b/render.js index a4958bb..8e84b34 100644 --- a/render.js +++ b/render.js @@ -3,7 +3,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS } from './config.js'; import { P, O, ball, score, crowd, G, court } from './state.js'; -import { predictLanding, previewServe , slotId, slotStock , dragContact } from './gameplay.js'; +import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; // ---------------------------------------------------------------- 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 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. +// 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) { 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 + // 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; - // It LEAVES THE RACKET as a POINT and opens out. Drawn flat on the lawn it read as a shape lying - // at his feet; starting wide it read as a bar hanging in mid-air. Narrow at the strings, settling - // onto the court as it goes, is what makes the near end look attached to him. - const yAt = u => oy + (0.05 - oy) * Math.min(1, u * 1.7); - 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); - const rgb = RGB(WEAPONS.laser.col); - // WHERE THE FAR EDGE SITS is a screen-space decision, not a world one. Run out to beamLen on the - // ground and it lands on the ads' TOP line; the beam should die at their MIDDLE. Pinning the far - // corners' y here keeps that exact in both orientations and leaves beamLen free to be the KILL - // range rather than a number tuned to one camera. - const farY = proj(0, 0, -6).y - AD_H / 2; // proj(0,0,-6) is the same line the ad strip sits on - // THREE NESTED WEDGES: a wide violet-blue bloom, a cyan body, and a white-hot core down the - // middle. One flat quad with a gradient along its length was a coloured shape; layers that share - // an axis and get brighter toward the centre are what read as sci-fi light. - const layers = [ - { 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(); + const corner = (u, w) => proj(ox + ux * (u * L) + px * w, yAt(u), oz + uz * (u * L) + 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 [r, g_, b] = RGB(WEAPONS.laser.col); + // DEPTH: strongest at the strings, thinning away down the court. Never to zero, though — a stop + // at alpha 0 made the beam invisible before its own far edge, so the visible end sat short of + // where it was drawn and moving the geometry appeared to change nothing at all. + const grad = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2, + (c3.x + c4.x) / 2, (c3.y + c4.y) / 2); + grad.addColorStop(0, `rgba(${r},${g_},${b},${0.34 * alpha})`); + grad.addColorStop(0.45, `rgba(${r},${g_},${b},${0.20 * alpha})`); + grad.addColorStop(1, `rgba(${r},${g_},${b},${0.10 * alpha})`); + ctx.fillStyle = grad; + 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(); + if (from > 0) { // the erase front: a bright bar racing away down the lane + 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(); } - // 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; + 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 @@ -1116,8 +1182,7 @@ export function render() { // 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); + if (!laserAim && band) drawShotBand(band, bandU0, flying); // the laser previews itself below, over the court // far-side drawables in z order (far → near), then the net, then the near side 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 // 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. + // 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) { 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 diff --git a/zombies.js b/zombies.js index 283ab04..6449f13 100644 --- a/zombies.js +++ b/zombies.js @@ -6,7 +6,7 @@ import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx , 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 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. 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 }; + // out of the PLAYER at chest height — same point the cone is measured from, so what you see and + // 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; for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i];