diff --git a/HANDOFF.md b/HANDOFF.md index 775e378..b979613 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -443,11 +443,13 @@ 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. -- **The cone's opacity and the over/under read are the SAME dial, and they pull against each other.** - The plane is flat: a body above it is drawn clean on top, a body below is seen THROUGH it — so how - clearly you can tell the two apart just is how opaque the cone is. Oliver asked for a fainter, - bluer cone AND a clearer split; the first wins, and the CUT LINE carries the signal instead. Do not - try to "fix" the split by raising the cone alpha again — that is the thing he asked to come down. +- **THE PLANE IS OPAQUE, and that is what settled it.** While it was translucent, its opacity and + the over/under read were the same dial pulling in opposite directions: readable enough to show the + split and it veiled the whole cone, subtle enough to look right and you could not tell above from + below at all. Solid colour ends the argument — a body above the plane is drawn on top of it, a body + below is simply hidden behind it. **Depth is carried by BRIGHTNESS** (full at the strings, half at + the ads) rather than by alpha, which is why it still reads as receding. `BEAM_EDGE`/`BEAM_MID`/ + `BEAM_CORE` are the three colours. The trade is that you cannot see the crowd through it. - **The fill is TWO gradients**, matching the reference Oliver sent: a horizontal one across the width (near-white down the centre line, falling to the weapon's blue at the edges) and a depth one along the length. Canvas has no 2D gradient, so the wedge is filled in eight slices along its @@ -482,23 +484,12 @@ 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() — a posed, PAUSED scene for looking at a shot +## T.pause(true) — freeze the world, keep drawing -`T.laserScene()` in the console (or load the page with `#laser`) drops you into splatter with six -bodies of different types at known depths, the laser armed, a full pull held, and the world PAUSED. - -**`T.pause(true)` freezes the world but keeps DRAWING**, which is the point: change `TUNE.beamY` or a -colour in devtools and the next frame shows it, with the scene standing still. The old test recipe -halted rAF instead (`requestAnimationFrame = () => 0`), which freezes the picture too and is useless -for judging how something looks. `T.pause(false)` runs again; `T.render()` redraws by hand. - -It sets `TUNE.zMax = 0` so nothing wanders into the shot — that persists after unpausing, so reload -before playing on. - -The knobs worth turning there: `beamY` (the height the plane holds, and the line it cuts bodies at), -`beamOriginY`, `beamRise`, `beamW0`/`beamW1` (half-width at the racket and at the far end), `beamLen` -(the KILL range — the drawn length is solved separately against the ad strip), `beamT`, `dmgBeam`. -The cone's colours are the gradient stops in `drawBeamWedge`; the cut line is `CUT_COL`. +`T.pause(true)` stops `update()` but keeps `render()` running, so a value changed in devtools shows +on the next frame while the scene stands still. The older recipe halted rAF +(`requestAnimationFrame = () => 0`), which freezes the PICTURE too and is no use for judging how +something looks. `T.pause(false)` runs again. ## TEST MODE — triple-tap the scoreboard diff --git a/index.html b/index.html index 167531a..81b1317 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/main.js b/main.js index a7e7565..7af144b 100644 --- a/main.js +++ b/main.js @@ -200,43 +200,9 @@ cv.addEventListener('pointerdown', e => { }); -// ---- A POSED SCENE for looking at the laser. `T.laserScene()` in the console, or load the page -// with #laser. It drops you into splatter with a spread of bodies at known depths, the laser armed -// and a pull held, renders it, and PAUSES the world so devtools edits (TUNE.beamY, beamW1, the -// colours in drawBeamWedge) can be judged against a still picture. -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; // no new spawns wandering into the shot - // one of everything, at four depths, straddling the lane so the cut lands at different heights - 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; // hold still: this 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) - update(1 / 60); P.x = 0; - render(); - paused = true; - return 'laser scene posed and PAUSED. T.pause(false) to run again, T.render() to redraw by hand.'; -} -// 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, laserScene, 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, 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 ca25911..1972667 100644 --- a/render.js +++ b/render.js @@ -589,16 +589,16 @@ function drawShotBand(a, u0, flying) { 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. -// 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) { +// The laser's plane, shared by the aim PREVIEW and the fired shot. ONE triangle, apex at the +// player's chest, opening out down the court. It is FULLY OPAQUE — solid colour, deep blue at the +// edges and near-white down the core, dimming with distance. Depth is carried by BRIGHTNESS, not by +// transparency, so nothing shows through it: a body above the plane is drawn on top of it and a body +// below is simply hidden behind it, which is as unambiguous as the split can be. (It was a +// translucent veil; at a readable opacity it washed everything out, and at a subtle one you could +// not tell above from below at all — the two pulled against each other. Opaque settles it.) +// `from` is the erase front: 0 draws the whole wedge, 0.5 only its far half, 1 nothing. +// Returns the length it drew, so the split can use the same geometry. +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 @@ -618,7 +618,6 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { 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); const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1); - const [r, g_, b] = RGB(WEAPONS.laser.col); // 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 @@ -631,16 +630,18 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { 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 - // Lower opacity and BLUER. It was near-white down the core at almost double this alpha, which - // veiled the whole cone evenly — and once everything under it is equally washed out, the split - // stops reading: you cannot tell the part above the plane from the part below. - const k = (0.19 - 0.13 * um) * alpha; // depth: strongest at the strings + // 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, `rgba(${r},${g_},${b},${k * 0.5})`); // edge: the weapon's blue - g.addColorStop(0.34, `rgba(${r},${g_},${b},${k})`); - g.addColorStop(0.5, `rgba(118,188,255,${k * 1.35})`); // core: a LIT blue, not white - g.addColorStop(0.66, `rgba(${r},${g_},${b},${k})`); - g.addColorStop(1, `rgba(${r},${g_},${b},${k * 0.5})`); + 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(); @@ -648,14 +649,14 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { // ...and the OUTER LINES. The reference reads as a plane because its edges are drawn, not just // implied by where the fill stops. const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1); - ctx.strokeStyle = `rgba(120,190,255,${0.45 * alpha})`; + ctx.strokeStyle = 'rgb(210,238,255)'; // the outer lines, solid like the rest of it 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(); 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.strokeStyle = 'rgb(255,255,255)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke(); } ctx.lineWidth = 1; @@ -665,6 +666,10 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, 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]; +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) const CUT_MIN_PX = 14; // ...and only if it is drawn at least this tall // NO ctx.filter ANYWHERE. The cut line used to be the sprite redrawn clipped to a band and flattened @@ -1218,10 +1223,10 @@ export function render() { // 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 - const L = 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 - 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 + const L = drawBeamWedge(laserAim.v.vx, laserAim.v.vz, P.x, TUNE.beamOriginY, 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