diff --git a/HANDOFF.md b/HANDOFF.md index ebcfc73..1c7a367 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/config.js b/config.js index 25605e3..2bae747 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index 72f13bb..9438b3d 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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. diff --git a/index.html b/index.html index dc95043..dafc902 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/render.js b/render.js index 8e84b34..4d7b9e5 100644 --- a/render.js +++ b/render.js @@ -181,7 +181,7 @@ function drawRunOff() { // ---------------------------------------------------------------- sprites ---- // assets/.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,82 +661,55 @@ 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; - ctx.fillStyle = CUT_COL; - ctx.fillRect(Math.round(gp.x - hw), cutY - bandH / 2, Math.round(hw * 2), bandH); - } - ctx.restore(); + // 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); } }