astara / src /background.js
system's picture
system HF Staff
7ca99ce: Two lanes: the drained stand beside the road, on terraces
d16591c verified
Raw History Blame Contribute Delete
37.2 kB
// The forest, in layers.
//
// THE TWO-SIDED VALUE RULE, measured off Blade Warrior. See PALETTE in
// tuning.js for the numbers.
//
// 1. Nothing lives between luminance 30 and 90. A layer is either pure black
// or it is well clear of black. The middle is banned.
// 2. Something substantial has to sit above 160, or a silhouette has nothing
// to read against.
//
// The build this replaces broke both: 30.4% of the frame in the forbidden zone,
// and not one pixel above 160. Three near-identical dark greys under a flat
// dead sky. That was the mud.
//
// So there are three values and black: a sky range, one mid band for the hills,
// and everything from the watchers inward pure black. What separates a watcher
// from a trunk is SHAPE, not tone — in Blade Warrior the foreground reeds are
// the same black as the hero and are never once confused with him, because thin
// spikes and solid human mass are different grammars.
//
// WHAT GETS LIT. The torch's mask is a flat screen-space circle with no depth
// information, so "that layer is far away" does not protect it. Each layer says
// which art it hands to pass 2; the ones that must not be lit hand over their
// dark art, the same trick the prince uses to stay a silhouette inside his own
// torchlight.
import { TUNING, BOWL, PALETTE, SKY, depthAt, DEPTH, TONE, TERRACE, atLuminance, luminance } from './tuning.js';
const SKY_TILE_W = 2048;
/** Blend two [r,g,b] palette entries. */
const mix3 = (a, b, t) => [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
];
const rgb = (hex) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
/** Deterministic 0..1 from an integer. No state, no list, no allocation. */
function hash01(i, salt) {
let h = Math.imul((i | 0) ^ salt, 2654435761) >>> 0;
h ^= h >>> 15;
h = Math.imul(h, 2246822507) >>> 0;
h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
/**
* The sky, built once into a tiling texture.
*
* A dithered field quantised to three palette entries rather than a smooth
* gradient, plus a moon and two cloud masses. Cloud masses are placed with
* wrap-around so the tile is seamless, because at 0.06x parallax the seam would
* otherwise cross the screen once during a full level.
*/
function buildSky(h) {
const c = document.createElement('canvas');
c.width = SKY_TILE_W;
c.height = h;
const x = c.getContext('2d');
const img = x.createImageData(SKY_TILE_W, h);
const d = img.data;
const low = rgb(PALETTE.SKY_LOW);
const high = rgb(PALETTE.SKY_HIGH);
const cloud = rgb(PALETTE.CLOUD);
// NO CLOUD BLOBS. Soft radial gradients in a sky read as glowing orbs, not as
// cloud — they look like light sources sitting in mid-air.
//
// The brightness is a horizontal BAND instead, brightest just above the
// horizon and falling away both up and down. That is where it is wanted
// anyway: the figures stand against the horizon, so the light belongs behind
// them rather than at the top of the frame. It also leaves the zenith darker,
// which is where the canopy arrives from.
const peakY = SKY.CENTRE * h;
const spread = SKY.SPREAD * h;
// The moon is measured in PIXELS, not in tile fractions. In fractions it
// comes out an ellipse, because the tile is not square.
const moonX = 0.17 * SKY_TILE_W;
const moonY = 0.15 * h;
const moonR = 52;
for (let py = 0; py < h; py++) {
// One band of brightness sitting just above the horizon.
const t0 = (py - peakY) / spread;
const v0 = SKY.BASE + SKY.PEAK * Math.exp(-t0 * t0);
for (let px = 0; px < SKY_TILE_W; px++) {
const v = v0;
let mdx = px - moonX;
if (mdx > SKY_TILE_W / 2) mdx -= SKY_TILE_W;
if (mdx < -SKY_TILE_W / 2) mdx += SKY_TILE_W;
const md = Math.hypot(mdx, py - moonY);
// a crescent: the disc, minus a disc offset up and to the right
const shadow = Math.hypot(mdx - moonR * 0.46, py - moonY + moonR * 0.34);
let vv = v;
if (md < moonR && shadow > moonR * 0.86) vv = 1.4;
else if (md < moonR) vv = Math.max(v, 0.62);
// CONTINUOUS TONE. This used to quantise to three palette entries through
// an ordered 4x4 Bayer matrix at 3px cells, to imitate a 16-colour Amiga
// game. It produced a visible quilt, and the maths says why: where the
// gradient sits halfway between two entries a Bayer threshold is a
// perfect checkerboard, so the sky carried a 6px weave with the matrix
// repeating every 12px. Measured at 0.765 autocorrelation at lag 6.
//
// Limbo is the opposite aesthetic and that is the direction now. The
// gradient is written straight, interpolating the same three palette
// entries instead of choosing between them — the colours and the band
// shape are unchanged, only the steps are gone.
const col = vv < 0.5
? mix3(low, high, vv / 0.5)
: mix3(high, cloud, Math.min(1, (vv - 0.5) / 0.5));
const i = (py * SKY_TILE_W + px) * 4;
d[i] = col[0]; d[i + 1] = col[1]; d[i + 2] = col[2]; d[i + 3] = 255;
}
}
x.putImageData(img, 0, 0);
return c;
}
/**
* Recolour a black silhouette once, at load, into a cached bitmap.
*
* The old build alpha-blended black onto the background to land on a target
* grey. That works for greys and not for the blue, because the hills are not a
* darker version of the sky — they are a different mix. Tinting once costs
* nothing per frame.
*/
export async function tintFrames(clip, hex, which = 'dark') {
// An undefined colour would leave fillStyle untouched and silently paint the
// previous one, which is how this quietly did nothing the first time.
if (typeof hex !== 'string') throw new Error(`tintFrames: no colour for "${clip.name}"`);
const out = [];
for (const f of clip.frames) {
const c = document.createElement('canvas');
c.width = clip.width;
c.height = clip.height;
const x = c.getContext('2d');
x.drawImage(f[which], 0, 0, clip.width, clip.height);
x.globalCompositeOperation = 'source-in';
x.fillStyle = hex;
x.fillRect(0, 0, c.width, c.height);
out.push(await createImageBitmap(c));
}
return out;
}
export async function createBackground(clips, groundY, viewH) {
const sky = buildSky(viewH);
/**
* Blur, and the constraint IS the finding.
*
* Hills (97) against sky (163) is a gradient from 97 to 163 — entirely above
* 90, so it costs nothing. Canopy (0) against sky runs straight through the
* forbidden zone, and so does any black trunk, actor or ground.
*
* So: the hills and the sky, and nothing else, ever.
*
* Baked once when the strip is made. The strip is already blitted once per
* layer per frame, so at runtime this is free.
*/
const blurred = (src, px) => {
if (!px) return src;
const pad = Math.ceil(px * 3);
const cv = document.createElement('canvas');
cv.width = src.width + pad * 2;
cv.height = src.height + pad * 2;
const x = cv.getContext('2d');
x.filter = `blur(${px}px)`;
// Drawn three times across the seam so a tiling strip still tiles: a blur
// at the edge would otherwise pull in transparent pixels and leave a seam.
x.drawImage(src, pad - src.width, pad);
x.drawImage(src, pad, pad);
x.drawImage(src, pad + src.width, pad);
x.filter = 'none';
const out = document.createElement('canvas');
out.width = src.width; out.height = src.height;
out.getContext('2d').drawImage(cv, -pad, -pad);
return out;
};
/**
* Haze: fade a layer toward the sky it sits against. The further away, the
* more of the air is between you and it.
*
* Toward SKY_HIGH rather than toward the sky's exact local value, because the
* sky is a gradient and a layer is one bitmap — SKY_HIGH is what sits behind
* the horizon band where the hills actually are.
*/
const haze = (hex, amount) => {
if (!amount) return hex;
const a = rgb(hex), b = rgb(PALETTE.SKY_HIGH);
const m = mix3(a, b, Math.min(1, amount)).map((v) => Math.round(v));
return `#${m.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
};
/**
* Soften and re-hue a whole clip's frames, once, at load.
*
* The canopy and the near trunks are PURE BLACK, which is exactly why neither
* could be blurred before: a soft edge from 0 to a 163 sky runs straight
* through the old forbidden zone. In Limbo the extreme foreground is out of
* focus and only the boy is sharp, so this is the cue that was most obviously
* missing and most firmly banned.
*
* Warmth is a hue change at the SAME darkness, never a lift. A lighter trunk
* in front of a black boy tints instead of blocks, which reads as a rendering
* fault, and pure black against a lit background is what says "close".
*/
const soften = (clip, blurPx, warm) => {
if (!clip || (!blurPx && !warm)) return null;
// BOTH passes. The near trunks hand the lit pass their own art, so softening
// only the dark one left the torch revealing a sharp trunk inside a blurred
// one — the defocus would switch off wherever the light landed.
const one = (f, which) => {
const cv = document.createElement('canvas');
cv.width = clip.width; cv.height = clip.height;
const g = cv.getContext('2d');
g.drawImage(f[which], 0, 0);
if (warm) {
g.globalCompositeOperation = 'source-in';
// Luminance about 12 — still black at a glance, warm not neutral.
g.fillStyle = `rgb(${Math.round(24 * warm)},${Math.round(14 * warm)},${Math.round(7 * warm)})`;
g.fillRect(0, 0, cv.width, cv.height);
g.globalCompositeOperation = 'source-over';
}
return blurPx ? blurred(cv, blurPx) : cv;
};
return clip.frames.map((f) => ({ dark: one(f, 'dark'), lit: one(f, 'lit') }));
};
const hillsTint = await tintFrames(clips.bg_treeline, haze(PALETTE.HILLS, TONE.HILLS_HAZE), 'dark');
const watcherTint = await tintFrames(clips.drained_watcher, haze(PALETTE.WATCHERS, TONE.WATCHER_HAZE), 'dark');
let canopySoft = soften(clips.bg_canopy, Math.round(TONE.CANOPY_BLUR), 0);
let trunkSoft = soften(clips.bg_trunks_near, Math.round(TONE.TRUNK_BLUR), TONE.TRUNK_WARM);
/**
* Rebuild the softened strips. Blurring is a load-time cost, so this is not
* something to call per frame — but it has to exist, because judging a soft
* foreground means A/B-ing it, and reloading the page between every pair is
* how a sweep stops happening.
*/
function setSoften(canopyBlur, trunkBlur, trunkWarm) {
canopySoft = soften(clips.bg_canopy, Math.round(canopyBlur), 0);
trunkSoft = soften(clips.bg_trunks_near, Math.round(trunkBlur), trunkWarm);
layers.find((l) => l.key === 'canopy').soft = canopySoft;
layers.find((l) => l.key === 'trunks_near').soft = trunkSoft;
}
// Sharp originals kept, so the toggle is reversible without a reload.
const sharpSky = sky;
const sharpHills = hillsTint[0];
let skyStrip = sharpSky;
let hillsStrip = sharpHills;
let blurPx = -1, blurSkyPx = -1;
// Separately, because they turn out to cost completely different things: the
// hills are an edge against the sky and blurring them is nearly free, while
// the sky is a vertical gradient and blurring it smears the bright band down
// into the dark end and throws bright mass away.
function setBlur(px, skyPx = px) {
if (px === blurPx && skyPx === blurSkyPx) return;
blurPx = px; blurSkyPx = skyPx;
skyStrip = blurred(sharpSky, skyPx);
hillsStrip = blurred(sharpHills, px);
layers.find((l) => l.key === 'hills').tinted = [hillsStrip];
}
// How much sky is open above a given spot on the walk.
//
// The sky itself has no horizontal variation any more — it is a band, and
// blobs in it read as orbs hanging in mid-air. So the only thing that makes
// one stretch brighter than another is the CANOPY: its bottom edge is
// ragged, and where a tooth hangs low there is less sky behind a figure.
//
// Measured once, off the canopy bitmap, into a per-column profile of where
// its ink ends. Encounter placement reads this so fights happen under the
// open patches, which is the one thing Blade Warrior does that costs nothing.
/** Topmost ink row per column of a clip's dark art. */
const topProfile = (cl) => {
if (!cl) return null;
const cv = document.createElement('canvas');
cv.width = cl.width; cv.height = cl.height;
const g = cv.getContext('2d', { willReadFrequently: true });
g.drawImage(cl.frames[0].dark, 0, 0);
const px = g.getImageData(0, 0, cl.width, cl.height).data;
const prof = new Float32Array(cl.width);
for (let x = 0; x < cl.width; x++) {
let top = cl.height;
for (let y = 0; y < cl.height; y++) {
if (px[(y * cl.width + x) * 4 + 3] > 16) { top = y; break; }
}
prof[x] = top;
}
return prof;
};
const hillProfile = topProfile(clips.bg_treeline);
// Which rows of the sky are actually BRIGHT. Open sky below the band is still
// sky, but a silhouette does not read against it, so placement must not count
// it. Measured off the built texture rather than re-deriving the gradient,
// so it cannot drift from what is on screen.
const brightBand = (() => {
const g = document.createElement('canvas');
g.width = 1; g.height = sky.height;
const gx = g.getContext('2d', { willReadFrequently: true });
gx.drawImage(sky, 0, 0);
const px = gx.getImageData(0, 0, 1, sky.height).data;
let top = sky.height, bot = 0;
for (let y = 0; y < sky.height; y++) {
const i = y * 4;
if (0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2] > 160) {
if (y < top) top = y;
bot = y;
}
}
return top <= bot ? { top, bot } : { top: 0, bot: sky.height };
})();
const canopyProfile = (() => {
const cl = clips.bg_canopy;
if (!cl) return null;
const cv = document.createElement('canvas');
cv.width = cl.width; cv.height = cl.height;
const cx2 = cv.getContext('2d', { willReadFrequently: true });
cx2.drawImage(cl.frames[0].dark, 0, 0);
const px = cx2.getImageData(0, 0, cl.width, cl.height).data;
const prof = new Float32Array(cl.width);
for (let x = 0; x < cl.width; x++) {
let low = 0;
for (let y = cl.height - 1; y >= 0; y--) {
if (px[(y * cl.width + x) * 4 + 3] > 16) { low = y; break; }
}
prof[x] = low;
}
return prof;
})();
// THE BACK LANE KEEPS ITS OWN SPACE.
//
// A Drained standing beside the road and a watcher standing in the trees are
// the same drawing at two sizes, and where one stood in front of the other
// the two outlines ran together into a single shape with too many arms. No
// placement can prevent it: the watchers scroll at 0.65 and the lane at 1.0,
// so every watcher slides behind every Drained somewhere along the walk.
//
// So the renderer hands over, each frame, the screen span each back-lane
// figure covers, and a watcher that comes within CLEAR_MARGIN of one fades
// out before the two can touch, and back in once it is clear.
//
// FADED OVER TIME, NOT OVER DISTANCE. The first version faded by how close
// it was, which is right while he walks and wrong the moment he stops: a
// watcher parked inside the fade band stayed half there, a ghost, for as
// long as he stood still. Now each one is either wanted or not, and walks
// its alpha there over CLEAR_FADE_S.
const CLEAR_MARGIN = 40;
const CLEAR_FADE_S = 0.25;
const CLEAR_MAX = 32;
const clear = { camX: 0, clock: -1, n: 0, spans: new Float32Array(CLEAR_MAX * 3) };
const watcherFade = new Map(); // watcher slot -> alpha
const watcherInk = (() => {
const cl = clips.drained_watcher;
const ink = cl && cl.ink;
return ink ? { l: ink.x - cl.pivotX, r: ink.x + ink.w - cl.pivotX } : { l: -40, r: 40 };
})();
/** Whether a watcher at screen x should be showing, given the back lane. */
function watcherWanted(sx, sc) {
const wl = sx + watcherInk.l * sc;
const wr = sx + watcherInk.r * sc;
for (let k = 0; k < clear.n; k++) {
if (!(clear.spans[k * 3 + 2] > 0)) continue;
const gap = Math.max(clear.spans[k * 3] - wr, wl - clear.spans[k * 3 + 1]);
if (gap < CLEAR_MARGIN) return 0;
}
return 1;
}
/** How visible watcher slot i is right now. 0..1. */
function watcherAlpha(i, sx, sc) {
const a = watcherFade.get(i);
return a === undefined ? watcherWanted(sx, sc) : a;
}
/**
* Whether watcher slot i has anyone in it, and if so where and how big.
* ONE ROLL, used by the draw and by sample(). The two drifting apart would
* make the watcher check measure a layer that is not on screen.
*/
function watcherRoll(l, i) {
// Density at this watcher's own world position, not the prince's.
//
// And thinner again inside the village, on top of what the clearing
// already takes off. Six of them on screen plus the shapes standing
// about read as a crowd, and this place has been empty sixty years —
// the crowd belongs to the trees, not to the street.
const wx0 = (i * l.cell) / l.rate;
const density = depthAt(wx0) *
(wx0 >= BOWL.X0 && wx0 <= BOWL.X1 ? TUNING.BOWL_WATCHERS : 1);
if (hash01(i, 0x5eed) >= density) return null;
return {
wx: i * l.cell + hash01(i, 0x77f1) * l.jitter,
variant: Math.floor(hash01(i, 0x1b9d) * l.clip.count) % l.clip.count,
sc: l.scale * (0.86 + hash01(i, 0x3a5f) * 0.28),
};
}
// --- the terraces -----------------------------------------------------------
// What they are and why they look like this is in TERRACE, in tuning.js.
const HILLS_DRAWN = haze(PALETTE.HILLS, TONE.HILLS_HAZE);
const hexMix = (a, b, t) => {
const m = mix3(rgb(a), rgb(b), t).map((v) => Math.round(Math.max(0, Math.min(255, v))));
return `#${m.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
};
const lighter = (hex, d) => atLuminance(hex, luminance(hex) + d);
const laneTop = groundY - TUNING.BACK_LANE_RISE;
/** The edge of step k, near to far. Step 0 is the back lane itself. */
const stepTop = (k) => laneTop - TERRACE.STEPS[k].above;
const STEP_COL = TERRACE.STEPS.map((S) => ({
wall: hexMix(atLuminance(TERRACE.WALL, TERRACE.WALL_LUM + S.wallUp), HILLS_DRAWN, S.blend),
lip: hexMix(atLuminance(TERRACE.FIELD, TERRACE.WALL_LUM + S.wallUp + S.lip), lighter(HILLS_DRAWN, 3), S.blend),
shadow: hexMix(atLuminance(TERRACE.WALL, TERRACE.SHADOW_LUM), HILLS_DRAWN, S.blend * 0.5),
}));
// Under the torch the first step is a retaining wall of laid stone, the way
// the road under it is earth with stones in it.
const LIT_WALL = '#a39684';
const LIT_LIP = '#c9b89c';
/**
* One flat step, drawn fresh: a handful of fields per screen, each a few px
* up or down from the next on the steps that wobble. The field edge first,
* then the wall under it, lipH lower.
*/
function drawStep(c, k, camX, pass, viewW, viewH2) {
const S = TERRACE.STEPS[k];
const rate = S.rate ?? layers.find((l) => l.key === 'watchers').rate;
const shift = camX * rate;
const top = stepTop(k);
const lit = pass === 'lit' && k === 0;
const SEG = S.seg ?? 520;
const j0 = Math.floor((shift - 60) / SEG);
const j1 = Math.ceil((shift + viewW + 60) / SEG);
// Three fills of the same edge, each a little lower: the field top, a
// two-pixel shadow where the wall starts, then the wall.
for (let layer = 0; layer < 3; layer++) {
const dy = layer === 0 ? 0 : layer === 1 ? S.lipH : S.lipH + 2;
c.beginPath();
c.moveTo(j0 * SEG - shift, viewH2);
for (let j = j0; j <= j1; j++) {
const off = S.wobble ? Math.round((hash01(j, 0x51 + k * 97) - 0.5) * 2 * S.wobble) : 0;
const xa = j * SEG - shift;
const y = top + off + dy;
c.lineTo(xa, y);
// Grass on the field edge, on the back lane's own step only.
if (S.tufts && layer === 0) {
const n = 1 + Math.floor(hash01(j, 0x77) * 3);
for (let t = 0; t < n; t++) {
const tx = xa + SEG * (t + 0.3 + 0.4 * hash01(j * 13 + t, 0x99)) / n;
const h = 5 + 7 * hash01(j * 17 + t, 0x3c);
c.lineTo(tx - 3, y); c.lineTo(tx - 1, y - h); c.lineTo(tx + 1, y - h * 0.6); c.lineTo(tx + 3, y);
}
}
c.lineTo(xa + SEG, y);
}
c.lineTo((j1 + 1) * SEG - shift, viewH2);
c.closePath();
c.fillStyle = layer === 0 ? (lit ? LIT_LIP : STEP_COL[k].lip)
: layer === 1 ? (lit ? '#000' : STEP_COL[k].shadow)
: (lit ? LIT_WALL : STEP_COL[k].wall);
c.fill();
}
if (lit) {
c.strokeStyle = '#000';
c.lineWidth = 2;
c.beginPath();
for (let j = Math.floor((shift - 50) / 46); j * 46 - shift < viewW + 20; j++) {
const x = j * 46 - shift + hash01(j, 0xab) * 14;
const row = hash01(j, 0xcd) < 0.5 ? 0 : 1;
c.moveTo(x, top + S.lipH + 2 + row * 22);
c.lineTo(x, top + 28 + row * 22);
}
c.moveTo(0, top + 30);
c.lineTo(viewW, top + 30);
c.stroke();
}
return 1;
}
/**
* The rows of fields up the hills' lower slopes, baked once into a strip the
* width of one hills tile and drawn with the hills, at their rate — they are
* ON the hills, so they move with them. Each row is the ridge again, scaled
* down toward the third step: flatter and browner the lower it is, closer to
* the hills' own colour the higher. The top of every hill is left alone.
*/
let rowsTile = null;
function buildRowsTile(hl) {
const sc = hl.scale ?? 1;
const cl = hl.clip;
const tileW = Math.round(cl.width * sc);
const hillsTop = hl.baseY - cl.pivotY * sc;
const bottom = stepTop(2);
const ridgeAt = (x) => hillsTop + hillProfile[Math.min(cl.width - 1, Math.floor(x / sc))] * sc;
let y0 = bottom;
for (let x = 0; x < tileW; x += 2) y0 = Math.min(y0, bottom - (bottom - ridgeAt(x)) * TERRACE.ROWS[0] - 4);
y0 = Math.floor(y0) - 2;
const cv = document.createElement('canvas');
cv.width = tileW;
cv.height = Math.max(1, viewH - y0);
const g = cv.getContext('2d');
TERRACE.ROWS.forEach((h, k) => {
const t = 1 - h / TERRACE.ROWS[0]; // 0 on the top row, near 1 on the bottom
const face = hexMix(HILLS_DRAWN, STEP_COL[2].wall, 0.25 + 0.7 * t);
const lip = lighter(face, 3 + 4 * t);
const lipH = 3 + 3 * t;
const fieldW = 150 + 90 * t; // lower down is nearer, so wider
const yAt = (x) => {
const off = Math.round((hash01(Math.floor(x / fieldW), 0x61 + k * 7) - 0.5) * 6);
return Math.min(bottom, bottom - (bottom - ridgeAt(x)) * h + off) - y0;
};
for (const [col, dy] of [[lip, 0], [face, lipH]]) {
g.beginPath();
g.moveTo(0, cv.height);
for (let x = 0; x <= tileW; x += 3) g.lineTo(x, yAt(Math.min(x, tileW - 1)) + dy);
g.lineTo(tileW, cv.height);
g.closePath();
g.fillStyle = col;
g.fill();
}
});
return { img: cv, y: y0, w: tileW };
}
function drawRows(c, camX, viewW) {
const hl = layers.find((l) => l.key === 'hills');
if (!hl || !hillProfile) return 0;
rowsTile ??= buildRowsTile(hl);
const shift = Math.round(camX * hl.rate);
const start = Math.floor(shift / rowsTile.w) * rowsTile.w - shift;
let n = 0;
for (let x = start; x < viewW; x += rowsTile.w) { c.drawImage(rowsTile.img, x, rowsTile.y); n++; }
return n;
}
const layers = [
{
key: 'hills', clip: clips.bg_treeline, rate: 0.15,
kind: 'tile', scale: 1.25, baseY: groundY + 24,
tinted: hillsTint, litMode: 'dark', order: 0,
},
// The fields on the hills' slopes. They are the hills, so they move with
// them; see drawRows.
{ key: 'terrace_rows', kind: 'rows', rate: 0.15, order: 0.5 },
{
// Overhead foliage is NEAR, so it scrolls fast even though it is a
// background element. Slow would read as a distant ceiling.
key: 'canopy', clip: clips.bg_canopy, rate: 0.55, soft: canopySoft,
kind: 'canopy', scale: 1, litMode: 'dark', order: 1,
},
{ key: 'terrace_3', kind: 'terrace', step: 2, order: 1.9 },
{
// Background people. They never move, they never react, and because this
// layer hands the lit pass its DARK art the torch can never reach one —
// a watcher stays a black shape for ever and the player will not learn
// who a single one of them was.
//
// Density rides the SAME depth curve as the canopy. One curve, two
// systems: the forest fills up at exactly the rate the sky runs out.
// Existence is rolled per cell against the density at that cell's OWN
// world position, so a watcher that is there is always there — walking
// back and forth cannot make one appear or vanish.
key: 'watchers', clip: clips.drained_watcher, rate: 0.65,
kind: 'watchers', cell: 200, jitter: 185, scale: 0.44,
// Their feet sit ABOVE the ground line, not on it. Shrinking them alone
// reads as small people standing next to him; standing them further up
// the frame is what reads as distance.
//
// AND ON THE THIRD TERRACE, which scrolls at their own rate. They stood
// on nothing at 26px up; with the back lane on the first step at 56 they
// were higher up the screen than the drained in front of them.
tinted: watcherTint, baseY: stepTop(2), litMode: 'dark', order: 2,
},
// The two nearer steps, in front of the crowd. The first IS the back lane.
{ key: 'terrace_2', kind: 'terrace', step: 1, order: 2.5 },
{ key: 'terrace_1', kind: 'terrace', step: 0, order: 2.9 },
{
// The near layer is what makes it read as a forest rather than a
// backdrop. One trunk every few seconds is enough.
key: 'trunks_near', clip: clips.bg_trunks_near, rate: 1.80, soft: trunkSoft,
kind: 'scatter', cell: 2400, jitter: 1100, scale: 1,
baseY: groundY + 210, litMode: 'lit', near: true, order: 9,
},
];
/** The sky itself, which is the background fill rather than a layer. */
function drawSky(c, camX, viewW) {
const shift = Math.round(camX * 0.06) % SKY_TILE_W;
const start = -((shift % SKY_TILE_W) + SKY_TILE_W) % SKY_TILE_W;
for (let x = start; x < viewW; x += SKY_TILE_W) c.drawImage(skyStrip, x, 0);
}
function drawLayer(c, layer, camX, viewW, viewH2, pass, worldX) {
if (layer.kind === 'rows') return drawRows(c, camX, viewW);
if (layer.kind === 'terrace') return drawStep(c, layer.step, camX, pass, viewW, viewH2);
const clip = layer.clip;
if (!clip) return 0;
const mode = pass === 'lit' ? layer.litMode : 'dark';
const shift = Math.round(camX * layer.rate);
let drawn = 0;
if (layer.kind === 'canopy') {
// Slides down as he goes deeper. At depth 0 it is entirely off the top of
// the frame; at depth 1 it has eaten nearly all the sky.
const depth = depthAt(worldX);
if (depth <= 0.001) return 0;
const top = -clip.height + depth * TUNING.CANOPY_MAX_DROP;
const img = layer.soft ? layer.soft[0][mode] : clip.frames[0][mode];
const tileW = clip.width;
const start = Math.floor(shift / tileW) * tileW - shift;
for (let x = start; x < viewW; x += tileW) {
c.drawImage(img, x, top);
drawn++;
}
return drawn;
}
if (layer.kind === 'tile') {
const sc = layer.scale ?? 1;
// The hills use the tinted bitmap; there is no lit variant of a mid band.
const img = layer.tinted ? layer.tinted[0] : clip.frames[0][mode];
const tileW = Math.round(clip.width * sc);
const start = Math.floor(shift / tileW) * tileW - shift;
for (let x = start; x < viewW; x += tileW) {
c.drawImage(img, x, layer.baseY - clip.pivotY * sc, tileW, clip.height * sc);
drawn++;
}
return drawn;
}
if (layer.kind === 'watchers') {
const { cell, scale } = layer;
const margin = clip.width * scale;
const first = Math.floor((shift - margin) / cell);
const last = Math.ceil((shift + viewW + margin) / cell);
const alpha0 = c.globalAlpha;
for (let i = first; i <= last; i++) {
const w = watcherRoll(layer, i);
if (!w) continue;
const sx = Math.round(w.wx - shift);
if (sx < -margin || sx > viewW + margin) continue;
// Out of the way of anyone standing in the back lane. See CLEAR_MARGIN.
const a = watcherAlpha(i, sx, w.sc);
if (a <= 0.01) continue;
c.globalAlpha = alpha0 * a;
// Never mirrored. Every one of them is already facing the way he is
// coming from before it enters the frame. Nothing turns to look.
// Always the tinted bitmap, in both passes. The torch cannot change
// one of these and it cannot brighten one.
c.drawImage(layer.tinted[w.variant], sx - clip.pivotX * w.sc,
layer.baseY - clip.pivotY * w.sc, clip.width * w.sc, clip.height * w.sc);
drawn++;
}
c.globalAlpha = alpha0;
return drawn;
}
// Occlusion: more near-layer objects, same value, same rate. The cheapest
// depth cue there is and it spends nothing — no new values, no soft edges.
const { jitter, scale } = layer;
const cell = layer.near ? layer.cell / Math.max(1, DEPTH.OCC) : layer.cell;
const margin = clip.width * scale;
const first = Math.floor((shift - margin) / cell);
const last = Math.ceil((shift + viewW + margin) / cell);
for (let i = first; i <= last; i++) {
const wx = i * cell + hash01(i, 0x9e37) * jitter;
const sx = Math.round(wx - shift);
if (sx < -margin || sx > viewW + margin) continue;
const variant = Math.floor(hash01(i, 0x85eb) * clip.count) % clip.count;
const sc = scale * (0.86 + hash01(i, 0xc2b2) * 0.3);
c.drawImage((layer.soft ? layer.soft[variant][mode] : clip.frames[variant][mode]),
sx - clip.pivotX * sc,
layer.baseY - clip.pivotY * sc, clip.width * sc, clip.height * sc);
drawn++;
}
return drawn;
}
// Hills blur is tone now, not an experiment toggle. DEPTH.BLUR still adds on
// top so the depth A/B keeps working.
setBlur(Math.round(TONE.HILLS_BLUR + DEPTH.BLUR), Math.round(DEPTH.BLUR_SKY ?? 0));
return {
layers,
setBlur,
setSoften,
get blurPx() { return blurPx; },
drawSky,
/** Everything behind the actors. */
drawFar(c, camX, viewW, viewH2, pass = 'dark', worldX = 0) {
let n = 0;
for (const l of layers) if (!l.near) n += drawLayer(c, l, camX, viewW, viewH2, pass, worldX);
return n;
},
/** Everything in front of the actors. */
drawNear(c, camX, viewW, viewH2, pass = 'dark', worldX = 0) {
let n = 0;
for (const l of layers) if (l.near) n += drawLayer(c, l, camX, viewW, viewH2, pass, worldX);
return n;
},
/**
* How many screen pixels of bright SKY a fight at worldX would have behind
* it. This is the placement score, and it has to account for all three
* things that eat the bright band, because scoring on the canopy alone put
* three encounters out of five BELOW the average of the spots they were
* picked over:
*
* the canopy, closing down from the top
* the hills, whose ridge line rises into it from below
* a near trunk, pure black and full height, stood across the action
*
* Columns are sampled across the width the figures occupy, not one point.
*/
skyOpenAt(worldX, viewW = 1920) {
const canopy = layers.find((l) => l.key === 'canopy');
const hills = layers.find((l) => l.key === 'hills');
const trunks = layers.find((l) => l.key === 'trunks_near');
const cc = canopy && canopy.clip;
const hc = hills && hills.clip;
const depth = depthAt(worldX);
const camX = worldX - viewW / 2;
const canopyTop = cc ? -cc.height + depth * TUNING.CANOPY_MAX_DROP : -1e6;
const hSc = hills ? (hills.scale ?? 1) : 1;
const hTileW = hc ? Math.round(hc.width * hSc) : 1;
const hTop = hc ? hills.baseY - hc.pivotY * hSc : 1e6;
// trunk columns to strike out entirely
const blocked = [];
if (trunks) {
const shift = camX * trunks.rate;
for (const wx of this.sample('trunks_near', shift - 600, shift + viewW + 600)) {
blocked.push([wx - shift - trunks.clip.width / 2, wx - shift + trunks.clip.width / 2]);
}
}
let open = 0;
for (let sx = viewW / 2 - 300; sx <= viewW / 2 + 300; sx += 6) {
if (blocked.some(([a, b]) => sx >= a && sx <= b)) continue;
let top = 0;
if (cc && depth > 0.001) {
const col = Math.floor(((sx + camX * canopy.rate) % cc.width + cc.width) % cc.width);
top = canopyTop + canopyProfile[col];
}
let bottom = 1e6;
if (hc && hillProfile) {
const tx = ((sx + camX * hills.rate) % hTileW + hTileW) % hTileW;
const col = Math.min(hc.width - 1, Math.floor(tx / hSc));
bottom = hTop + hillProfile[col] * hSc;
}
// Clip to the rows that are actually above luminance 160.
const a = Math.max(top, brightBand.top);
const b = Math.min(bottom, brightBand.bot);
open += Math.max(0, b - a);
}
return open;
},
/**
* Screen y of the canopy's lowest edge over the spot a fight at worldX
* would happen, averaged across the width of the action so one stray tooth
* does not decide it. Smaller means the canopy is higher and there is more
* bright sky behind the figures. Null before the canopy has closed at all.
*/
canopyBottomAt(worldX, viewW = 1920) {
const cl = clips.bg_canopy;
if (!canopyProfile || !cl) return null;
const depth = depthAt(worldX);
if (depth <= 0.001) return null;
const top = -cl.height + depth * TUNING.CANOPY_MAX_DROP;
const layerShift = (worldX - viewW / 2) * 0.55;
let sum = 0, n = 0;
for (let sx = viewW / 2 - 320; sx <= viewW / 2 + 320; sx += 16) {
const col = Math.floor(((sx + layerShift) % cl.width + cl.width) % cl.width);
sum += top + canopyProfile[col];
n++;
}
return sum / n;
},
/**
* The screen spans the back lane is standing in, for this frame. Flat
* triples — left, right, weight — so handing them over allocates nothing.
*/
keepClear(camX, spans, n, clock = 0) {
// A cut rather than a frame: a restart, a check staging a scene, a
// reset clock. Everything goes straight to where it should be.
const dt = clock - clear.clock;
const cut = !(dt >= 0 && dt < 0.25) || Math.abs(camX - clear.camX) > 300;
clear.camX = camX;
clear.clock = clock;
clear.n = Math.min(n, CLEAR_MAX);
for (let k = 0; k < clear.n * 3; k++) clear.spans[k] = spans[k];
// Walk every watcher on screen toward wanted or not. Once per frame,
// here, and not in the draw — the draw runs twice a frame, once for
// each pass, and a check can call it again.
const wl = layers.find((l) => l.key === 'watchers');
if (!wl) return;
const shift = Math.round(camX * wl.rate);
const margin = wl.clip.width * wl.scale;
const step = dt > 0 ? dt / CLEAR_FADE_S : 0;
for (let i = Math.floor((shift - margin) / wl.cell); i <= Math.ceil((shift + 1920 + margin) / wl.cell); i++) {
const w = watcherRoll(wl, i);
if (!w) continue;
const want = watcherWanted(Math.round(w.wx - shift), w.sc);
const was = watcherFade.get(i);
if (cut || was === undefined) { watcherFade.set(i, want); continue; }
watcherFade.set(i, want > was ? Math.min(want, was + step) : Math.max(want, was - step));
}
},
/**
* For the determinism check: trunk positions in a world range.
*
* For watchers, `shownOnly` leaves out any the back lane has faded, even
* partly. A faded one is not a watcher on screen, and measuring its torso
* measures whatever is behind it.
*/
sample(key, fromX, toX, shownOnly = false) {
const l = layers.find((x) => x.key === key);
if (!l) return [];
const out = [];
if (l.kind === 'watchers') {
for (let i = Math.floor(fromX / l.cell); i <= Math.ceil(toX / l.cell); i++) {
const w = watcherRoll(l, i);
if (!w) continue;
if (shownOnly && watcherAlpha(i, Math.round(w.wx - clear.camX * l.rate), w.sc) < 0.999) continue;
out.push(Math.round(w.wx));
}
return out;
}
if (l.kind !== 'scatter') return [];
for (let i = Math.floor(fromX / l.cell); i <= Math.ceil(toX / l.cell); i++) {
out.push(Math.round(i * l.cell + hash01(i, 0x9e37) * l.jitter));
}
return out;
},
};
}