Stop bodies blinking out, and give the cone a core and edges
The blinking was drawZombie validating its frame count against the LAST frame only: a set with (say) z_h2 missing but z_h4 present kept N = 4, the walk cycle stepped onto a name that had never loaded, and drawStatic silently drew nothing. The body vanished for that frame of the cycle and came back on the next one, forever. One cached 404 -- this project's oldest trap -- is enough to cause it. It holds frame one now: a body that stops animating is a bug you can see, one that flickers in and out reads as the game breaking. SPRV bumped to flush whatever his phone has cached. And no ctx.filter anywhere. The cut line was briefly the sprite redrawn clipped to a band and flattened by a canvas filter, which put it exactly on the silhouette -- and on the iPhone the line did not draw at all. Unsupported or leaking past its restore, a rendering feature that fails silently on the target device is not worth a pixel of polish. It is a plain bar again, brighter, and it crosses the odd gap between the legs, which is the trade. The cone gets the reference's look: a horizontal gradient across the width with a near-white core down the centre line falling to blue at the edges, plus the depth falloff along its length, plus stroked outer edges. Canvas has no 2D gradient, so it is filled in eight slices each carrying its own across-gradient -- enough that the seams vanish, which is what the old nested wedges got wrong. Test mode: barrels gone (the rack already holds everything they would give you) and the crowd raised to 16 bodies at a spawn every 0.9s, so it does not run dry between kills. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
337def8526
commit
8f16d085f0
5 files changed
+95
-76
No files matched your search
+24
@@ -418,6 +418,24 @@ 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 BLINKING BODIES, and why a missing frame is worse than a missing sheet (2026-08-03)
|
||||
|
||||
Oliver: "on iphone the zombies were blinking in and out of existence". **`drawZombie` validated its
|
||||
frame count against the LAST frame only** — `if (!SPR['z_' + v + N]) N = 2` — so a set with, say,
|
||||
`z_h2` missing but `z_h4` present kept `N = 4`, the walk cycle stepped onto a name that had never
|
||||
loaded, and `drawStatic` silently drew NOTHING. The body vanished for that frame of the cycle and
|
||||
came back on the next one, forever. A single cached 404 (this project's oldest trap) is enough.
|
||||
|
||||
`if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';` — hold frame one. **A body that stops
|
||||
animating is a bug you can see and diagnose; one that flickers in and out reads as the game
|
||||
breaking.** Every silent `SPR[x] ? x : fallback` in this file deserves the same suspicion.
|
||||
|
||||
**And no `ctx.filter`, anywhere.** The cut line was briefly the sprite redrawn clipped to a band and
|
||||
flattened to one colour by a canvas filter, which put it exactly on the silhouette. On an iPhone the
|
||||
line did not draw AT ALL. Whether that was the filter being unsupported or leaking past its
|
||||
`restore()`, a rendering feature that fails silently on the target device is not worth a pixel of
|
||||
polish — the line is a plain bar now and crosses the odd gap between the legs, which is the trade.
|
||||
|
||||
## 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)**
|
||||
@@ -425,6 +443,12 @@ 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 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
|
||||
length, each with its own across-gradient at that slice's depth alpha — enough slices that the
|
||||
seams vanish. The two long edges are STROKED: the reference reads as a plane because its edges are
|
||||
drawn, not merely where the fill stops.
|
||||
- **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
|
||||
|
||||
@@ -234,11 +234,11 @@ export const TUNE = {
|
||||
// MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
|
||||
// kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
|
||||
// editing it — G.runGames keeps climbing underneath and the run is still a real run.
|
||||
testZMax: 8, // bodies on court: enough to test a crowd weapon, few enough to read
|
||||
testZMax: 16, // bodies on court. Was 8 and the sandbox kept running dry between kills — a
|
||||
// crowd weapon needs a crowd to test it against
|
||||
testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
|
||||
testSpawnEvery: 1.6,// s between spawns
|
||||
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
|
||||
testBarrelEvery: 14,// s between barrels, so the pickup itself is testable too
|
||||
// ---- 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
|
||||
|
||||
+3
-9
@@ -559,7 +559,7 @@ export function toggleTestMode() {
|
||||
stockTestRack();
|
||||
G.flash = { text: 'TEST MODE', t: 1.6 };
|
||||
showModal({ title: 'TEST MODE', color: UI.pink,
|
||||
body: ['A STEADY MEDIUM WAVE', 'EVERY BARREL WEAPON, KEPT FULL',
|
||||
body: ['A STEADY CROWD, MEDIUM SPEED', 'EVERY BARREL WEAPON, KEPT FULL',
|
||||
'', 'TRIPLE-TAP THE SCOREBOARD AGAIN TO STOP'] });
|
||||
return true;
|
||||
}
|
||||
@@ -573,16 +573,10 @@ function stockTestRack() {
|
||||
else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin });
|
||||
}
|
||||
}
|
||||
let testBarrelT = 0;
|
||||
export function tickTestMode(dt) {
|
||||
if (!G.testMode) return;
|
||||
stockTestRack();
|
||||
if (G.mode !== 'splatter' || G.bossFight || G.interlude) return;
|
||||
if ((testBarrelT -= dt) <= 0) {
|
||||
testBarrelT = TUNE.testBarrelEvery;
|
||||
if (!zombies.some(z => z.variant === 'o')) spawnBarrel();
|
||||
}
|
||||
}
|
||||
stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and
|
||||
} // one rolling across the sandbox was just something else to look at
|
||||
|
||||
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
|
||||
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
|
||||
|
||||
+1
-1
@@ -179,6 +179,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=104"></script>
|
||||
<script type="module" src="main.js?v=105"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -181,7 +181,7 @@ function drawRunOff() {
|
||||
|
||||
// ---------------------------------------------------------------- sprites ----
|
||||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||||
const SPRV = 81; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||||
const SPRV = 82; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||||
const SPR = {};
|
||||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
|
||||
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
|
||||
@@ -471,6 +471,13 @@ function drawZombie(zb) {
|
||||
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(walked, 2)];
|
||||
if (SPR[stage]) name = stage;
|
||||
}
|
||||
// A MISSING FRAME MUST NEVER BLINK A BODY OUT OF EXISTENCE. N above is validated against the LAST
|
||||
// frame only, so a set with (say) z_h2 missing but z_h4 present keeps N = 4, the cycle steps onto a
|
||||
// name that never loaded, and drawStatic silently draws NOTHING — the body vanishes for that frame
|
||||
// and comes back on the next one. That is what Oliver saw on his phone, and a cached 404 (this
|
||||
// project's oldest trap) is enough to cause it. Hold frame 1 instead: a body that stops animating
|
||||
// is a bug you can see and diagnose; one that flickers in and out reads as the game breaking.
|
||||
if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';
|
||||
// Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched
|
||||
// frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body
|
||||
// grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule
|
||||
@@ -611,18 +618,39 @@ 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 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();
|
||||
// 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
|
||||
const k = (0.34 - 0.24 * um) * alpha; // depth: strongest at the strings
|
||||
const g = ctx.createLinearGradient(el.x, el.y, er.x, er.y);
|
||||
g.addColorStop(0, `rgba(${r},${g_},${b},${k * 0.55})`); // edge: the weapon's blue
|
||||
g.addColorStop(0.34, `rgba(${r},${g_},${b},${k})`);
|
||||
g.addColorStop(0.5, `rgba(215,238,255,${k * 1.5})`); // core: near-white, the hot line
|
||||
g.addColorStop(0.66, `rgba(${r},${g_},${b},${k})`);
|
||||
g.addColorStop(1, `rgba(${r},${g_},${b},${k * 0.55})`);
|
||||
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();
|
||||
}
|
||||
// ...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(190,225,255,${0.5 * alpha})`;
|
||||
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.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke();
|
||||
@@ -633,83 +661,56 @@ 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(29,153,232,0.55)'; // the laser icon's blue, for the no-filter fallback
|
||||
const CUT_COL = 'rgba(58,175,255,0.85)'; // the laser icon's blue (#1d99e8), lifted so a flat bar reads
|
||||
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; }
|
||||
})();
|
||||
// NO ctx.filter ANYWHERE. The cut line used to be the sprite redrawn clipped to a band and flattened
|
||||
// to one colour by a canvas filter, which put it exactly on the silhouette — and on an iPhone the
|
||||
// line did not draw AT ALL and bodies blinked in and out of existence, which is what a filter
|
||||
// leaking past its restore() looks like. Not worth it at any price. Plain bars from here.
|
||||
// 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.
|
||||
// BOUNDED: each body in the beam is drawn once MORE (the part above the plane), every frame you
|
||||
// are aiming and not only on the shot. Nearest first, capped, and bodies too small on screen for a
|
||||
// cut to say anything are skipped.
|
||||
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
|
||||
if (along < 0.5 || along > L) continue; // behind him, 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 gp = proj(zb.x, 0, zb.z), h = zombieH(zb);
|
||||
// whole 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);
|
||||
// a NaN anywhere here would make the clip path invalid, and an invalid clip is the sort of thing
|
||||
// that makes a body vanish for a frame — skip rather than draw something undefined
|
||||
if (!Number.isFinite(cutY) || !Number.isFinite(h) || !Number.isFinite(gp.s)) continue;
|
||||
if (h * gp.s < CUT_MIN_PX) continue;
|
||||
budget--;
|
||||
// the part ABOVE the plane is redrawn on top of it; everything below stays where it was and is
|
||||
// tinted by the translucent beam already laid over it
|
||||
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
|
||||
// ...and THE LINE, only where the plane actually passes through the body (one that the beam
|
||||
// clears over the head of, or passes under the feet of, gets none).
|
||||
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;
|
||||
// Thickness follows perspective — a share of the body's own drawn height, squared, because this
|
||||
// camera's scale only spans about 1.5x across the court and a linear falloff was invisible.
|
||||
const bandH = Math.max(0.4, h * gp.s * gp.s * 0.0009);
|
||||
const hw = h * 0.22 * gp.s; // narrow, so it stays inside the silhouette rather than overshooting
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user