Make the plane solid, and take the posed scene back out

The cone is fully opaque now: solid colour, deep blue at the edges and near-white
down the core, dimming as it runs away. Depth is carried by BRIGHTNESS instead of
by alpha, so it still reads as receding without anything showing through.

That settles an argument the translucent version could not win. Its opacity and
how well you could tell above-the-plane from below-the-plane were the same dial
pulling opposite ways: readable enough to show the split and it veiled the whole
cone, subtle enough to look right and there was nothing to see. Solid, a body
above the plane is drawn on top of it and a body below is hidden behind it. The
trade is that you cannot see the crowd through it, which is the thing to judge on
the phone.

The `alpha` parameter went with it rather than being left in the signature lying
about what it does.

laserScene is gone. T.pause(true) stays -- it freezes the world but keeps drawing,
which is worth having whenever a still is what you need to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 17:21:21 +02:00
1 parent 44419d9ce1
commit 601aa934ad
4 files changed
+43 -81

No files matched your search

+12 -21
View File
@@ -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
+1 -1
View File
@@ -179,6 +179,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=107"></script>
<script type="module" src="main.js?v=108"></script>
</body>
</html>
+1 -35
View File
@@ -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; } };
+29 -24
View File
@@ -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