diff --git a/HANDOFF.md b/HANDOFF.md index 910f5de..ca2d718 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -443,6 +443,9 @@ that holds that height all the way out instead of sloping to the ground — that 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. +- **`beamLen` is the DRAWN length now, not just the kill range.** It used to be solved every frame so + the far end landed on the middle of the ad strip, which made it a knob that visibly did nothing. + One meaning beats one clever behaviour. - **`TUNE.beamBand` is a live A/B between two looks**, flippable from the T panel on the phone. **1 (default) = the BAND**: drawn the way the ball's aim trajectory is drawn — a path down the lane whose width comes from each point's OWN projection scale, so it narrows into the distance like @@ -494,6 +497,27 @@ nothing for it to cut exactly where the crowd stands. are AIMING rather than only on the shot. Measured on desktop: 0.27 ms/frame with 20 bodies against a 16.7 ms budget. +## T.laserScene() — the tuning playground + +`T.laserScene()` in the console (or `#laser` on the URL) poses six bodies of different types at known +depths with the laser armed and a pull held. `T.laserFire()` shoots it; `T.laserScene()` re-poses. + +**It does NOT pause, and that is the point.** The first version froze the world so the picture would +hold still — and then every value changed in the T panel did nothing visible, because nothing +redrew. Oliver: "tuning the parameters does not change the view (because it's paused?)". The bodies +are held still individually (`v = 0`) and spawning is off instead, so the picture is stable while the +render loop keeps running. **Never pose a tuning scene by halting rAF or pausing** — a still frame +you cannot change is worth nothing. + +**A boolean living in TUNE must be tested with `>= 0.5`.** The panel renders every TUNE key as a +number input stepping by 0.02, so one nudge on `beamBand` set it to 0.02 — still truthy — and the +renderer silently switched variants under him mid-tune. + +The laser's shape was tuned this way and these are Oliver's numbers: `beamY` 0.95, `beamLen` 23, +`beamW0` 0.08, `beamW1` 3.1, `beamOriginLift` 0.44, `beamBorder` 0.18. +**`beamY` and `beamLen` interact** — a lower plane meets the ad strip at a shorter distance, so +dropping one wants the other shortened. + ## T.pause(true) — freeze the world, keep drawing `T.pause(true)` stops `update()` but keeps `render()` running, so a value changed in devtools shows diff --git a/config.js b/config.js index 886d8a9..7c35238 100644 --- a/config.js +++ b/config.js @@ -240,27 +240,36 @@ export const TUNE = { testSpawnEvery: 0.9,// s between spawns: they keep coming testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up // ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane - 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. + beamLen: 23, // m it reaches — the DRAWN length as well as the kill length. It used to be only + // the kill: the drawn length was solved each frame so the far end landed on the + // middle of the ad strip, which meant turning this knob did nothing you could + // see. One meaning now. Tuned WITH beamY: a lower plane reaches the ads sooner, + // so dropping one wants the other shortened. Oliver's numbers, 2026-08-03. + beamY: 0.95, // 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. + beamOriginLift: 0.44, // m the apex sits ABOVE beamY. It is a lift, not an absolute height, so + // lowering beamY brings the near end down with it — set as an absolute, dropping + // the plane left the apex hanging at chest height with the beam sloping away + // beneath it. It leaves the PLAYER, not the racket head: emitted from the racket + // (2.2 m) the apex floated above him and read as detached. 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 + beamW0: 0.08, // m half-width at the racket — very nearly a point + beamW1: 3.1, // ...and at the far end. It IS a cone — but a gentle one. At 5.0 the world width + // grew so much faster than the projection shrank that on screen it opened out + // while the court receded, and the eye read that as a flat shape stuck to the + // glass. Growing slowly, the two roughly cancel and it lies in the world. // A/B, live from the T panel on the phone. 1 = draw the laser the way the BALL's aim band is // drawn: a path down the lane whose width comes from each point's OWN projection scale, so it // tapers into the distance and sits in the world. 0 = the solid widening cone. // The cone is a real projection, but it GROWS in world width (0.06 m -> beamW1), so on screen it // opens out while everything else recedes — which is what reads as a flat sticker. - beamBand: 1, + beamBorder: 0.18, // px of the two outer edge lines — a hairline. At 1 they were the heaviest + // thing on the court and boxed the cone in. + beamBand: 0, // 0 now: the CONE won, once it stopped ballooning (see beamW1). >= 0.5 is ON — + // the panel steps by 0.02 and a nudge to 0.02 used to flip the renderer silently beamBandW: 0.30, // world half-width of that band — CONSTANT, which is what makes it taper beamT: 0.42, // s the wedge stays drawn dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is diff --git a/index.html b/index.html index 1c19703..d1cecdb 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/main.js b/main.js index 7af144b..8851fbd 100644 --- a/main.js +++ b/main.js @@ -200,9 +200,48 @@ cv.addEventListener('pointerdown', e => { }); + +// ---- A POSED SCENE for tuning a shot: `T.laserScene()`, or load with #laser. Six bodies of +// different types at known depths, the laser armed, a pull held. +// IT DOES NOT PAUSE, deliberately. The first version froze the world so the picture would hold +// still — and then every TUNE value you changed did nothing visible, because nothing redrew. The +// bodies are held still individually (v = 0) and spawning is off instead, so the picture is stable +// while the render loop keeps running and an edit shows on the very next frame. +function laserScene() { + const intro = document.getElementById('intro'); + if (intro) intro.hidden = true; + paused = false; + if (G.mode === 'tennis') forceZombies(); + for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter + G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false; + G.mgT = 0; G.gunId = null; G.beam = null; G.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp; + zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0; + score.server = 'P'; setupPoint(); + P.x = 0; + TUNE.zMax = 0; // nothing new wanders into the shot + for (const [x, z, v] of [[0, 20, 'a'], [0.75, 16, 'b'], [-0.85, 12, 'k'], + [0.85, 8.5, 'g'], [-0.75, 5.5, 'h'], [0, 3, 'e']]) { + spawnZombie(x, z, v); + zombies[zombies.length - 1].v = 0; // held still: the scene is a photograph + } + G.rack.length = 0; G.temp = null; + G.weapon = grantWeapon('laser'); + G.modal = null; + G.aim = { fx: 0, fy: -1, mag: 100 }; // a full pull straight up-court (fy is NEGATIVE) + return 'posed and LIVE — change TUNE (T panel or console) and it redraws. T.laserFire() to shoot it.'; +} +// fire the posed shot, then pose it again so you can keep tuning +function laserFire() { + G.aim = null; score.server = 'P'; setupPoint(); + keyPressed('Space'); + return 'fired — T.laserScene() to re-pose'; +} +// a plain timer, not the load event: this module runs as a module, so `load` may already have gone +if (location.hash === '#laser') setTimeout(laserScene, 600); + requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, +window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 891d747..90f2b05 100644 --- a/render.js +++ b/render.js @@ -601,57 +601,48 @@ function drawShotBand(a, u0, flying) { function drawBeamWedge(dirX, dirZ, ox, oy, oz, from) { const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; 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; + // The DRAWN length is TUNE.beamLen, straight. It used to be solved here each frame so the far end + // landed on the middle of the ad strip — which made beamLen a knob that visibly did nothing, since + // it only governed the kill. One meaning is worth more than one clever behaviour. + const L = TUNE.beamLen; 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 corner = (u, w) => proj(ox + ux * (u * L) + px * w, yAt(u), oz + uz * (u * L) + pz * w); - if (TUNE.beamBand) { drawBeamBand(ux, uz, ox, oy, oz, L, from); return L; } + // >= 0.5, not just truthy: the T panel steps numbers by 0.02, so one nudge on this flag set it to + // 0.02 — still truthy, and the renderer silently switched under Oliver mid-tune. A flag living in + // a table of numbers has to be robust to being treated as a number. + if (TUNE.beamBand >= 0.5) { drawBeamBand(ux, uz, ox, oy, oz, L, from); return L; } const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1); - // TWO gradients, because the reference has both: a HORIZONTAL one across the width (near-white - // down the centre line, falling to blue at the edges) and a DEPTH one along the length. Canvas has - // no 2D gradient, so the wedge is filled in a handful of slices along its length, each with its - // own across-gradient at that slice's depth alpha. Enough slices and the seams disappear; too few - // and it bands, which is the thing the old nested-wedge version got wrong. - const SEGS = 8; - for (let i = 0; i < SEGS; i++) { - const ua = u0 + (1 - u0) * (i / SEGS), ub = u0 + (1 - u0) * ((i + 1) / SEGS); - const um = (ua + ub) / 2; - const wa = wAt(ua), wb = wAt(ub), wm = wAt(um); - const a1 = corner(ua, -wa), a2 = corner(ua, wa), b2 = corner(ub, wb), b1 = corner(ub, -wb); - const el = corner(um, -wm), er = corner(um, wm); // the across axis, at this slice's width - // FULLY OPAQUE. Depth is carried by BRIGHTNESS, not by alpha — the plane is solid colour, dark - // blue at the edges and near-white down the core, dimming as it runs away from you. Nothing under - // it shows through, so "above the plane" and "below the plane" stop being a matter of degree: - // the part above is simply there and the part below is simply gone behind it. - const f = 1 - 0.5 * um; // depth: full at the strings, half at the ads - const mix = (c, m) => `rgb(${Math.round(c[0] * m)},${Math.round(c[1] * m)},${Math.round(c[2] * m)})`; - const g = ctx.createLinearGradient(el.x, el.y, er.x, er.y); - g.addColorStop(0, mix(BEAM_EDGE, f * 0.8)); // edge: deep blue - g.addColorStop(0.30, mix(BEAM_MID, f)); - g.addColorStop(0.5, mix(BEAM_CORE, f)); // core: the hot line down the middle - g.addColorStop(0.70, mix(BEAM_MID, f)); - g.addColorStop(1, mix(BEAM_EDGE, f * 0.8)); - ctx.fillStyle = g; - ctx.beginPath(); ctx.moveTo(a1.x, a1.y); ctx.lineTo(a2.x, a2.y); ctx.lineTo(b2.x, b2.y); ctx.lineTo(b1.x, b1.y); - ctx.closePath(); ctx.fill(); + // THE SHADOW ON THE LAWN, borrowed from the ball's aim band and from the band variant — this is + // most of why those read as lying in the world. A bright shape hanging in the air is ambiguous + // about where it actually is until something pins it to the ground. + for (let i = 0; i <= 14; i++) { + const u = u0 + (1 - u0) * (i / 14), w = wAt(u); + const p = proj(ox + ux * (u * L), 0, oz + uz * (u * L)); + ctx.fillStyle = `rgba(0,0,0,${0.16 * (1 - u * 0.55)})`; + ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * w * 0.9), Math.max(0.4, p.s * w * 0.3), 0, 0, 7); + ctx.fill(); } - // ...and the OUTER LINES. The reference reads as a plane because its edges are drawn, not just - // implied by where the fill stops. + // ONE gradient, along the LENGTH only. There was a second one across the width (near-white core + // falling to blue at the edges) and it is gone: across a shape this narrow it read as a highlight + // painted down the middle rather than as depth. The plane is a flat solid colour now, dimming as + // it runs away from you — that is the whole shading. const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1); - ctx.strokeStyle = 'rgb(210,238,255)'; // the outer lines, solid like the rest of it - ctx.lineWidth = 1; + const mix = (c, m) => `rgb(${Math.round(c[0] * m)},${Math.round(c[1] * m)},${Math.round(c[2] * m)})`; + 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, mix(BEAM_MID, 1)); + grad.addColorStop(1, mix(BEAM_MID, 0.5)); + 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.closePath(); ctx.fill(); + + // ...and the OUTER LINES. It reads as a plane because its edges are drawn, not merely implied by + // where the fill stops. + ctx.strokeStyle = 'rgb(196,230,255)'; // the outer lines — width is a knob (beamBorder) + ctx.lineWidth = TUNE.beamBorder; 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); @@ -667,8 +658,8 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, from) { // 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(58,175,255,0.85)'; // the laser icon's blue (#1d99e8), lifted so a flat bar reads -// The plane's three colours, edge -> core. Opaque: see the gradient in drawBeamWedge. -const BEAM_EDGE = [26, 62, 178]; +// The plane's colour. Opaque, one flat blue, dimmed with distance — see drawBeamWedge. BEAM_CORE is +// still used by the band variant's hot centre line (TUNE.beamBand). const BEAM_MID = [63, 116, 255]; const BEAM_CORE = [206, 238, 255]; const CUT_MAX = 8; // bodies that get a cut line in one frame (nearest first) @@ -732,7 +723,7 @@ function drawBeamCuts(dirX, dirZ, ox, oz, L) { const along = rx * ux + rz * uz; if (along < 0.5 || along > L) continue; // behind him, or past the end const off = Math.abs(rx * uz - rz * ux); - const halfW = TUNE.beamBand ? TUNE.beamBandW * 2 // must match fireBeam's test, or the cut + const halfW = TUNE.beamBand >= 0.5 ? TUNE.beamBandW * 2 // must match fireBeam's test, or the cut : TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L); // marks bodies the shot will miss if (off > halfW + 0.8) continue; const gp = proj(zb.x, 0, zb.z), h = zombieH(zb); @@ -1266,7 +1257,7 @@ export function render() { const L = drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 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); // the preview is the same solid plane + const L = drawBeamWedge(laserAim.v.vx, laserAim.v.vz, P.x, TUNE.beamY + TUNE.beamOriginLift, P.z, 0); // the preview is the same solid plane 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 44b77fe..7bbfee5 100644 --- a/zombies.js +++ b/zombies.js @@ -304,7 +304,7 @@ export function fireBeam(dirX, dirZ) { const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; // 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 }; + G.beam = { x: P.x, y: TUNE.beamY + TUNE.beamOriginLift, 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]; @@ -315,7 +315,7 @@ export function fireBeam(dirX, dirZ) { const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane // the kill follows whichever LOOK is on, or the A/B would be a lie: a narrow band that deletes a // cone-wide crowd tells you nothing about how the narrow band plays - const halfW = TUNE.beamBand ? TUNE.beamBandW * 2 + const halfW = TUNE.beamBand >= 0.5 ? TUNE.beamBandW * 2 : TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen); if (off > halfW + hitR(zb)) continue; // THE BEAM DELETES ORDINARY BODIES. hp does not enter into it: a plane of light that left a