Commit 5d38891a by PLN (Algolia)

impression: party pacing, bristles, paint-over ghosts, rare states

parent 004704eb
...@@ -165,13 +165,16 @@ function softEllipse(ctx, x, y, size, color, alpha, angle, squash, hardness = 0. ...@@ -165,13 +165,16 @@ function softEllipse(ctx, x, y, size, color, alpha, angle, squash, hardness = 0.
// (a painter computing a bad alpha must not lose the whole canvas) // (a painter computing a bad alpha must not lose the whole canvas)
if (!Number.isFinite(size) || !(size > 0)) return; if (!Number.isFinite(size) || !(size > 0)) return;
if (!Number.isFinite(alpha) || alpha <= 0) return; if (!Number.isFinite(alpha) || alpha <= 0) return;
// canvas weave + fine grain: the alpha every dab lands with carries the
// thread phase of where it lands and a breath of position noise
const a2 = clamp(alpha * weaveMul(x, y) * (0.97 + 0.03 * grain01(x, y)), 0, 1);
ctx.save(); ctx.save();
ctx.translate(x, y); ctx.translate(x, y);
if (angle) ctx.rotate(angle); if (angle) ctx.rotate(angle);
if (squash !== 1) ctx.scale(1, clamp(squash, 0.05, 8)); if (squash !== 1) ctx.scale(1, clamp(squash, 0.05, 8));
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, size); const g = ctx.createRadialGradient(0, 0, 0, 0, 0, size);
g.addColorStop(0, rgba(color, clamp(alpha, 0, 1))); g.addColorStop(0, rgba(color, a2));
g.addColorStop(clamp(hardness, 0.05, 0.95), rgba(color, alpha * 0.92)); g.addColorStop(clamp(hardness, 0.05, 0.95), rgba(color, a2 * 0.92));
g.addColorStop(1, rgba(color, 0)); g.addColorStop(1, rgba(color, 0));
ctx.fillStyle = g; ctx.fillStyle = g;
ctx.beginPath(); ctx.beginPath();
...@@ -190,6 +193,254 @@ export function dab(ctx, x, y, size, color, alpha, angle = 0, squash = 1) { ...@@ -190,6 +193,254 @@ export function dab(ctx, x, y, size, color, alpha, angle = 0, squash = 1) {
softEllipse(ctx, x, y, size, color, alpha, angle, squash, 0.55); softEllipse(ctx, x, y, size, color, alpha, angle, squash, 0.55);
} }
// ---------------------------------------------------------------------------
// BRISTLE STAMPS, CANVAS WEAVE, FINE GRAIN — why the painting stopped feeling
// digital. Three upgrades, all deterministic, all accumulate-only:
//
// 1. BRISTLE STAMPS. The textured brushes (filbert, fan, knife, drybrush, and
// the larger stipple touches) no longer stamp soft ellipses: they stamp
// pre-rendered offscreen bristle stamps — 8-24 parallel striations with
// per-strand alpha variance and tapered ends, cached per (kind, size
// bucket) across four buckets and rendered once. A stamp is an alpha
// structure (white strands on transparent); at stamp time it is tinted to
// the dab's colour through a small shared scratch canvas ('source-in'),
// then composited rotated and rng-jittered where the ellipse used to be.
// The rim/soft brushes (dab, roundDab, the glaze and wet realizations)
// keep their soft edges — that is where glow and bleed live — but still
// pick up the weave and grain below. Striations read because every layer
// of underpainting shows through the strand gaps: broken color made
// physical.
// 2. CANVAS WEAVE. Every dab's alpha is modulated by a deterministic function
// of its position: a ~2.6px thread-phase cross-hatch, up to 10% dip at the
// crossings. Scenes prime their own ground; the frame (weaveGround in
// frame.js) presses one subtle woven tile into that prime right after
// init, so the ground is woven too. No rng — a pure function of (x, y).
// 3. FINE GRAIN. 2-4% per-dab alpha noise. Where an rng stream is in hand
// (stamped dabs) it rides that stream; where none is passed (dab's
// precomputed placements) it derives from a hash of the dab's position —
// a pure function of the arguments, in the spirit of handDab's localRng.
//
// Determinism: stamp geometry rolls a module-private mulberry32 keyed by
// (kind, bucket) — identical on every machine, for every seed, in any call
// order (the cache changes WHEN a stamp is built, never WHAT it holds). Same
// seed, same painting, byte-identical freeze renders.
// ---------------------------------------------------------------------------
const WEAVE_PITCH = 2.6;
const WEAVE_AMP = 0.1;
// thread-phase alpha multiplier at a dab's position: 0.90 .. 1.0
function weaveMul(x, y) {
const k = TAU / WEAVE_PITCH;
const m = 1 - WEAVE_AMP * (0.5 + 0.5 * Math.sin(x * k) * Math.sin(y * k));
return Number.isFinite(m) ? m : 1;
}
// position hash in [0, 1) — pure, deterministic, rng-free
function grain01(x, y) {
let t = ((Math.round(x * 4) * 668265261) ^ (Math.round(y * 4) * 374761393)) >>> 0;
t = Math.imul(t ^ (t >>> 13), 1274126177);
return ((t ^ (t >>> 16)) >>> 0) / 4294967296;
}
// module-private mulberry32 (same recipe as scene-api's; stamps need no
// shared stream, only a deterministic one)
function stampRng(seed) {
let t = seed >>> 0;
return () => {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
const STAMP_BUCKETS = [4, 9, 18, 36]; // nominal dab radii, design px
const stamps = new Map(); // 'kind:bucket' -> { c, half }
let scratch = null, sctx = null;
function bucketOf(size) {
for (let i = 0; i < STAMP_BUCKETS.length; i++) {
if (size <= STAMP_BUCKETS[i] * 1.15) return STAMP_BUCKETS[i];
}
return STAMP_BUCKETS[STAMP_BUCKETS.length - 1];
}
function getStamp(kind, size) {
const b = bucketOf(size);
const key = kind + ':' + b;
let st = stamps.get(key);
if (!st) { st = renderStamp(kind, b); stamps.set(key, st); }
return st;
}
// one stamp: white bristle strands on transparent, alpha structure only
function renderStamp(kind, R) {
const rng = stampRng(((R * 0x9E3779B1) ^ (kind.charCodeAt(0) * 0x85EBCA6B) ^ 0xC2B2AE35) >>> 0);
const half = R * 1.15 + 1;
const SS = 2; // supersample: stamps survive dpr-2 displays
const c = document.createElement('canvas');
c.width = Math.ceil(half * 2 * SS);
c.height = c.width;
const g = c.getContext('2d');
g.scale(SS, SS);
const cx = half, cy = half;
if (kind === 'speck') {
// an irregular loaded dot: jittered blob, short radial striations
g.fillStyle = 'rgba(255,255,255,0.78)';
g.beginPath();
const pts = 10;
for (let i = 0; i <= pts; i++) {
const a = (i / pts) * TAU;
const rr = R * (0.72 + rng() * 0.3);
const px = cx + Math.cos(a) * rr, py = cy + Math.sin(a) * rr;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.closePath();
g.fill();
for (let i = 0; i < 7; i++) {
const a = rng() * TAU, r0 = R * (0.1 + rng() * 0.35), r1 = R * (0.55 + rng() * 0.35);
g.strokeStyle = 'rgba(255,255,255,' + (0.2 + rng() * 0.4).toFixed(3) + ')';
g.lineWidth = Math.max(0.5, R * (0.08 + rng() * 0.1));
g.lineCap = 'round';
g.beginPath();
g.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0);
g.lineTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1);
g.stroke();
}
} else {
// faint base load so the mark keeps body between the strands
g.fillStyle = 'rgba(255,255,255,0.3)';
g.beginPath();
g.arc(cx, cy, R * 0.95, 0, TAU);
g.fill();
const thin = kind === 'knife' ? 0.4 : kind === 'dry' ? 0.5 : 0.66; // strand span across
const n = Math.max(8, Math.min(24, Math.round(R * 0.7 + 7)));
for (let i = 0; i < n; i++) {
const v = n === 1 ? 0 : (i / (n - 1)) * 2 - 1;
const y0 = cy + v * R * thin + (rng() - 0.5) * R * 0.12;
g.strokeStyle = 'rgba(255,255,255,' + (0.3 + rng() * 0.55).toFixed(3) + ')';
g.lineWidth = Math.max(0.5, ((R * 2 * thin) / n) * (0.6 + rng() * 0.7));
g.lineCap = 'round';
const span = R * (kind === 'dry' ? 0.7 + rng() * 0.35 : 0.98 + rng() * 0.06);
const bow = (rng() - 0.5) * R * 0.2;
if (kind === 'dry' && rng() < 0.5) {
// a broken strand: two dashes with the dry gap between
g.beginPath();
g.moveTo(cx - span, y0);
g.lineTo(cx + span * (0.05 + rng() * 0.2), y0 + bow * 0.4);
g.stroke();
g.beginPath();
g.moveTo(cx + span * (0.35 + rng() * 0.2), y0 + bow * 0.7);
g.lineTo(cx + span, y0 + bow);
g.stroke();
} else {
g.beginPath();
g.moveTo(cx - span, y0);
g.quadraticCurveTo(cx, y0 + bow, cx + span, y0 + bow * 0.6);
g.stroke();
}
}
}
// finishing, in erase mode: tapered ends and a softened rim (the knife's
// edge stays crisper — its ridge does the crisping at paint time)
g.globalCompositeOperation = 'destination-out';
const tw = R * 0.55;
let lg = g.createLinearGradient(cx - R * 1.2, 0, cx - R * 1.2 + tw, 0);
lg.addColorStop(0, 'rgba(0,0,0,0.95)');
lg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = lg;
g.fillRect(0, 0, cx - R * 1.2 + tw + 1, cy * 2);
lg = g.createLinearGradient(cx + R * 1.2, 0, cx + R * 1.2 - tw, 0);
lg.addColorStop(0, 'rgba(0,0,0,0.95)');
lg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = lg;
g.fillRect(cx + R * 1.2 - tw - 1, 0, tw + 1, cy * 2);
const rimStart = kind === 'knife' ? 0.8 : 0.62;
const rimA = kind === 'knife' ? 0.5 : 0.75;
const rg = g.createRadialGradient(cx, cy, R * rimStart, cx, cy, R * 1.12);
rg.addColorStop(0, 'rgba(0,0,0,0)');
rg.addColorStop(1, 'rgba(0,0,0,' + rimA + ')');
g.fillStyle = rg;
g.fillRect(0, 0, cx * 2, cy * 2);
g.globalCompositeOperation = 'source-over';
return { c, half };
}
// tint a cached stamp to a colour ('source-in' keeps the stamp's alpha
// structure, replaces its white with the pigment). Tinted stamps are cached
// per (kind, bucket, colour quantized to 16 levels/channel — sub-4/255 error,
// invisible, and scene palettes repeat heavily so the hit rate is high): the
// hot path becomes a single drawImage. On a miss the shared scratch canvas
// builds the tint and it is copied out to its own canvas — the scratch is
// render-only state, never read back, so determinism is untouched (which
// tint lands in the cache changes WHEN work happens, never WHAT is painted).
const tintCache = new Map(); // 'kind:R:rgb4' -> tinted canvas
const TINT_CACHE_MAX = 256;
function tintedStamp(kind, size, color) {
const st = getStamp(kind, size);
const R = bucketOf(size);
const [r, g, b] = parseColor(color);
const key = kind + ':' + R + ':' + (((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4));
let tinted = tintCache.get(key);
if (!tinted) {
if (!scratch) {
scratch = document.createElement('canvas');
scratch.width = scratch.height = 200; // largest bucket renders ~169px
sctx = scratch.getContext('2d');
}
const w = st.c.width;
sctx.clearRect(0, 0, w, w);
sctx.drawImage(st.c, 0, 0);
sctx.globalCompositeOperation = 'source-in';
sctx.fillStyle = rgba(color, 1);
sctx.fillRect(0, 0, w, w);
sctx.globalCompositeOperation = 'source-over';
tinted = document.createElement('canvas');
tinted.width = w;
tinted.height = w;
tinted.getContext('2d').drawImage(scratch, 0, 0);
tintCache.set(key, tinted);
if (tintCache.size > TINT_CACHE_MAX) tintCache.delete(tintCache.keys().next().value);
}
return { c: tinted, half: st.half, R };
}
// composite one bristle stamp at (x, y): optional soft underlay for body,
// tinted stamp rotated/jittered to the dab, weave- and grain-modulated alpha.
// Consumes exactly four rng values (jx, jy, ja, grain).
function drawStamp(ctx, kind, size, color, alpha, x, y, angle, squash, rng, under = 0.45, underColor = null) {
if (!Number.isFinite(size) || !(size > 0)) return;
if (!Number.isFinite(alpha) || alpha <= 0) return;
if (under >= 0.2) {
softEllipse(ctx, x, y, size, underColor || color, alpha * under, angle, squash == null ? 1 : squash, 0.8);
}
const ts = tintedStamp(kind, size, color);
const s = size / ts.R; // bucket radius -> dab radius
const ext = ts.half * s;
// manual state swap, no save/restore: a per-dab state-stack pop measured
// ~6x the drawImage itself, so the transform+alpha are set and put back by
// hand — the only two things this composite touches.
const jx = (rng() - 0.5) * size * 0.12, jy = (rng() - 0.5) * size * 0.12;
const ja = (rng() - 0.5) * 0.14;
const gr = 0.96 + 0.04 * rng(); // per-dab grain
const pa = ctx.globalAlpha;
const base = ctx.getTransform();
const m = new DOMMatrix();
m.translateSelf(x + jx, y + jy);
m.rotateSelf(((angle || 0) + ja) * 180 / Math.PI);
const sq = clamp(squash == null ? 1 : squash, 0.05, 8);
if (sq !== 1) m.scaleSelf(1, sq);
ctx.globalAlpha = clamp(alpha * weaveMul(x, y) * gr, 0, 1);
ctx.setTransform(base.multiply(m));
ctx.drawImage(ts.c, -ext, -ext, ext * 2, ext * 2);
ctx.setTransform(base);
ctx.globalAlpha = pa;
}
/** /**
* roundDab — a loaded round-brush touch: one soft ellipse plus 1-2 rng- * roundDab — a loaded round-brush touch: one soft ellipse plus 1-2 rng-
* offset satellites so no two touches read as perfect circles. PAINT. * offset satellites so no two touches read as perfect circles. PAINT.
...@@ -218,8 +469,9 @@ export function filbert(ctx, rng, p) { ...@@ -218,8 +469,9 @@ export function filbert(ctx, rng, p) {
const angle = p.angle || 0; const angle = p.angle || 0;
const squash = p.squash == null ? 0.45 : p.squash; const squash = p.squash == null ? 0.45 : p.squash;
const cols = _broken(rng, color, 0.3); const cols = _broken(rng, color, 0.3);
softEllipse(ctx, x, y, size, cols[0], alpha, angle, squash, 0.72); // bristle stamp: striated cat's-tongue body over a soft underlay carrying
if (cols[1]) softEllipse(ctx, x, y, size * 0.72, cols[1], alpha * 0.75, angle, squash, 0.7); // the broken-color neighbour, so the strand gaps shimmer with it
drawStamp(ctx, 'filbert', size, cols[0], alpha, x, y, angle, squash, rng, 0.5, cols[1] || cols[0]);
// bristle drag: a couple of thin streaks along the major axis // bristle drag: a couple of thin streaks along the major axis
const streaks = 1 + Math.floor(rng() * 2); const streaks = 1 + Math.floor(rng() * 2);
for (let i = 0; i < streaks; i++) { for (let i = 0; i < streaks; i++) {
...@@ -248,8 +500,9 @@ export function fanDab(ctx, rng, p) { ...@@ -248,8 +500,9 @@ export function fanDab(ctx, rng, p) {
const u = n === 1 ? 0 : (i / (n - 1)) * 2 - 1; // -1..1 across the fan const u = n === 1 ? 0 : (i / (n - 1)) * 2 - 1; // -1..1 across the fan
const off = u * size * spread; const off = u * size * spread;
const col = cols[i % cols.length]; const col = cols[i % cols.length];
softEllipse(ctx, x - Math.sin(angle) * off, y + Math.cos(angle) * off, // each fan track is its own bristle stamp: parallel hair tracks
size * (0.55 + rng() * 0.3), col, alpha * (0.6 + rng() * 0.4), angle, squash, 0.7); drawStamp(ctx, 'fan', size * (0.55 + rng() * 0.3), col, alpha * (0.6 + rng() * 0.4),
x - Math.sin(angle) * off, y + Math.cos(angle) * off, angle, squash, rng, 0.35);
} }
} }
...@@ -267,22 +520,16 @@ export function knife(ctx, rng, p) { ...@@ -267,22 +520,16 @@ export function knife(ctx, rng, p) {
const angle = p.angle || 0; const angle = p.angle || 0;
const th = size * clamp(p.squash == null ? 0.35 : p.squash, 0.08, 1); const th = size * clamp(p.squash == null ? 0.35 : p.squash, 0.08, 1);
const col = p.warmth ? temperatureShift(color, (rng() - 0.5) * p.warmth) : color; const col = p.warmth ? temperatureShift(color, (rng() - 0.5) * p.warmth) : color;
// the two ragged-edge dice keep their beats in the stream — they now steer
// the stamp's placement jitter, so long knife runs still break symmetry
const rag1 = rng(), rag2 = rng();
// bristle slab: the knife stamp (thin, crisper rim) carries the striations
drawStamp(ctx, 'knife', size, col, alpha,
x + (rag1 - 0.5) * size * 0.16, y + (rag2 - 0.5) * th * 0.5,
angle, th / (size * 0.62), rng, 0.5);
ctx.save(); ctx.save();
ctx.translate(x, y); ctx.translate(x, y);
ctx.rotate(angle); ctx.rotate(angle);
// slab: hard top edge, soft bottom edge via gradient across thickness
const g = ctx.createLinearGradient(0, -th / 2, 0, th / 2);
g.addColorStop(0, rgba(col, clamp(alpha, 0, 1)));
g.addColorStop(0.55, rgba(col, alpha * 0.85));
g.addColorStop(1, rgba(col, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(-size, -th / 2); // crisp edge, full length
ctx.lineTo(size, -th / 2);
ctx.lineTo(size * (0.82 + rng() * 0.12), th / 2); // ragged soft edge
ctx.lineTo(-size * (0.8 + rng() * 0.15), th / 2);
ctx.closePath();
ctx.fill();
// impasto ridge: light catching the pushed paint along the hard edge // impasto ridge: light catching the pushed paint along the hard edge
ctx.globalAlpha = clamp(alpha, 0, 1) * 0.5; ctx.globalAlpha = clamp(alpha, 0, 1) * 0.5;
ctx.fillStyle = mix(col, '#fff8ec', 0.45); ctx.fillStyle = mix(col, '#fff8ec', 0.45);
...@@ -310,11 +557,11 @@ export function drybrush(ctx, rng, p) { ...@@ -310,11 +557,11 @@ export function drybrush(ctx, rng, p) {
if (rng() > density) continue; // the skip IS the technique if (rng() > density) continue; // the skip IS the technique
const d = (t - 0.5) * step * n; const d = (t - 0.5) * step * n;
const wobble = (rng() - 0.5) * size * 0.8; const wobble = (rng() - 0.5) * size * 0.8;
softEllipse(ctx, drawStamp(ctx, 'dry', size * (0.5 + rng() * 0.6), cols[i % cols.length],
alpha * (0.4 + rng() * 0.5),
x + Math.cos(angle) * d - Math.sin(angle) * wobble, x + Math.cos(angle) * d - Math.sin(angle) * wobble,
y + Math.sin(angle) * d + Math.cos(angle) * wobble, y + Math.sin(angle) * d + Math.cos(angle) * wobble,
size * (0.5 + rng() * 0.6), cols[i % cols.length], angle, squash, rng, 0.12); // barely any underlay: the skip IS the technique
alpha * (0.4 + rng() * 0.5), angle, squash, 0.75);
} }
} }
...@@ -331,9 +578,15 @@ export function stipple(ctx, rng, p) { ...@@ -331,9 +578,15 @@ export function stipple(ctx, rng, p) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const a = rng() * TAU; const a = rng() * TAU;
const r = (rng() + rng() - 1) * size * scatter; // triangular: denser center const r = (rng() + rng() - 1) * size * scatter; // triangular: denser center
softEllipse(ctx, x + Math.cos(a) * r, y + Math.sin(a) * r * 0.85, const rr = size * (0.06 + rng() * 0.14);
size * (0.06 + rng() * 0.14), cols[i % cols.length], const al = alpha * (0.5 + rng() * 0.5);
alpha * (0.5 + rng() * 0.5), 0, 1, 0.85); const px = x + Math.cos(a) * r, py = y + Math.sin(a) * r * 0.85;
if (rr >= 2.2) {
// big enough to hold a bristle: an irregular loaded speck, not a dot
drawStamp(ctx, 'speck', rr, cols[i % cols.length], al, px, py, a, 1, rng, 0.2);
} else {
softEllipse(ctx, px, py, rr, cols[i % cols.length], al, 0, 1, 0.85);
}
} }
} }
...@@ -684,6 +937,27 @@ function realizeImpasto(ctx, rng, hand, p) { ...@@ -684,6 +937,27 @@ function realizeImpasto(ctx, rng, hand, p) {
if (long) capsulePath(ctx, len, w); if (long) capsulePath(ctx, len, w);
else ctx.arc(0, 0, w / 2, 0, TAU); else ctx.arc(0, 0, w / 2, 0, TAU);
ctx.fill(); ctx.fill();
// bristle grooves pressed into the slab: short darker strands along the
// stroke axis, straight in the slab's own rotated frame — no clip, no
// save/restore (a per-dab clip/restore measured 6x the fill itself). Reads
// as pushed hair, not plastic. Skipped below w=3 — a three-pixel slab has
// no room for hair.
if (w >= 3) {
const strands = 3 + Math.floor(rng() * 4); // 3-6 grooves
ctx.strokeStyle = rgba(mix(col, '#1c1a18', 0.3), a * 0.35);
ctx.lineWidth = Math.max(0.5, w * 0.1);
ctx.lineCap = 'round';
const span = long ? len : w;
for (let i = 0; i < strands; i++) {
const yy = (rng() - 0.5) * w * 0.55;
const l = span * (0.32 + rng() * 0.12);
const x0 = -l * 0.5 + (rng() - 0.5) * l * 0.25;
ctx.beginPath();
ctx.moveTo(x0, yy);
ctx.lineTo(x0 + l, yy + (rng() - 0.5) * w * 0.15);
ctx.stroke();
}
}
// under-shadow along the lower rim: depth on the far side of the slab // under-shadow along the lower rim: depth on the far side of the slab
ctx.strokeStyle = rgba(mix(col, '#1c1a18', 0.35), a * 0.30); ctx.strokeStyle = rgba(mix(col, '#1c1a18', 0.35), a * 0.30);
ctx.lineWidth = Math.max(0.6, w * 0.14); ctx.lineWidth = Math.max(0.6, w * 0.14);
...@@ -778,10 +1052,10 @@ function realizeDry(ctx, rng, hand, p) { ...@@ -778,10 +1052,10 @@ function realizeDry(ctx, rng, hand, p) {
if (rng() < 0.32) continue; // the skip IS the technique if (rng() < 0.32) continue; // the skip IS the technique
const t = segs === 1 ? 0 : (i / (segs - 1)) * 2 - 1; const t = segs === 1 ? 0 : (i / (segs - 1)) * 2 - 1;
const wob = (rng() - 0.5) * w * 0.9; const wob = (rng() - 0.5) * w * 0.9;
softEllipse(ctx, drawStamp(ctx, 'dry', w * (0.5 + rng() * 0.5), matte, a * (0.6 + rng() * 0.4),
p.x + ux * t * len * 0.5 - uy * wob, p.x + ux * t * len * 0.5 - uy * wob,
p.y + uy * t * len * 0.5 + ux * wob, p.y + uy * t * len * 0.5 + ux * wob,
w * (0.5 + rng() * 0.5), matte, a * (0.6 + rng() * 0.4), ang, 0.35, 0.8); ang, 0.35, rng, 0.15); // the dry hand keeps its skips
} }
} }
......
// Atelier Impressionniste — the FRAME, not the paintings. // Atelier Impressionniste — the FRAME, not the paintings.
// One painting emerges at a time on a persistent offscreen canvas; when it // One painting emerges at a time on a persistent offscreen canvas; when it
// completes, hold, crossfade, next painting. Loops forever. // completes, a long admire hold, then the NEXT PAINTING PAINTS DIRECTLY OVER
// the finished one — no wipe, no crossfade. The show never stops moving.
// Loops forever.
import scenes from './scenes/manifest.js'; import scenes from './scenes/manifest.js';
import { mulberry32 } from './scene-api.js'; import { mulberry32, rareOverlay, rareSubtitle } from './scene-api.js';
import { dab, brokenColor } from './brushes.js'; import { dab, brokenColor } from './brushes.js';
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const HOLD_MS = 7000; // Party rhythm (owner decision): ~35s of emergence, ~12s of admire hold. Scenes
const FADE_MS = 1500; // declare their own durationMs (untouched); the frame paces playback so ANY
// declared duration lands on EMERGE_MS of wall clock — progress advances
// durationMs/EMERGE_MS times faster than real time (55000 -> ~1.57x). The menu's
// speed control stays RELATIVE to this default: ½× ≈ 70s, 2× ≈ 17.5s.
const EMERGE_MS = 35000;
const paceOf = (sc) => sc.durationMs / EMERGE_MS;
const HOLD_MS = 12000;
const IDLE_MS = 3000; const IDLE_MS = 3000;
const REPLAY_STEPS = 480; const REPLAY_STEPS = 480;
// LAYERED GHOSTS — the paint-over transition, with no baked history. The work
// canvases hold ONLY the current scene (its ground, its strokes); completed
// paintings become ghost layers composited UNDER the work at blit time,
// decaying by generation. A visitor sees at most the current painting, one
// strong ghost, one faint ghost — never more than three paintings blended.
// Nothing is baked: the incoming ground ramps to full opacity across the
// emergence while the ghost decays, so by completion the new painting stands
// alone (the hold and the archive are pristine) and each new ghost replaces
// the old. The scenes' stroke programs are untouched and deterministic; the
// ghost stack is show-history, the same allowed variance as the mutation.
const GHOST_A = [0.9, 0.55]; // gen-1 ghost alpha, emergence start -> end
const GHOST2_A = 0.3; // gen-2 ghost alpha, beneath gen-1
const GROUND_A = [0.6, 1.0]; // incoming ground alpha, emergence start -> end
const baseSeed = params.has('seed') ? (parseInt(params.get('seed'), 10) || 0) : 0; const baseSeed = params.has('seed') ? (parseInt(params.get('seed'), 10) || 0) : 0;
const freeze = params.has('freeze') ? Math.min(1, Math.max(0, parseFloat(params.get('freeze')) || 0)) : null; const freeze = params.has('freeze') ? Math.min(1, Math.max(0, parseFloat(params.get('freeze')) || 0)) : null;
...@@ -20,7 +41,7 @@ const still = params.has('still'); ...@@ -20,7 +41,7 @@ const still = params.has('still');
// --- the director's chair: conditions, speed, auto-advance ------------------- // --- the director's chair: conditions, speed, auto-advance -------------------
const LS_KEY = 'atelier-constraints'; const LS_KEY = 'atelier-constraints';
let constraints = { timeOfDay: null, season: null, weather: null }; // null = any let constraints = { timeOfDay: null, season: null, weather: null, rare: null }; // null = any
let speedMul = 1; // 0.5 | 1 | 2 — divides the effective durationMs let speedMul = 1; // 0.5 | 1 | 2 — divides the effective durationMs
let autoplay = params.get('autoplay') !== '0'; let autoplay = params.get('autoplay') !== '0';
try { try {
...@@ -28,6 +49,7 @@ try { ...@@ -28,6 +49,7 @@ try {
if (saved.timeOfDay) constraints.timeOfDay = saved.timeOfDay; if (saved.timeOfDay) constraints.timeOfDay = saved.timeOfDay;
if (saved.season) constraints.season = saved.season; if (saved.season) constraints.season = saved.season;
if (saved.weather) constraints.weather = saved.weather; if (saved.weather) constraints.weather = saved.weather;
if (saved.technique) constraints.technique = saved.technique;
if (saved.speed === 0.5 || saved.speed === 2) speedMul = saved.speed; if (saved.speed === 0.5 || saved.speed === 2) speedMul = saved.speed;
if (!params.has('autoplay') && saved.autoplay === false) autoplay = false; if (!params.has('autoplay') && saved.autoplay === false) autoplay = false;
} catch {} } catch {}
...@@ -59,16 +81,31 @@ if (params.has('tech')) { ...@@ -59,16 +81,31 @@ if (params.has('tech')) {
} }
} }
// ?rare=comet|etoile|balloon|shower|none — force the rare-event axis (testing
// hook): 'none' force-nulls the everyday ~97% case, an event name forces it.
// Without the param, makeConditions rolls the rare die itself.
if (params.has('rare')) {
const t = params.get('rare').trim().toLowerCase();
if (['comet', 'etoile', 'balloon', 'shower', 'none'].includes(t)) {
constraints.rare = t;
}
}
const canvas = document.getElementById('stage'); const canvas = document.getElementById('stage');
const vctx = canvas.getContext('2d'); const vctx = canvas.getContext('2d');
const titleEl = document.getElementById('title'); const titleEl = document.getElementById('title');
const artistEl = document.getElementById('artist'); const artistEl = document.getElementById('artist');
const fillEl = document.getElementById('fill'); const fillEl = document.getElementById('fill');
const dotsEl = document.getElementById('dots');
let dpr = 1, rect = null; let dpr = 1, rect = null;
let showNumber = 0, idx = 0, scene = null, pendingIdx = 0; let showNumber = 0, idx = 0, scene = null, toileNo = 0;
let offA = null, octx = null, offB = null; // offB holds the next painting during crossfade // LAYERED GHOSTS canvases: the current scene lives on the ground (its init
// wash) and work (its strokes) canvases; at completion they flatten into
// completedC, which the hold displays and breathes on, and which becomes the
// next first-generation ghost. octx is the stroke target, cctx the hold one.
let groundC = null, workC = null, completedC = null;
let octx = null, cctx = null;
let ghosts = []; // [gen-1, gen-2] — pristine paintings, newest first
let phase = 'painting', t0 = 0, lastTo = 0, pausedAt = null; let phase = 'painting', t0 = 0, lastTo = 0, pausedAt = null;
let archivedKey = null; let archivedKey = null;
...@@ -95,9 +132,45 @@ function makeOffscreen() { ...@@ -95,9 +132,45 @@ function makeOffscreen() {
} }
const seedFor = () => (baseSeed + showNumber) >>> 0; const seedFor = () => (baseSeed + showNumber) >>> 0;
// One-time canvas-weave pass over the primed ground. Scenes lay their own
// prime inside init() (a full-frame wash fillRect), which the frame invokes via
// callInit — so immediately after priming, the frame presses a very subtle
// woven texture into the wet ground: ~2.6px thread pitch, alternating shadow
// and light threads, composited 'source-atop' so it lands only where paint
// exists. Strokes laid afterwards sit over it and carry their own per-dab
// weave modulation (brushes.js). Pure geometry, no rng — deterministic.
const WEAVE_PITCH = 2.6;
let weaveTile = null;
function weaveGround(ctx) {
if (!weaveTile) {
const T = 26; // ten thread periods: the tile repeats seamlessly
const c = document.createElement('canvas');
c.width = T;
c.height = T;
const t = c.getContext('2d');
for (let i = 0; i < 10; i++) {
const p = i * WEAVE_PITCH;
t.fillStyle = 'rgba(28,24,18,0.045)';
t.fillRect(p, 0, 1.15, T);
t.fillRect(0, p, T, 1.15);
t.fillStyle = 'rgba(255,250,238,0.035)';
t.fillRect(p + 1.3, 0, 1.05, T);
t.fillRect(0, p + 1.3, T, 1.05);
}
weaveTile = c;
}
ctx.save();
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = ctx.createPattern(weaveTile, 'repeat');
ctx.fillRect(0, 0, rect.w, rect.h);
ctx.restore();
}
const rngFor = (from) => mulberry32(((seedFor() * 7919) ^ Math.floor(from * 4096)) >>> 0); const rngFor = (from) => mulberry32(((seedFor() * 7919) ^ Math.floor(from * 4096)) >>> 0);
// Dedicated conditions stream: distinct from the init and stroke streams. // Dedicated conditions stream: distinct from the init and stroke streams.
const condRngFor = () => mulberry32(((seedFor() * 69621) ^ 0x5DEECE66) >>> 0); const condRngFor = () => mulberry32(((seedFor() * 69621) ^ 0x5DEECE66) >>> 0);
// Dedicated rare-overlay stream per window: distinct from all of the above.
const rareRngFor = (from) => mulberry32(((seedFor() * 104729) ^ ((Math.floor(from * 4096) + 0x0DDBA11) >>> 0)) >>> 0);
let conditions; // the current scene's rolled conditions; undefined for legacy scenes let conditions; // the current scene's rolled conditions; undefined for legacy scenes
...@@ -109,30 +182,109 @@ function callInit(sc, ctx, w, h) { ...@@ -109,30 +182,109 @@ function callInit(sc, ctx, w, h) {
sc.init(ctx, w, h, mulberry32(seedFor()), conditions); sc.init(ctx, w, h, mulberry32(seedFor()), conditions);
} }
function startScene(i, replayTo = 0) { // One quantized progress window of painting: the scene's strokes, then the
// frame-level rare overlay (comet, étoile, montgolfière, pluie d'étoiles) —
// called here rather than inside the scenes' weatherOverlay so every scene
// inherits rare states, clear skies included, with zero scene edits.
function drawWindow(from, to) {
scene.draw(octx, rect.w, rect.h, rngFor(from), from, to, conditions);
if (conditions && conditions.rare) {
rareOverlay(octx, rect.w, rect.h, rareRngFor(from), conditions, from, to);
}
}
// entomb(): the painting on the easel, however far it got, becomes the new
// first-generation ghost. A completed one moves wholesale (hold-time mutation
// traces included: the afterlife is part of its history); an interrupted one
// is flattened from its ground and strokes. Ghosts stay pristine — one canvas
// per painting, nothing older baked in — so the composite never blends more
// than three paintings, and each new ghost replaces the previous generation.
function entomb() {
if (freeze != null) return;
let g = null;
if (phase === 'hold' && completedC) {
g = completedC;
} else if (groundC && lastTo > 0) {
const f = makeOffscreen();
f.ctx.drawImage(groundC, 0, 0, rect.w, rect.h);
if (workC) f.ctx.drawImage(workC, 0, 0, rect.w, rect.h);
g = f.c;
}
if (g) {
ghosts.unshift(g);
ghosts = ghosts.slice(0, 2); // gen-3 is gone
}
}
// startScene(i, replayTo, over) — begin a painting. Fresh canvases by default
// (boot, resize, a re-roll from the conditions menu, the very first scene of a
// session): no substrate, no ghosts, normal init. With over=true the scene
// PAINTS OVER the easel: the current painting is entombed as a ghost, the
// scene's init runs into its own ground canvas (it composites at a ramping
// alpha OVER the ghosts at blit time, never baking into the work canvas), and
// the strokes start from a clean work canvas. Determinism is preserved: the
// scene's init and stroke streams are identical either way — the ghost stack
// is show-history, exactly like the mutation's afterlife.
function startScene(i, replayTo = 0, over = false) {
idx = ((i % scenes.length) + scenes.length) % scenes.length; idx = ((i % scenes.length) + scenes.length) % scenes.length;
scene = scenes[idx]; scene = scenes[idx];
const o = makeOffscreen(); if (over) {
offA = o.c; octx = o.ctx; offB = null; entomb();
callInit(scene, octx, rect.w, rect.h); } else {
ghosts = [];
}
const g = makeOffscreen();
groundC = g.c;
callInit(scene, g.ctx, rect.w, rect.h);
weaveGround(g.ctx);
const w = makeOffscreen();
workC = w.c;
octx = w.ctx;
completedC = null;
cctx = null;
if (over || toileNo === 0) toileNo++; // every painting of the session gets its number
if (replayTo > 0) replay(0, replayTo); else lastTo = 0; if (replayTo > 0) replay(0, replayTo); else lastTo = 0;
mutation = null; // a fresh canvas gets no afterlife from the previous show mutation = null; // a new show gets no afterlife from the previous one
phase = 'painting'; phase = 'painting';
t0 = performance.now(); t0 = performance.now();
pausedAt = null; pausedAt = null;
caption(); caption(); // title/subtitle switch the moment the new emergence begins
}
// The completed painting, flattened opaque: ground at full strength + every
// stroke. Built once at the final stroke, shown during the hold, breathed on
// by the mutation, archived pristine, entombed as the next ghost. Equal to the
// p=1 composite, so the hold begins without a pop.
function buildCompleted() {
const f = makeOffscreen();
f.ctx.drawImage(groundC, 0, 0, rect.w, rect.h);
f.ctx.drawImage(workC, 0, 0, rect.w, rect.h);
completedC = f.c;
cctx = f.ctx;
}
// The painting as it stands right now, flattened opaque: for downloads during
// the emergence (the hold just hands over completedC).
function currentPainting() {
if (completedC && phase !== 'painting') return completedC;
if (!groundC) return null;
const f = makeOffscreen();
f.ctx.drawImage(groundC, 0, 0, rect.w, rect.h);
if (workC) f.ctx.drawImage(workC, 0, 0, rect.w, rect.h);
return f.c;
} }
function replay(from, to) { function replay(from, to) {
for (let k = 0; k < REPLAY_STEPS; k++) { for (let k = 0; k < REPLAY_STEPS; k++) {
const a = from + (to - from) * (k / REPLAY_STEPS); const a = from + (to - from) * (k / REPLAY_STEPS);
const b = from + (to - from) * ((k + 1) / REPLAY_STEPS); const b = from + (to - from) * ((k + 1) / REPLAY_STEPS);
if (b > a) scene.draw(octx, rect.w, rect.h, rngFor(a), a, b, conditions); if (b > a) drawWindow(a, b);
} }
lastTo = to; lastTo = to;
} }
let subEl = null; let subEl = null;
let toileEl = null;
function caption() { function caption() {
titleEl.textContent = scene.title; titleEl.textContent = scene.title;
if (!subEl) { if (!subEl) {
...@@ -143,11 +295,24 @@ function caption() { ...@@ -143,11 +295,24 @@ function caption() {
'color:var(--dim);opacity:0.85;'; 'color:var(--dim);opacity:0.85;';
titleEl.parentNode.insertBefore(subEl, titleEl.nextSibling); titleEl.parentNode.insertBefore(subEl, titleEl.nextSibling);
} }
const text = scene.subtitle && conditions ? scene.subtitle(conditions) : ''; let text = scene.subtitle && conditions ? scene.subtitle(conditions) : '';
const rareLine = rareSubtitle(conditions); // '· une comète !' only when it paints
if (rareLine) text = text ? `${text} · ${rareLine}` : rareLine;
subEl.textContent = text; subEl.textContent = text;
subEl.style.display = text ? '' : 'none'; subEl.style.display = text ? '' : 'none';
artistEl.textContent = scene.artist; artistEl.textContent = scene.artist;
[...dotsEl.children].forEach((d, k) => d.classList.toggle('active', k === idx)); // the session counter: an endless atelier has no catalogue, only a running
// tally of canvases painted, one number more with each new painting
if (!toileEl) {
toileEl = document.createElement('div');
toileEl.id = 'toile-counter';
toileEl.style.cssText =
'margin-top:7px;font-size:10px;font-variant:small-caps;letter-spacing:0.24em;' +
'color:var(--dim);opacity:0.6;';
artistEl.parentNode.insertBefore(toileEl, artistEl.nextSibling);
}
toileEl.textContent = `toile n° ${toileNo}`;
toileEl.style.display = freeze == null ? '' : 'none'; // freeze is verification, not the session
refreshMenu(); refreshMenu();
} }
...@@ -175,7 +340,7 @@ function archivePainting() { ...@@ -175,7 +340,7 @@ function archivePainting() {
const key = `${scene.id}:${seedFor()}`; const key = `${scene.id}:${seedFor()}`;
if (archivedKey === key) return; if (archivedKey === key) return;
archivedKey = key; archivedKey = key;
const body = JSON.stringify({ scene: scene.id, seed: seedFor(), dataUrl: offA.toDataURL('image/jpeg', 0.92) }); const body = JSON.stringify({ scene: scene.id, seed: seedFor(), dataUrl: completedC.toDataURL('image/jpeg', 0.92) });
fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }) fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
.then((r) => { if (r.ok) flash('archived ✓'); }) .then((r) => { if (r.ok) flash('archived ✓'); })
.catch(() => {}); .catch(() => {});
...@@ -190,9 +355,10 @@ function archivePainting() { ...@@ -190,9 +355,10 @@ function archivePainting() {
// frame), a few pixels of total drift over the whole hold. The painting is // frame), a few pixels of total drift over the whole hold. The painting is
// never done, only evolving slower. // never done, only evolving slower.
// //
// ORDERING GUARANTEE: archivePainting() snapshots offA at the final stroke // ORDERING GUARANTEE: archivePainting() snapshots the completed flatten at the
// (painting phase), BEFORE the hold begins and before the first breath — the // final stroke (painting phase), BEFORE the hold begins and before the first
// artwork of record is archived pristine; the mutation is its afterlife. // breath — the artwork of record is archived pristine; the mutation is its
// afterlife.
// //
// Anchor honesty: scenes keep their stroke lists inside their own draw() // Anchor honesty: scenes keep their stroke lists inside their own draw()
// closures, so the frame cannot read the scene's stroke data. The fallback // closures, so the frame cannot read the scene's stroke data. The fallback
...@@ -221,7 +387,7 @@ function buildMutation() { ...@@ -221,7 +387,7 @@ function buildMutation() {
small.width = SW; small.width = SW;
small.height = SH; small.height = SH;
const sctx = small.getContext('2d'); const sctx = small.getContext('2d');
sctx.drawImage(offA, 0, 0, SW, SH); sctx.drawImage(completedC, 0, 0, SW, SH);
const px = sctx.getImageData(0, 0, SW, SH).data; const px = sctx.getImageData(0, 0, SW, SH).data;
const lum = new Float32Array(SW * SH); const lum = new Float32Array(SW * SH);
for (let i = 0; i < SW * SH; i++) { for (let i = 0; i < SW * SH; i++) {
...@@ -271,7 +437,7 @@ function buildMutation() { ...@@ -271,7 +437,7 @@ function buildMutation() {
// one breath: advance the anchors along their drift, lay 2-6 soft dabs in the // one breath: advance the anchors along their drift, lay 2-6 soft dabs in the
// painting's own colours near them, schedule the next breath (longer as the // painting's own colours near them, schedule the next breath (longer as the
// crossfade approaches — the painting settles before it dissolves) // paint-over approaches — the painting settles before the next strokes cover it)
function breathe(now, gate) { function breathe(now, gate) {
const rng = mulberry32(((seedFor() * 69621) ^ Math.imul(mutation.tick + 1, 0x9E3779B1)) >>> 0); const rng = mulberry32(((seedFor() * 69621) ^ Math.imul(mutation.tick + 1, 0x9E3779B1)) >>> 0);
mutation.tick++; mutation.tick++;
...@@ -283,10 +449,10 @@ function breathe(now, gate) { ...@@ -283,10 +449,10 @@ function breathe(now, gate) {
const x = Math.min(rect.w * 0.97, Math.max(rect.w * 0.03, a.x + (rng() - 0.5) * rect.w * 0.05)); const x = Math.min(rect.w * 0.97, Math.max(rect.w * 0.03, a.x + (rng() - 0.5) * rect.w * 0.05));
const y = Math.min(rect.h * 0.97, Math.max(rect.h * 0.03, a.y + (rng() - 0.5) * rect.h * 0.05)); const y = Math.min(rect.h * 0.97, Math.max(rect.h * 0.03, a.y + (rng() - 0.5) * rect.h * 0.05));
// broken color around the pixel the painting itself holds there // broken color around the pixel the painting itself holds there
const [c1, c2] = brokenColor(octx, rng, [a.r, a.g, a.b], 0.35); const [c1, c2] = brokenColor(cctx, rng, [a.r, a.g, a.b], 0.35);
const size = Math.max(1.5, rect.w * (0.003 + rng() * 0.006)); const size = Math.max(1.5, rect.w * (0.003 + rng() * 0.006));
const ang = a.dir + (rng() - 0.5) * 0.6; const ang = a.dir + (rng() - 0.5) * 0.6;
dab(octx, x, y, size, rng() < 0.5 ? c1 : c2, 0.03 + rng() * 0.08, ang, 0.55); dab(cctx, x, y, size, rng() < 0.5 ? c1 : c2, 0.03 + rng() * 0.08, ang, 0.55);
} }
for (const a of mutation.anchors) { for (const a of mutation.anchors) {
a.x += Math.cos(a.dir) * a.speed * dt; a.x += Math.cos(a.dir) * a.speed * dt;
...@@ -298,9 +464,9 @@ function breathe(now, gate) { ...@@ -298,9 +464,9 @@ function breathe(now, gate) {
function stepMutation(now) { function stepMutation(now) {
if (!mutation) return; if (!mutation) return;
if (mutation.lastAt == null) mutation.lastAt = now; if (mutation.lastAt == null) mutation.lastAt = now;
// Feather the end: in the last ~30% of the hold before an auto-crossfade the // Feather the end: in the last ~30% of the hold before an auto paint-over the
// breathing slows and stops. An indefinite hold (?autoplay=0) never feathers // breathing slows and stops. An indefinite hold (?autoplay=0) never feathers
// — there is no crossfade to settle for, the painting just keeps living. // — there is no transition to settle for, the painting just keeps living.
let gate = 1; let gate = 1;
if (autoplay) { if (autoplay) {
const p = (now - t0) / HOLD_MS; const p = (now - t0) / HOLD_MS;
...@@ -327,8 +493,12 @@ const MENU_SEG = { ...@@ -327,8 +493,12 @@ const MENU_SEG = {
['any', 'any'], ['clear', 'clair'], ['mist', 'brume'], ['overcast', 'couvert'], ['any', 'any'], ['clear', 'clair'], ['mist', 'brume'], ['overcast', 'couvert'],
['rain', 'pluie'], ['snow', 'neige'], ['rain', 'pluie'], ['snow', 'neige'],
], ],
technique: [
['any', 'any'], ['empatement', 'empâtement'], ['vernis', 'vernis'],
['pointillisme', 'pointillisme'], ['sec', 'sec'], ['mouille', 'mouillé'],
],
}; };
const MENU_LABEL = { timeOfDay: 'Heure', season: 'Saison', weather: 'Météo' }; const MENU_LABEL = { timeOfDay: 'Heure', season: 'Saison', weather: 'Météo', technique: 'Main' };
let menuOpen = false; let menuOpen = false;
let menuEl = null; let menuEl = null;
...@@ -338,6 +508,7 @@ function persistMenu() { ...@@ -338,6 +508,7 @@ function persistMenu() {
try { try {
localStorage.setItem(LS_KEY, JSON.stringify({ localStorage.setItem(LS_KEY, JSON.stringify({
timeOfDay: constraints.timeOfDay, season: constraints.season, weather: constraints.weather, timeOfDay: constraints.timeOfDay, season: constraints.season, weather: constraints.weather,
technique: constraints.technique,
speed: speedMul, autoplay, speed: speedMul, autoplay,
})); }));
} catch {} } catch {}
...@@ -354,7 +525,8 @@ function setConstraint(axis, value) { ...@@ -354,7 +525,8 @@ function setConstraint(axis, value) {
function setSpeed(s) { function setSpeed(s) {
if (speedMul === s) return; if (speedMul === s) return;
if (phase === 'painting' && pausedAt == null) { if (phase === 'painting' && pausedAt == null) {
t0 = performance.now() - lastTo * (scene.durationMs / s); // keep progress continuous // keep progress continuous: elapsed = progress * durationMs / (speed * pace)
t0 = performance.now() - lastTo * (scene.durationMs / (s * paceOf(scene)));
} }
speedMul = s; speedMul = s;
persistMenu(); persistMenu();
...@@ -362,8 +534,9 @@ function setSpeed(s) { ...@@ -362,8 +534,9 @@ function setSpeed(s) {
} }
function downloadCanvas() { function downloadCanvas() {
if (!offA) return; const c = currentPainting();
offA.toBlob((blob) => { if (!c) return;
c.toBlob((blob) => {
if (!blob) return; if (!blob) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
...@@ -406,15 +579,17 @@ function buildMenu() { ...@@ -406,15 +579,17 @@ function buildMenu() {
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
#menu { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); #menu { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
width:min(620px, 92vw); max-height:88vh; overflow:auto; pointer-events:auto; width:min(620px, 92vw); max-height:88vh; overflow-y:auto; overscroll-behavior:contain;
pointer-events:auto; scrollbar-width:thin;
scrollbar-color:rgba(207,198,184,0.3) transparent;
background:rgba(26,23,18,0.94); border:1px solid rgba(207,198,184,0.16); background:rgba(26,23,18,0.94); border:1px solid rgba(207,198,184,0.16);
padding:26px 32px 22px; display:none; } padding:18px 26px 16px; display:none; }
#menu.open { display:block; } #menu.open { display:block; }
#menu h2 { font-weight:400; font-size:11px; font-variant:small-caps; #menu h2 { font-weight:400; font-size:11px; font-variant:small-caps;
letter-spacing:0.3em; color:var(--dim); padding-bottom:8px; margin-bottom:14px; letter-spacing:0.3em; color:var(--dim); padding-bottom:6px; margin-bottom:10px;
border-bottom:1px solid rgba(207,198,184,0.14); } border-bottom:1px solid rgba(207,198,184,0.14); }
#menu h2:not(:first-child) { margin-top:26px; } #menu h2:not(:first-child) { margin-top:16px; }
.mrow { display:flex; align-items:center; gap:14px; margin:9px 0; } .mrow { display:flex; align-items:center; gap:14px; margin:7px 0; }
.mlabel { flex:0 0 84px; font-size:10.5px; font-variant:small-caps; .mlabel { flex:0 0 84px; font-size:10.5px; font-variant:small-caps;
letter-spacing:0.2em; color:var(--dim); } letter-spacing:0.2em; color:var(--dim); }
.seg { display:flex; flex:1; border:1px solid rgba(207,198,184,0.18); } .seg { display:flex; flex:1; border:1px solid rgba(207,198,184,0.18); }
...@@ -439,7 +614,7 @@ function buildMenu() { ...@@ -439,7 +614,7 @@ function buildMenu() {
menuEl.id = 'menu'; menuEl.id = 'menu';
menuEl.appendChild(menuHeading('Conditions')); menuEl.appendChild(menuHeading('Conditions'));
for (const axis of ['timeOfDay', 'season', 'weather']) { for (const axis of ['timeOfDay', 'season', 'weather', 'technique']) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'mrow'; row.className = 'mrow';
const lab = document.createElement('span'); const lab = document.createElement('span');
...@@ -510,7 +685,7 @@ function buildMenu() { ...@@ -510,7 +685,7 @@ function buildMenu() {
function refreshMenu() { function refreshMenu() {
if (!menuEl) return; if (!menuEl) return;
for (const axis of ['timeOfDay', 'season', 'weather']) { for (const axis of ['timeOfDay', 'season', 'weather', 'technique']) {
const cur = constraints[axis] || 'any'; const cur = constraints[axis] || 'any';
segButtons[axis].forEach((b) => b.classList.toggle('on', b.dataset.value === cur)); segButtons[axis].forEach((b) => b.classList.toggle('on', b.dataset.value === cur));
} }
...@@ -520,24 +695,17 @@ function refreshMenu() { ...@@ -520,24 +695,17 @@ function refreshMenu() {
if (s) s.textContent = `graine ${seedFor()}`; if (s) s.textContent = `graine ${seedFor()}`;
} }
function buildDots() {
scenes.forEach((s, k) => {
const b = document.createElement('button');
b.title = s.title;
b.setAttribute('aria-label', s.title);
b.addEventListener('click', () => jump(k));
dotsEl.appendChild(b);
});
}
function jump(i) { function jump(i) {
const n = scenes.length; const n = scenes.length;
const j = ((i % n) + n) % n; const j = ((i % n) + n) % n;
if (freeze != null) { idx = j; renderFreeze(j); return; } if (freeze != null) { idx = j; renderFreeze(j); return; }
startScene(j, 0); // jumping — forward or back — also paints over: whatever is on the easel
// (a held painting or an emergence in progress) becomes the new ghost, an
// earlier painting simply re-emerges over the later one. No hard cuts.
startScene(j, 0, true);
} }
function blit(a, b = null, p = 0) { function paintBacking() {
vctx.fillStyle = '#1a1712'; vctx.fillStyle = '#1a1712';
vctx.fillRect(0, 0, innerWidth, innerHeight); vctx.fillRect(0, 0, innerWidth, innerHeight);
vctx.save(); vctx.save();
...@@ -547,13 +715,35 @@ function blit(a, b = null, p = 0) { ...@@ -547,13 +715,35 @@ function blit(a, b = null, p = 0) {
vctx.fillStyle = '#100e0b'; vctx.fillStyle = '#100e0b';
vctx.fillRect(rect.x, rect.y, rect.w, rect.h); vctx.fillRect(rect.x, rect.y, rect.w, rect.h);
vctx.restore(); vctx.restore();
}
function blit(a) {
paintBacking();
vctx.drawImage(a, rect.x, rect.y, rect.w, rect.h); vctx.drawImage(a, rect.x, rect.y, rect.w, rect.h);
if (b) { vignette();
vctx.save(); }
vctx.globalAlpha = p;
vctx.drawImage(b, rect.x, rect.y, rect.w, rect.h); // Layered composite for the painting phase: ghosts beneath (gen-2 first, gen-1
vctx.restore(); // decaying), then the incoming ground at its ramping alpha, then the current
// strokes at full opacity. Through bare regions the previous painting shows
// through the wet ground and is progressively buried; at p=1 the ground is
// opaque, so the composite equals the completed flatten exactly.
function blitComposite(p) {
paintBacking();
if (ghosts[1]) {
vctx.globalAlpha = GHOST2_A;
vctx.drawImage(ghosts[1], rect.x, rect.y, rect.w, rect.h);
}
if (ghosts[0]) {
vctx.globalAlpha = GHOST_A[0] + (GHOST_A[1] - GHOST_A[0]) * p;
vctx.drawImage(ghosts[0], rect.x, rect.y, rect.w, rect.h);
} }
if (groundC) {
vctx.globalAlpha = ghosts.length ? GROUND_A[0] + (GROUND_A[1] - GROUND_A[0]) * p : 1;
vctx.drawImage(groundC, rect.x, rect.y, rect.w, rect.h);
}
vctx.globalAlpha = 1;
if (workC) vctx.drawImage(workC, rect.x, rect.y, rect.w, rect.h);
vignette(); vignette();
} }
...@@ -577,49 +767,37 @@ function vignette() { ...@@ -577,49 +767,37 @@ function vignette() {
function tick(now) { function tick(now) {
if (phase === 'painting') { if (phase === 'painting') {
if (pausedAt == null) { if (pausedAt == null) {
const to = Math.min(1, (now - t0) / (scene.durationMs / speedMul)); const to = Math.min(1, ((now - t0) * paceOf(scene)) / (scene.durationMs / speedMul));
if (to > lastTo) { if (to > lastTo) {
scene.draw(octx, rect.w, rect.h, rngFor(lastTo), lastTo, to, conditions); drawWindow(lastTo, to);
lastTo = to; lastTo = to;
if (lastTo >= 1) archivePainting(); // final stroke done, before the hold if (lastTo >= 1) {
// final stroke: flatten, then archive — both before the hold and its
// first breath, so the artwork of record is pristine
buildCompleted();
archivePainting();
}
} }
} }
blit(offA); blitComposite(lastTo);
fillEl.style.width = (lastTo * 100).toFixed(2) + '%'; fillEl.style.width = (lastTo * 100).toFixed(2) + '%';
if (lastTo >= 1 && pausedAt == null) { phase = 'hold'; t0 = now; buildMutation(); } if (lastTo >= 1 && pausedAt == null) { phase = 'hold'; t0 = now; buildMutation(); }
} else if (phase === 'hold') { } else if (phase === 'hold') {
if (pausedAt == null) stepMutation(now); // the painting keeps living while unpaused if (pausedAt == null) stepMutation(now); // the painting keeps living while unpaused
blit(offA); blit(completedC);
fillEl.style.width = '100%'; fillEl.style.width = '100%';
if (pausedAt == null && now - t0 >= HOLD_MS) { if (pausedAt == null && now - t0 >= HOLD_MS) {
if (autoplay) { if (autoplay) {
pendingIdx = (idx + 1) % scenes.length; const next = (idx + 1) % scenes.length;
if (pendingIdx === 0) showNumber++; // each replay of the show gets a fresh seed if (next === 0) showNumber++; // each replay of the show gets a fresh seed
const o = makeOffscreen(); // PAINT-OVER: no wipe, no crossfade — the held painting is entombed as
offB = o.c; // the new ghost and the next painting emerges over it, constant
callInit(scenes[pendingIdx], o.ctx, rect.w, rect.h); // animation, at most three paintings on any one canvas.
mutation = null; // the afterlife ends with the crossfade startScene(next, 0, true);
phase = 'fade';
t0 = now;
} else { } else {
t0 = now; // ?autoplay=0: hold the finished painting indefinitely t0 = now; // ?autoplay=0: hold the finished painting indefinitely
} }
} }
} else if (phase === 'fade') {
const p = Math.min(1, (now - t0) / FADE_MS);
blit(offA, offB, p);
if (p >= 1) {
offA = offB;
octx = offA.getContext('2d');
octx.setTransform(dpr, 0, 0, dpr, 0, 0);
offB = null;
idx = pendingIdx;
scene = scenes[idx];
lastTo = 0;
phase = 'painting';
t0 = now;
caption();
}
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
} }
...@@ -629,10 +807,11 @@ function renderFreeze(i = 0) { ...@@ -629,10 +807,11 @@ function renderFreeze(i = 0) {
idx = ((i % scenes.length) + scenes.length) % scenes.length; idx = ((i % scenes.length) + scenes.length) % scenes.length;
scene = scenes[idx]; scene = scenes[idx];
const o = makeOffscreen(); const o = makeOffscreen();
offA = o.c; octx = o.ctx; octx = o.ctx;
callInit(scene, octx, rect.w, rect.h); callInit(scene, octx, rect.w, rect.h);
weaveGround(octx);
replay(0, freeze); replay(0, freeze);
blit(offA); blit(o.c); // freeze is always a fresh canvas: no substrate, no ghosts
fillEl.style.width = (freeze * 100).toFixed(2) + '%'; fillEl.style.width = (freeze * 100).toFixed(2) + '%';
caption(); caption();
} }
...@@ -641,7 +820,11 @@ let idleTimer = 0; ...@@ -641,7 +820,11 @@ let idleTimer = 0;
function wake() { function wake() {
document.body.classList.remove('idle'); document.body.classList.remove('idle');
clearTimeout(idleTimer); clearTimeout(idleTimer);
if (freeze == null) idleTimer = setTimeout(() => document.body.classList.add('idle'), IDLE_MS); // an open menu must never idle away: invisible furniture is unreachable
// furniture, and a faded panel reads as a frozen show
if (freeze == null && !menuOpen) {
idleTimer = setTimeout(() => document.body.classList.add('idle'), IDLE_MS);
}
} }
addEventListener('keydown', (e) => { addEventListener('keydown', (e) => {
...@@ -679,7 +862,6 @@ if (freeze == null) { ...@@ -679,7 +862,6 @@ if (freeze == null) {
} }
layout(); layout();
buildDots();
buildMenu(); buildMenu();
if (params.get('menu') === '1') setMenu(true); // headless/screenshots can't press M if (params.get('menu') === '1') setMenu(true); // headless/screenshots can't press M
if (freeze != null) { if (freeze != null) {
......
...@@ -4,6 +4,22 @@ ...@@ -4,6 +4,22 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Atelier Impressionniste</title> <title>Atelier Impressionniste</title>
<meta name="description" content="Des tableaux impressionnistes génératifs qui se peignent en direct, coup de pinceau après coup de pinceau.">
<meta name="theme-color" content="#1a1712">
<meta property="og:title" content="Atelier Impressionniste — la peinture qui se peint">
<meta property="og:description" content="Des tableaux impressionnistes génératifs qui se peignent en direct sous vos yeux — ciel, heure, saison et main du peintre jetés aux dés, à chaque visite.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://me.nech.pl/impression/">
<meta property="og:locale" content="fr_FR">
<meta property="og:image" content="https://me.nech.pl/impression/og.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Un tableau impressionniste généré — peinture qui émerge coup de pinceau après coup de pinceau sur un charbon chaud.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Atelier Impressionniste — la peinture qui se peint">
<meta name="twitter:description" content="Des tableaux impressionnistes génératifs qui se peignent en direct sous vos yeux — ciel, heure, saison et main du peintre jetés aux dés, à chaque visite.">
<meta name="twitter:image" content="https://me.nech.pl/impression/og.jpg">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231a1712'/%3E%3Cellipse cx='16' cy='16' rx='9' ry='6' transform='rotate(-24 16 16)' fill='%23e08a3c'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
......
...@@ -34,12 +34,13 @@ const WEATHER_BY_SEASON = { ...@@ -34,12 +34,13 @@ const WEATHER_BY_SEASON = {
winter: ['clear', 'clear', 'mist', 'overcast', 'snow', 'snow'], winter: ['clear', 'clear', 'mist', 'overcast', 'snow', 'snow'],
}; };
// `constraints` (optional): {timeOfDay, season, weather, technique}, each null // `constraints` (optional): {timeOfDay, season, weather, technique, rare}, each
// or a value. A set axis is forced to its value; the dedicated rng stream still // null or a value. A set axis is forced to its value; the dedicated rng stream
// rolls the same number of dice for the free axes, and every coupling below // still rolls the same number of dice for the free axes, and every coupling
// (sun/moon, sunY, warmth, strength) follows the forced values like any other // below (sun/moon, sunY, warmth, strength) follows the forced values like any
// roll. The technique die is cast LAST, after every other consumer, so rolling // other roll. The technique die is cast LAST, after every other consumer, so
// or forcing it never perturbs the earlier axes for the same seed. // rolling or forcing it never perturbs the earlier axes for the same seed —
// and the rare die is cast after even that (see the rare-states block below).
export function makeConditions(rng, constraints = null) { export function makeConditions(rng, constraints = null) {
const forced = constraints || {}; const forced = constraints || {};
const timeOfDay = forced.timeOfDay || pick(rng, [ const timeOfDay = forced.timeOfDay || pick(rng, [
...@@ -61,8 +62,26 @@ export function makeConditions(rng, constraints = null) { ...@@ -61,8 +62,26 @@ export function makeConditions(rng, constraints = null) {
const technique = TECHNIQUES.includes(forced.technique) const technique = TECHNIQUES.includes(forced.technique)
? forced.technique ? forced.technique
: pick(rng, TECHNIQUES); : pick(rng, TECHNIQUES);
// --- the rare die, cast after even the technique die ------------------------
// ~97% of paintings get nothing but their own weather; the rest earn a comet,
// a shooting star, a balloon or a shower. `rare: 'none'` force-nulls (testing
// the everyday case); a known event name forces it; anything else rolls.
let rare = null;
if (forced.rare === 'none') {
rare = null;
} else if (RARE_EVENTS.includes(forced.rare)) {
rare = forced.rare;
} else {
const rr = rng();
if (rr >= 1 - RARE_P) {
rare = RARE_EVENTS[Math.min(RARE_EVENTS.length - 1, Math.floor(((rr - (1 - RARE_P)) / RARE_P) * RARE_EVENTS.length))];
}
}
// geometry rolled WITH the event so every overlay window of a show agrees
const rareData = rare ? rollRareData(rare, rng) : null;
return { return {
timeOfDay, season, weather, technique, timeOfDay, season, weather, technique,
rare, rareData,
sunVisible: !night, sunVisible: !night,
moonVisible: night, moonVisible: night,
sunX, sunY, horizonY, lightAngle, warmth, sunX, sunY, horizonY, lightAngle, warmth,
...@@ -84,6 +103,286 @@ const TECHNIQUE_FR = { ...@@ -84,6 +103,286 @@ const TECHNIQUE_FR = {
sec: 'sec', mouille: 'mouillé', sec: 'sec', mouille: 'mouillé',
}; };
// --- rare states --------------------------------------------------------------
// The Eno eventfulness axis. Most paintings are only their hour, season,
// weather and hand; with probability RARE_P the sky offers something a patient
// viewer remembers. Rolled on its own final die of the conditions stream (one
// roll, never earlier), so its presence or absence leaves every other axis
// byte-identical for the same seed. The event's geometry is rolled once here
// too; rareOverlay below quantizes it to the progress window it is handed.
// Comet and shower need a sky worth crossing (night, dusk, dawn); the balloon
// floats at any hour. Drawing lives in the overlay, not in any scene.
const RARE_P = 0.03; // ~97% of shows get the everyday sky
const RARE_EVENTS = ['comet', 'etoile', 'balloon', 'shower'];
const RARE_FR = {
comet: 'une comète !',
etoile: 'une étoile filante !',
balloon: 'une montgolfière !',
shower: 'une pluie d’étoiles !',
};
function rareVisible(c) {
if (!c || !c.rare) return false;
if (c.rare === 'balloon') return true;
return c.timeOfDay === 'night' || c.timeOfDay === 'dusk' || c.timeOfDay === 'dawn';
}
// Caption fragment for a rolled, actually-visible rare event (a comet rolled
// at noon never paints, so it never announces itself either).
export function rareSubtitle(c) {
return rareVisible(c) ? RARE_FR[c.rare] : null;
}
function rollRareData(rare, rng) {
const seed = (rng() * 0xffffffff) >>> 0; // stable per-show jitter stream
const ltr = rng() < 0.5; // travel direction: left-to-right or reverse
if (rare === 'comet') {
return {
seed, ltr,
x0: ltr ? 0.10 + rng() * 0.08 : 0.82 + rng() * 0.08,
x1: ltr ? 0.82 + rng() * 0.08 : 0.10 + rng() * 0.08,
y0: 0.045 + rng() * 0.055, // high in the safe sky band
drift: (rng() - 0.5) * 0.05, // gentle arc across the crossing
};
}
if (rare === 'etoile') {
return {
seed, ltr,
p: 0.40 + rng() * 0.35, // one brief flash, mid-emergence
x: 0.15 + rng() * 0.68,
y: 0.04 + rng() * 0.10,
ang: (0.34 + rng() * 0.30) * (ltr ? 1 : -1), // shallow dive, rad from +x
len: 0.09 + rng() * 0.06, // fraction of W
};
}
if (rare === 'balloon') {
return {
seed,
x: 0.20 + rng() * 0.60, // motionless it floats
y: 0.085 + rng() * 0.045, // envelope centre, fraction of H
s: 0.040 + rng() * 0.016, // envelope half-width, fraction of W
stripes: rng() < 0.5 ? ['#c85a3c', '#efe3c4'] : ['#4a6a8f', '#efe3c4'],
};
}
// shower: 5-8 brief streaks, each at its own moment of the emergence
const n = 5 + Math.floor(rng() * 4);
const streaks = [];
for (let k = 0; k < n; k++) {
streaks.push({
p: 0.08 + rng() * 0.84,
x: 0.10 + rng() * 0.78,
y: 0.04 + rng() * 0.115,
ang: (0.30 + rng() * 0.30) * (ltr ? 1 : -1),
len: 0.05 + rng() * 0.05,
});
}
return { seed, streaks };
}
// Draw the rolled rare event over the progress window [from, to). Called by the
// frame right after each quantized scene.draw window (not from scenes), with a
// dedicated per-window rng; all long-lived geometry comes from c.rareData, so
// successive windows agree. Sits in the upper 18% of the canvas — safe sky for
// every motif — and scales with W/H. Deterministic per seed + window.
export function rareOverlay(ctx, W, H, rng, c, from, to) {
if (!c || !c.rare || !c.rareData || !rareVisible(c) || !(to > from)) return;
const d = c.rareData;
const p = (from + to) / 2;
if (c.rare === 'comet') {
if (p <= 2 / 3) return; // the comet belongs to the painting's last third
const t = clamp01((p - 2 / 3) / (1 / 3));
// ease-out crossing: fast through the early sky, slowing hard into the
// end, so the head dwells (and the paint accumulates) at the comet's
// arrival point while the early passage stays a fainter streak of itself
const te = 1 - Math.pow(1 - t, 2.6);
const hx = lerp(d.x0, d.x1, te) * W;
const hy = (d.y0 + d.drift * te) * H;
const sgn = d.x1 > d.x0 ? 1 : -1;
// density proportional to the window, as the weather touches do: many tiny
// live windows lay one stroke each; a freeze replay builds the same trail
const nTail = Math.max(1, Math.round((to - from) * 150));
ctx.save();
ctx.lineCap = 'round';
const L = W * (0.12 + 0.30 * t); // the tail grows as the comet travels
for (let k = 0; k < nTail; k++) {
const f = Math.pow(rng(), 1.7); // biased behind the head: bright, short
const bx = hx - sgn * L * f;
const by = hy - d.drift * H * f + (rng() - 0.5) * H * 0.016 * (0.5 + f);
const seg = L * (0.04 + rng() * 0.09);
const back = sgn > 0 ? Math.PI : 0;
const alpha = 0.50 * Math.pow(1 - f, 1.8) * (0.5 + rng() * 0.5);
if (alpha < 0.02) continue;
ctx.strokeStyle = `rgba(255,238,190,${alpha.toFixed(3)})`;
ctx.lineWidth = Math.max(0.7, W * 0.0060 * (1 - f * 0.8));
const a2 = back + (rng() - 0.5) * 0.45;
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + Math.cos(a2) * seg, by + Math.sin(a2) * seg * 0.45);
ctx.stroke();
if (rng() < 0.35) { // the broken second beat of the same stroke
ctx.beginPath();
ctx.moveTo(bx + Math.cos(a2) * seg * 1.3, by + Math.sin(a2) * seg * 0.45 * 1.3);
ctx.lineTo(bx + Math.cos(a2) * seg * 1.8, by + Math.sin(a2) * seg * 0.45 * 1.8);
ctx.stroke();
}
}
// the head: one faint per-window breath of glow plus a tight cluster of
// pale gold-white dabs — the ease-out dwell lets the arrival point burn
// while the fast early passage leaves only its tapering wake
const glow = Math.min(0.06, (to - from) * 14);
if (glow > 0.002) {
const g = ctx.createRadialGradient(hx, hy, 0, hx, hy, W * 0.028);
g.addColorStop(0, `rgba(255,244,214,${glow.toFixed(3)})`);
g.addColorStop(1, 'rgba(255,244,214,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(hx, hy, W * 0.028, 0, 6.2832);
ctx.fill();
}
const cols = ['#fff8e0', '#ffeeb8', '#f6d992'];
const nHead = Math.max(2, Math.round((to - from) * 250));
for (let k = 0; k < nHead; k++) {
ctx.fillStyle = cols[Math.floor(rng() * cols.length)];
ctx.globalAlpha = 0.05 + rng() * 0.07; // faint per window: overlapping
// windows must merge into one smooth streak, never bead into bulbs
ctx.beginPath();
ctx.ellipse(
hx + (rng() - 0.5) * W * 0.011, hy + (rng() - 0.5) * W * 0.007,
W * (0.0022 + rng() * 0.0035), W * (0.0015 + rng() * 0.0028),
rng() * Math.PI, 0, 6.2832,
);
ctx.fill();
}
ctx.restore();
return;
}
if (c.rare === 'etoile') {
if (!(from < d.p && to >= d.p)) return; // one brief flash, exactly one window
const dir = d.ltr ? 1 : -1;
const x = d.x * W, y = d.y * H;
const len = d.len * W;
ctx.save();
ctx.lineCap = 'round';
// a deep-violet counter-stroke beneath the trail so the flash also reads
// against pale dusk/mist skies — the shadow a bright meteor casts
ctx.strokeStyle = 'rgba(52,60,92,0.35)';
ctx.lineWidth = Math.max(1.4, W * 0.0050);
ctx.beginPath();
ctx.moveTo(x + dir * Math.cos(d.ang) * W * 0.004, y + Math.sin(d.ang) * W * 0.004);
ctx.lineTo(x - dir * Math.cos(d.ang) * len * 0.5, y - Math.sin(d.ang) * len * 0.5);
ctx.stroke();
// tapering gold trail behind a brilliant white core
for (let k = 0; k < 7; k++) {
const f = k / 7;
ctx.strokeStyle = `rgba(255,241,196,${(0.65 * Math.pow(1 - f, 1.4)).toFixed(3)})`;
ctx.lineWidth = Math.max(0.8, W * 0.0042 * (1 - f * 0.8));
const seg = len * (0.10 + f * 0.16);
ctx.beginPath();
ctx.moveTo(x - dir * Math.cos(d.ang) * len * f, y - Math.sin(d.ang) * len * f);
ctx.lineTo(x - dir * Math.cos(d.ang) * (len * f + seg), y - Math.sin(d.ang) * (len * f + seg));
ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,253,240,1)';
ctx.lineWidth = Math.max(1.4, W * 0.0038);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dir * Math.cos(d.ang) * len * 0.28, y - Math.sin(d.ang) * len * 0.28);
ctx.stroke();
for (let k = 0; k < 4; k++) { // a few sparks at the head
ctx.fillStyle = `rgba(255,250,225,${(0.35 + rng() * 0.4).toFixed(3)})`;
ctx.beginPath();
ctx.arc(x + (rng() - 0.5) * W * 0.012, y + (rng() - 0.5) * W * 0.010, W * (0.0009 + rng() * 0.0016), 0, 6.2832);
ctx.fill();
}
ctx.restore();
return;
}
if (c.rare === 'balloon') {
if (p <= 0.5) return; // it arrives with the second half of the emergence
// geometry is window-stable (motionless it floats): placement dabs come
// from a dedicated sub-stream so every window overpaints the same shape
const jr = mulberry32(d.seed);
const cx = d.x * W, cy = d.y * H;
const rw = d.s * W, rh = rw * 1.25; // envelope half-width / half-height
const rows = 11;
const [cA, cB] = d.stripes;
ctx.save();
for (let j = 0; j < rows; j++) {
const v = j / (rows - 1); // 0 top, 1 bottom of envelope
const w = rw * Math.sin(Math.PI * (0.18 + 0.82 * v)) ** 0.75;
const y = cy - rh + 2 * rh * v;
const step = Math.max(2, w / 4.5);
for (let x = cx - w; x <= cx + w; x += step) {
const stripe = Math.floor((x - cx + w) / (step * 2)) % 2 === 0 ? cA : cB;
ctx.fillStyle = stripe;
ctx.globalAlpha = 0.62 + jr() * 0.22;
ctx.beginPath();
ctx.ellipse(x + (jr() - 0.5) * step * 0.5, y + (jr() - 0.5) * rh * 0.10,
step * 0.55, rh * 0.10 + jr() * 1.2, (jr() - 0.5) * 0.5, 0, 6.2832);
ctx.fill();
}
}
// mouth + burner glow
ctx.globalAlpha = 0.8;
ctx.fillStyle = '#5a4326';
ctx.beginPath();
ctx.ellipse(cx, cy + rh * 1.02, rw * 0.34, rh * 0.10, 0, 0, 6.2832);
ctx.fill();
// ropes
ctx.globalAlpha = 0.7;
ctx.strokeStyle = 'rgba(46,34,20,0.75)';
ctx.lineWidth = Math.max(0.7, W * 0.0012);
const bx0 = cx - rw * 0.34, bx1 = cx + rw * 0.34;
const by = cy + rh * 1.55;
ctx.beginPath();
ctx.moveTo(bx0, cy + rh * 1.06); ctx.lineTo(bx0, by);
ctx.moveTo(bx1, cy + rh * 1.06); ctx.lineTo(bx1, by);
ctx.stroke();
// basket: a few umber dabs
for (let k = 0; k < 6; k++) {
ctx.fillStyle = k % 2 ? '#6b4a2e' : '#57391f';
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.ellipse(cx + (jr() - 0.5) * rw * 0.5, by + (jr() - 0.5) * rh * 0.09,
rw * 0.16, rh * 0.09, 0, 0, 6.2832);
ctx.fill();
}
ctx.restore();
return;
}
// shower: each of the 5-8 streaks paints once, at the one window holding its
// own moment of the emergence — brief sparks scattered across the sky
for (const s of d.streaks) {
if (!(from < s.p && to >= s.p)) continue;
const dir = d.ltr ? 1 : -1;
const x = s.x * W, y = s.y * H, len = s.len * W;
ctx.save();
ctx.lineCap = 'round';
for (let k = 0; k < 4; k++) {
const f = k / 4;
ctx.strokeStyle = `rgba(255,244,208,${(0.45 * Math.pow(1 - f, 1.2)).toFixed(3)})`;
ctx.lineWidth = Math.max(0.7, W * 0.0026 * (1 - f * 0.7));
const seg = len * (0.14 + f * 0.18);
ctx.beginPath();
ctx.moveTo(x - dir * Math.cos(s.ang) * len * f, y - Math.sin(s.ang) * len * f);
ctx.lineTo(x - dir * Math.cos(s.ang) * (len * f + seg), y - Math.sin(s.ang) * (len * f + seg));
ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,252,238,0.9)';
ctx.lineWidth = Math.max(0.9, W * 0.0022);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dir * Math.cos(s.ang) * len * 0.22, y - Math.sin(s.ang) * len * 0.22);
ctx.stroke();
ctx.restore();
}
}
// --- palettes --------------------------------------------------------------- // --- palettes ---------------------------------------------------------------
const hexRgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; const hexRgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
......
// Le Campement des Gitans — wagon-dwellers camped on the common land at the
// town's edge, in the reportage spirit of the circle's real camp subjects:
// dignity, warmth, transit — people at home, never a romance. The meadow runs
// back to a far low silhouette of rooftops (a few lit windows, maybe a spire);
// scattered trees stand off to the sides; the painted caravans — curved dark
// green, madder red, ochre-yellow, the barest suggestion of scrollwork — are
// the color souls of the scene. The campfires are its heart: warm orange-gold
// flames built from upward dabs, a soft glow halo bleeding into the grass,
// firelight catching wagon wheels, horse flanks and the fire side of every
// figure (broken-color discipline: the cool side stays cool). Figures are
// gestural silhouettes — no faces, no contour, the marché precedent — 5-12
// strokes each, readable against the glow. Smoke rises in grey-violet columns
// and merges with the dusk; the first stars come out over the common.
//
// DICE (init stream, after the conditions stream). Wagons 1-3 (the conditions
// motifCount, so the composition dice steer the camp's size); fires 1-3 at
// rational depths; hobbled horses 1-4; figures 4-10 (seated and standing at
// the fires, a woman carrying a pot, children between the wagons); a laundry
// line; tethered dogs; 1-3 trees; 2-4 lit windows in the far town.
//
// CONDITIONS. Dusk is canonical — fires bright, sky violet to amber. Night is
// deep: fires the only light, sparks rising, wagon windows glowing, stars
// dense. Dawn breaks the camp: wagons hitched, fires dead to smoke, mist in
// the grass. Golden hour is the arrival: wagons still, horses grazing, light
// warm. Morning/noon (user-locked only) paint a daylight camp, fires to
// embers. Seasons shift the land; mist half-ghosts the camp; rain shields the
// fires and drags the smoke low; a storm (overcast) douses them — lantern
// light only.
//
// SKELETON AND HAND: the load-bearing strokes — the prime, the meadow field,
// the wagon bodies, the figures' masses — ride the plain house stroke
// (marche/bois precedent: masses plain, accents through the hand), because a
// pointillisme wrist must not starve the tonal spine. Every expressive touch —
// flames, glow haloes, scrollwork, rims, heads, laundry, sparks — routes
// through brushes.handFor(conditions.technique) + handDab.
//
// Contract: identical to scenes/marche.js — every stroke precomputed in init()
// with a fixed progress stamp, draw() paints exactly the stamps in [from, to),
// the persistent canvas accumulates, same seed -> same painting. Per-stroke
// jitter and weather touches ride their own mulberry32 streams; NOTHING else
// consumes randomness at draw time. TONAL SPINE (pixel-verified): sky bands
// mid, land mid-dark, wagons/figures darkest, fires and their glows the
// brightest warm accents.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, Horizon, brokenColor,
dab, roundDab, rigger, stroke,
} from '../brushes.js';
let strokes = null;
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
// The camp paints the hours its colour story can tell honestly: dusk
// (canonical), night, dawn (breaking camp), goldenHour (arrival). Morning and
// noon are re-rolled away unless the user locks them. The dedicated conditions
// stream is consumed identically every pass, so this stays deterministic.
const HOME_TOD = ['dusk', 'night', 'dawn', 'goldenHour'];
export default {
id: 'gitans',
title: 'Le Campement',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => {
let c = makeConditions(rng, constraints);
for (let i = 0; i < 12; i++) {
if (c.timeOfDay === 'dusk') return c; // the canonical hour keeps its weight
if (HOME_TOD.includes(c.timeOfDay) && rng() < 0.22) return c;
c = makeConditions(rng, constraints);
}
if (HOME_TOD.includes(c.timeOfDay) || (constraints && constraints.timeOfDay)) return c;
return makeConditions(rng, { ...(constraints || {}), timeOfDay: 'dusk' });
},
subtitle: (c) => 'Le campement, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1901), null);
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', daylight = tod === 'morning' || tod === 'noon';
const misty = weather === 'mist', rainy = weather === 'rain';
const stormy = weather === 'overcast', snowy = weather === 'snow';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const lerp = (a, b, t) => a + (b - a) * t;
// one jitter stream per stroke; weather touches ride ~12% of strokes
const stamped = (p, fn) => {
const jr = mulberry32((0xAB175 + Math.floor(rng() * 0xffffff) * 691) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; the scene keeps painting */ }
}
});
};
// -- THE HAND ----------------------------------------------------------
const hand = handFor(cond.technique);
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean;
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(1.6, Math.max(1, 1 / Math.max(0.25, slMean * kMean)));
const inkK = hand.alphaCurve === 'soft' ? 2.3 : hand.alphaCurve === 'bleed' ? 1.7
: hand.alphaCurve === 'matte' ? 1.25 : 1;
// touch and smear paint THROUGH the rolled hand
const touch = (cc, jr, x, y, w, c, a, ang = 0, squash = 1) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: 0, ang, w: handW(w), c, a: aa, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: aa, angle: ang, squash }),
});
};
const smear = (cc, jr, x, y, len, ang, w, c, a, squash = 1) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(w), c, a: aa, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, c, aa),
});
};
// the soft air — smoke, mist, the unifying breath — stays translucent
const veil = (x, y, len, ang, w, c, a, squash = 0.5) => (cc) => {
const half = Math.max(1, len * 0.5);
const ab = hand.alphaBase;
dab(cc, x, y, half, c, a * ab, ang, clamp(squash, 0.06, 8));
dab(cc, x, y, half * 1.45, c, a * 0.34 * ab, ang, clamp(squash * 0.92, 0.06, 8));
};
// the skeleton stroke, hand-independent: the composition must survive
// every wrist
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.38,
});
// the skeleton slab: a SOLID bar (stroke()'s dab chains show their ribs
// when length dwarfs width — wagon bodies and shadow strips must not)
const slab = (c, x, y, len, ang, w, col, a) => {
const x0 = x - Math.cos(ang) * len * 0.5, y0 = y - Math.sin(ang) * len * 0.5;
const x1 = x + Math.cos(ang) * len * 0.5, y1 = y + Math.sin(ang) * len * 0.5;
const d = Math.hypot(x1 - x0, y1 - y0);
const n = Math.max(2, Math.ceil(d / Math.max(0.7, w * 0.45)));
for (let k = 0; k <= n; k++) {
dab(c, lerp(x0, x1, k / n), lerp(y0, y1, k / n), Math.max(0.8, w * 0.62), col, a, 0, 1);
}
};
// -- THE GROUND PLANE ----------------------------------------------------
const vxN = 0.46 + rng() * 0.08; // vanishing x of the common
const hzY = H * clamp01(cond.horizonY);
const vxW = vxN * W;
const horizon = new Horizon(hzY, vxW, 1.5, H);
const gp = (xn, d) => horizon.computeGroundPlane(xn * W, d, H);
const gOf = (d) => horizon.ground(d);
const fhNear = H * 0.095; // a person at depth 1
// -- grading: the dusk leans everything violet; the fire leans back warm --
const gnd = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#141c2e', 0.72 * k);
else if (dusk) o = mixHex(o, '#3c3248', 0.44 * k);
else if (dawn) o = mixHex(o, '#8a92a8', 0.32 * k);
else if (golden) o = mixHex(o, '#c8905c', 0.16 * k);
else o = mixHex(o, '#c8c2a8', 0.06 * k); // locked daylight hours
if (misty) o = mixHex(o, '#d6dad6', 0.10 * k);
if (rainy) o = mixHex(o, '#6a7886', 0.14 * k);
if (stormy) o = mixHex(o, '#5a6270', 0.16 * k);
if (snowy) o = mixHex(o, '#dfe5ea', 0.22 * k);
return o;
};
const liftCol = night ? '#5c6478' : '#e8e0cc'; // the high register: cream by day, moonlit grey at night
// the DARK grader: for the camp's dark souls — wagons, figures, animals —
// which must sink toward the night, never lift toward the haze
const dark = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#10182a', 0.50 * k);
else if (dusk) o = mixHex(o, '#2c2438', 0.38 * k);
else if (dawn) o = mixHex(o, '#4a4c60', 0.30 * k);
else if (golden) o = mixHex(o, '#6a4c38', 0.25 * k);
else o = mixHex(o, '#3a3630', 0.15 * k);
if (misty) o = mixHex(o, '#8a9090', 0.07 * k);
if (rainy) o = mixHex(o, '#3a4450', 0.08 * k);
return o;
};
const hazeCol = gnd('#b8b4ac', 0.85);
const hazer = (h, k) => mixHex(h, hazeCol, clamp01(k));
// -- DICE ------------------------------------------------------------------
const nWagons = Math.max(1, Math.min(3, cond.motifCount)); // 1-3 caravans
const nFires = 1 + Math.floor(rng() * 3); // 1-3 campfires
const nHorses = 1 + Math.floor(rng() * 4); // 1-4 hobbled horses
const nFigWanted = 4 + Math.floor(rng() * 7); // 4-10 figures
const nChild = 1 + Math.floor(rng() * 2); // children between the wagons
const hasLaundry = rng() < 0.6;
const nDogs = rng() < 0.7 ? 1 + Math.floor(rng() * 2) : 0;
const nTrees = 1 + Math.floor(rng() * 3); // 1-3 scattered trees
const nTownWin = 2 + Math.floor(rng() * 3); // 2-4 lit windows in the town
const campXn = clamp01(0.5 + (cond.motifSide === 'left' ? -1 : 1) * (0.04 + rng() * 0.06));
const wind = (rng() < 0.5 ? -1 : 1) * (0.6 + rng() * 0.6); // smoke drift
// -- THE FIRES: the heart. Rolled first; the glow field steers everything --
const fireAmp = stormy ? 0 : rainy ? 0.7
: night ? 1.25 : dusk ? 1.0 : golden ? 0.55 : daylight ? 0.2 : 0.15;
const flameAmp = stormy ? 0 : rainy ? 0.6
: night ? 1.0 : dusk ? 1.0 : golden ? 0.8 : daylight ? 0.35 : 0; // dawn: dead to smoke
const smokeAmp = dawn ? 1.6 : stormy ? 1.0 : rainy ? 0.55
: night ? 0.55 : dusk ? 0.85 : 0.7;
const lowSmoke = rainy || stormy;
const fires = [];
for (let i = 0; i < nFires; i++) {
const d = clamp(0.58 + rng() * 0.28 + (i ? 0.05 : 0), 0.45, 0.92);
const xn = clamp(campXn + (i === 0 ? (rng() - 0.5) * 0.16
: (rng() < 0.5 ? -1 : 1) * (0.13 + rng() * 0.15)), 0.10, 0.90);
const p = gp(xn, d);
fires.push({ d, xn, x: p.x, y: p.y, s: p.scale, main: i === 0 });
}
fires.sort((a, b) => a.d - b.d); // far fires first everywhere
// a storm douses the fires: one lantern on a pole keeps the camp lit
const lantern = stormy
? { x: fires[0].x + fhNear * fires[0].s * 1.5 * (rng() < 0.5 ? -1 : 1),
y: fires[0].y, s: fires[0].s }
: null;
const fireGlowCol = '#e89040';
const fireGlow = (x, y) => {
let v = 0;
for (const f of fires) {
const r = H * 0.15 * (0.5 + f.s);
const dx = (x - f.x) / r, dy = (y - (f.y - fhNear * f.s * 0.25)) / r;
v += (f.main ? 1 : 0.75) * fireAmp * Math.exp(-(dx * dx + dy * dy));
}
if (lantern) {
const r = H * 0.09 * (0.5 + lantern.s);
const dx = (x - lantern.x) / r, dy = (y - (lantern.y - fhNear * lantern.s * 1.5)) / r;
v += 1.1 * Math.exp(-(dx * dx + dy * dy));
}
return v;
};
const nearestFire = (x, y) => {
let best = fires[0], bd = Infinity;
for (const f of fires) {
const dd = Math.hypot(x - f.x, y - f.y);
if (dd < bd) { bd = dd; best = f; }
}
return best;
};
// -- THE PRIME: the dusk engine, sky above the horizon, meadow below ------
const GPAL = groundPalette(season, cond.warmth); // the season's own greens
const meadowFar = gnd(GPAL.mid[0], 0.85);
const meadowMid = gnd(GPAL.mid[1], 0.9);
const meadowNear = gnd(GPAL.shade[0], 1);
const prime = (c) => {
const g = c.createLinearGradient(0, 0, 0, H);
for (const s of skyGradient(cond)) {
if (s.at > hzY / H + 0.02) break; // the sky only; the meadow primes itself
// mist must not wash the night pale: the dark hour takes its sky back
g.addColorStop(clamp01(s.at),
night ? mixHex(s.color, '#101a30', 0.45) : s.color);
}
g.addColorStop(clamp01(hzY / H), hazer(meadowFar, 0.30));
g.addColorStop(clamp01(hzY / H + 0.10), mixHex(meadowMid, meadowFar, 0.55));
g.addColorStop(1, meadowNear);
c.fillStyle = g;
c.fillRect(0, 0, W, H);
};
prime(ctx);
add(0.001, prime);
// -- THE SKY: broad violet-to-amber bands, the dusk engine -----------------
const SKY_BANDS = {
night: ['#141d33', '#23304b', '#3a4a66'],
dusk: ['#3f4a68', '#7c6480', '#c08358'],
dawn: ['#6f7c9c', '#c99070', '#ecc194'],
goldenHour: ['#8d6f92', '#d98d58', '#f2c47e'],
};
const bands = SKY_BANDS[tod] || ['#9cb8ca', '#c2d2d6', '#f0e9d2'];
const skyHi = night ? '#5c688a' : '#e6e3d8';
for (let i = 0; i < 580; i++) {
const x = -W * 0.03 + rng() * W * 1.06;
const hi = Math.pow(rng(), 0.9); // 0 high, 1 near the horizon
const y = hi * Math.max(4, hzY - H * 0.02);
const len = W * (0.04 + rng() * 0.16);
const wd = H * (0.006 + rng() * 0.022) * (0.75 + hi * 0.5);
const ang = (rng() - 0.5) * 0.24;
const bandIdx = Math.min(2, Math.floor(hi * 2.4));
stamped(0.02 + 0.10 * hi + rng() * 0.014, (c, jr) => {
band(c, jr, x, y, len, ang, wd, mixHex(bands[bandIdx], skyHi, rng() * 0.22),
(0.10 + rng() * 0.16) * (night ? 0.8 : 1));
});
}
// the warm breath low in the sky where the light dies
if (!night) {
const warmSky = dusk ? '#c08358' : golden ? '#f2c47e' : dawn ? '#ecc194' : '#f0e0b0';
for (let i = 0; i < 90; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY - Math.pow(rng(), 1.7) * H * 0.15;
stamped(0.09 + rng() * 0.05, (c, jr) => {
band(c, jr, x, y, W * (0.04 + rng() * 0.10), (rng() - 0.5) * 0.08,
H * (0.005 + rng() * 0.012), mixHex(warmSky, bands[2], rng() * 0.35),
0.12 + rng() * 0.18);
});
}
}
// -- sun or moon ------------------------------------------------------------
{
const sy = Math.max(H * 0.04, Math.min(cond.sunY * H, hzY - H * 0.03));
const sx = cond.sunX * W;
if (night) {
stamped(0.138, (c) => {
dab(c, sx, sy, H * 0.013, '#e8ecf2', 0.7);
dab(c, sx - H * 0.004, sy - H * 0.003, H * 0.007, '#f4f6f8', 0.5);
});
for (let i = 0; i < 8; i++) {
const a = rng() * TAU, r = H * (0.02 + rng() * 0.035);
stamped(0.141 + rng() * 0.010, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.010 + rng() * 0.014), '#4a5670', 0.04 + rng() * 0.05));
}
} else {
const core = golden ? '#f4c882' : dusk ? '#e8a878' : dawn ? '#f0d2a0' : '#f6e2b4';
const dim = cond.sunStrength == null ? 1 : cond.sunStrength;
stamped(0.138, (c) => {
dab(c, sx, sy, H * 0.016, core, 0.5 * dim);
dab(c, sx, sy, H * 0.009, mixHex(core, '#fff8e4', 0.5), 0.55 * dim);
});
for (let i = 0; i < 9; i++) {
const a = rng() * TAU, r = H * (0.024 + rng() * 0.045);
stamped(0.142 + rng() * 0.010, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.011 + rng() * 0.016), mixHex(core, bands[1], 0.5),
(0.04 + rng() * 0.05) * Math.max(0.35, dim)));
}
}
}
// -- THE STARS: the first stars at dusk, a dense field at night -------------
{
const starWx = { clear: 1, mist: 0.35, overcast: 0.08, rain: 0.2, snow: 0.25 }[weather];
const nStars = Math.round((night ? 95 : dusk ? 16 : dawn ? 8 : 4) * starWx);
for (let i = 0; i < nStars; i++) {
const x = rng() * W;
const y = Math.pow(rng(), 1.2) * Math.max(8, hzY - H * 0.04);
const b = rng();
const w = H * (0.0012 + b * b * 0.0035);
const a = 0.30 + b * 0.55;
stamped(0.150 + rng() * 0.05, (c, jr) => {
touch(c, jr, x, y, w, pick(['#d8e4f8', '#e8eefa', '#c2d2ee', '#f2f4f8']), a);
});
if (b > 0.85) {
stamped(0.155 + rng() * 0.04, (c) => dab(c, x, y, w * 3.4, '#5a6a9a', 0.10 + rng() * 0.08));
}
}
}
// -- THE TOWN: a far low silhouette of rooftops on the horizon --------------
{
const townCx = clamp01(campXn + (campXn < 0.5 ? 0.16 + rng() * 0.10 : -(0.16 + rng() * 0.10)));
const tW = W * (0.30 + rng() * 0.16);
const tX0 = townCx * W - tW / 2;
const nSeg = 26;
const tops = [];
for (let i = 0; i <= nSeg; i++) tops.push(H * (0.012 + Math.pow(rng(), 1.4) * 0.030));
const hasSpire = rng() < 0.55;
const spireSeg = Math.floor(rng() * nSeg);
const roofY = (u) => { // u 0..1 across the town span -> roofline y
const f = clamp01(u) * nSeg;
const i0 = Math.floor(f);
return hzY - lerp(tops[i0], tops[Math.min(nSeg, i0 + 1)], f - i0);
};
for (let i = 0, nT = 340; i < nT; i++) {
const u = rng();
const x = tX0 + u * tW;
const top = roofY(u) - (hasSpire && Math.abs(u - (spireSeg + 0.5) / nSeg) < 0.02
? H * (0.045 + rng() * 0.035) : 0);
const v = Math.pow(rng(), 0.65); // seated toward the base: walls, not cloud
const y = lerp(hzY + H * 0.008, top, v);
stamped(0.205 + rng() * 0.05, (c, jr) => {
band(c, jr, x, y, H * (0.006 + rng() * 0.016), Math.PI / 2 + (rng() - 0.5) * 0.2,
Math.max(1.6, H * (0.006 + rng() * 0.009)),
hazer(gnd(mixHex('#2c2e44', '#4a4258', rng() * 0.6), 0.95), 0.40 + rng() * 0.18),
0.38 + rng() * 0.25);
});
}
// the town's seat: a low dark band hugging the horizon line
for (let i = 0; i < 26; i++) {
const x = tX0 + rng() * tW;
stamped(0.2595 + rng() * 0.002, (c, jr) => {
band(c, jr, x, hzY + H * 0.004, W * (0.02 + rng() * 0.04), 0,
Math.max(1.8, H * 0.008), hazer(gnd('#1e2233', 0.95), 0.32), 0.5);
});
}
if (hasSpire) { // the church spire over the roofs
const sxp = tX0 + ((spireSeg + 0.5) / nSeg) * tW;
const sh = H * (0.055 + rng() * 0.045);
stamped(0.256, (c, jr) => {
band(c, jr, sxp, hzY - sh * 0.5, sh, -Math.PI / 2, Math.max(2.2, W * 0.008),
hazer(gnd('#343044', 0.95), 0.40), 0.55);
band(c, jr, sxp, hzY - sh - sh * 0.16, sh * 0.34, -Math.PI / 2, Math.max(1.6, W * 0.005),
hazer(gnd('#2a2638', 0.95), 0.42), 0.55);
});
}
// the lit windows: the town is going to bed too
const litK = night ? 1 : dusk || dawn ? 0.85 : golden ? 0.5 : 0.15;
for (let i = 0; i < nTownWin + 3; i++) {
const u = 0.08 + rng() * 0.84;
const x = tX0 + u * tW;
const y = hzY - H * (0.006 + rng() * 0.020);
const lit = rng() < litK;
stamped(0.262 + rng() * 0.006, (c, jr) => {
if (lit) {
touch(c, jr, x, y, Math.max(1.2, H * 0.0042),
pick(['#f0c860', '#e8b850', '#f6dc86']), 0.7);
dab(c, x, y, Math.max(2.2, H * 0.010), mixHex('#e8b850', '#3a4456', 0.55), 0.12);
} else {
touch(c, jr, x, y, Math.max(1.0, H * 0.0035), mixHex('#1c2028', hazeCol, 0.5), 0.4);
}
});
}
}
// -- THE COMMON: a broad meadow receding to the horizon ---------------------
for (let i = 0, nM = 55; i < nM; i++) { // the far wash
const d = 0.04 + rng() * 0.22;
const p = gp(rng(), d);
stamped(0.285 + rng() * 0.02, veil(p.x + (rng() - 0.5) * W * 0.3, p.y - H * rng() * 0.015,
W * (0.08 + rng() * 0.20), (rng() - 0.5) * 0.06, Math.max(2, H * (0.014 + rng() * 0.028)),
mixHex(meadowFar, hazeCol, 0.3), 0.05 + rng() * 0.05, 0.25));
}
for (let i = 0, nG = Math.round(1980 * covK); i < nG; i++) {
const d = 1 - Math.pow(rng(), 1.5) * 0.96; // bias toward the near plane
const g = gOf(d);
const xn = -0.02 + rng() * 1.04;
const p = gp(xn, d);
const r = rng();
let col = r < 0.38 ? pick(GPAL.mid) : r < 0.86 ? pick(GPAL.shade) : pick(GPAL.light);
col = gnd(col, 0.92);
const gl = clamp01(fireGlow(p.x, p.y));
if (gl > 0.03) col = mixHex(col, fireGlowCol, Math.min(0.55, gl * 0.55)); // firelight on the grass
col = hazer(col, (1 - g) * (misty ? 0.60 : 0.30));
if (night) col = mixHex(col, '#161e30', 0.24); // the night keeps the land dark
else if (dawn) col = mixHex(col, '#6a7086', 0.30); // dawn greys the sand down
else if (golden) col = mixHex(col, '#8a7048', 0.16); // golden hour warms it mid-deep
const sz = Math.max(1.2, H * (0.005 + rng() * 0.011) * (0.3 + g * 1.15));
stamped(0.372 + 0.150 * (1 - d) + rng() * 0.014, (c, jr) => {
smear(c, jr, p.x, p.y, sz * (1.2 + rng() * 0.8), (rng() - 0.5) * 0.5, sz * 0.9,
col, 0.16 + rng() * 0.22, 0.6);
});
}
if (snowy || season === 'winter') { // frost sparkle on the grass
for (let i = 0, nF = Math.round(95 * covK); i < nF; i++) {
const d = 0.35 + rng() * 0.62;
const p = gp(rng(), d);
stamped(0.545 + rng() * 0.012, (c, jr) => {
touch(c, jr, p.x, p.y, Math.max(1.0, H * 0.0035 * (0.4 + gOf(d))),
pick(['#eef4fa', '#dfe8f2', '#f8fbfe']), 0.25 + rng() * 0.3, 0, 0.6);
});
}
}
// -- THE TREES: 1-3 scattered on the common ---------------------------------
for (let ti = 0; ti < nTrees; ti++) {
const d = 0.22 + rng() * 0.34;
const side = ti % 2 === 0 ? -1 : 1;
const xn = clamp(campXn + side * (0.22 + rng() * 0.18), 0.05, 0.95);
const p = gp(xn, d);
const s = p.scale;
const th = fhNear * s * (1.7 + rng() * 1.1);
const winterBare = season === 'winter' || (season === 'autumn' && rng() < 0.3);
const leafCols = {
spring: ['#7ca24a', '#94ba52', '#5c8258'],
summer: ['#5c7a3c', '#6c8a48', '#4c6a34'],
autumn: ['#b07838', '#c08a48', '#8a5c30'],
winter: ['#6a6460', '#5a564e', '#78726a'],
}[season];
const slot = 0.548 + 0.006 * ti;
stamped(slot, (c, jr) => {
const trunkCol = dark('#3a3026', 0.95);
band(c, jr, p.x, p.y - th * 0.35, th * 0.7, -Math.PI / 2 + (rng() - 0.5) * 0.08,
Math.max(1.6, th * 0.09), trunkCol, 0.8);
band(c, jr, p.x + th * 0.05, p.y - th * 0.72, th * 0.34, -Math.PI / 2 - 0.3,
Math.max(1.0, th * 0.05), trunkCol, 0.7);
if (winterBare) {
for (let k = 0; k < 8; k++) {
const a = -Math.PI / 2 + (rng() - 0.5) * 2.2;
rigger(c, jr, { x: p.x + (rng() - 0.5) * th * 0.08, y: p.y - th * (0.55 + rng() * 0.35),
length: th * (0.18 + rng() * 0.22), color: trunkCol, alpha: 0.6,
angle: a, width: Math.max(0.7, th * 0.025), bow: th * 0.03 });
}
} else {
for (let k = 0, nC = 75; k < nC; k++) {
const a = rng() * TAU, r = Math.pow(rng(), 0.55) * th * 0.48;
const cx = p.x + Math.cos(a) * r;
const cy = p.y - th * (0.88 + Math.sin(a) * r / th * 0.95);
const col = hazer(gnd(pick(leafCols), 0.95), (1 - s) * (misty ? 0.6 : 0.3));
touch(c, jr, cx, cy, Math.max(2.0, th * (0.12 + rng() * 0.14)), col,
0.5 + rng() * 0.3, (rng() - 0.5) * 0.6, 0.75);
}
}
});
}
// -- THE FIRE GLOW: soft haloes bleeding into the surrounding grass ---------
for (let i = 0; i < fires.length; i++) {
const f = fires[i];
stamped(0.560 + 0.004 * i, (c) => {
const amp = (f.main ? 1 : 0.8) * fireAmp;
if (amp <= 0.02) return;
const fh = fhNear * f.s;
const R = H * 0.17 * (0.5 + f.s);
dab(c, f.x, f.y - fh * 0.25, R, mixHex(fireGlowCol, '#5c4468', 0.42), 0.17 * amp, 0, 0.55);
dab(c, f.x, f.y - fh * 0.20, R * 0.62, mixHex('#f0a850', '#6a4a5c', 0.30), 0.21 * amp, 0, 0.5);
dab(c, f.x, f.y, R * 0.30, '#f4b45e', 0.30 * amp, 0, 0.45);
dab(c, f.x, f.y + fh * 0.08, R * 0.55, mixHex(fireGlowCol, '#4a3c50', 0.38), 0.16 * amp, 0, 0.35);
// organic edge: offset haloes so the bleed never reads as a disc
for (let k = 0; k < 14; k++) {
const a = rng() * TAU, r = R * (0.4 + rng() * 0.75);
dab(c, f.x + Math.cos(a) * r, f.y - fh * 0.15 + Math.sin(a) * r * 0.45,
R * (0.25 + rng() * 0.32), mixHex(fireGlowCol, '#5c4468', 0.48),
(0.08 + rng() * 0.07) * amp, 0, 0.5);
}
});
}
if (lantern) { // the storm's only light: a lantern on a pole
stamped(0.572, (c, jr) => {
const fh = fhNear * lantern.s;
rigger(c, jr, { x: lantern.x, y: lantern.y, length: fh * 1.5,
color: gnd('#2a2620', 0.95), alpha: 0.7, angle: -Math.PI / 2,
width: Math.max(1.0, fh * 0.05), bow: 0 });
touch(c, jr, lantern.x, lantern.y - fh * 1.5, fh * 0.14, '#f4d070', 0.85);
for (let k = 0; k < 12; k++) {
const a = rng() * TAU, r = fh * (0.2 + rng() * 0.6);
dab(c, lantern.x + Math.cos(a) * r, lantern.y - fh * 1.5 + Math.sin(a) * r,
fh * (0.18 + rng() * 0.24), mixHex('#e8a050', '#3a4456', 0.38), 0.12 + rng() * 0.10);
}
});
}
// -- THE SMOKE: grey-violet columns merging with the dark -------------------
if (smokeAmp > 0.02) {
for (let i = 0; i < fires.length; i++) {
const f = fires[i];
stamped(0.578 + 0.004 * i, (c) => {
const fh = fhNear * f.s;
const skyLow = mixHex(bands[2], hazeCol, 0.3);
for (let k = 0, nS = 30; k < nS; k++) {
const t = Math.pow(rng(), 0.85);
const riseH = lowSmoke ? fh * (0.6 + rng() * 0.7) : fh * (2.2 + 2.6 * rng());
const x = f.x + wind * t * t * fh * (lowSmoke ? 2.6 : 1.4) + gauss() * fh * 0.16 * (0.3 + t);
const y = f.y - fh * 0.5 - t * riseH;
const col = mixHex(mixHex('#8a8696', '#545064', night ? 0.35 : 0), skyLow,
dawn ? 0.10 + rng() * 0.15 : 0.30 + rng() * 0.30);
dab(c, x, y, fh * (0.12 + 0.34 * t), col,
Math.max(0.014, (0.22 - 0.11 * t) * smokeAmp), 0, 0.6);
}
});
}
}
// -- THE WAGONS: the colorful souls of the scene ----------------------------
const WAGON_COLS = ['#2e4a34', '#6e3430', '#31435c', '#54462a'];
const CANVAS_COL = '#a89878';
const SCROLL = ['#d8b050', '#e8cc70', '#c87830', '#e0b840'];
const hitched = dawn; // the camp breaking up: wagons hitched
const wagons = [];
for (let i = 0; i < nWagons; i++) {
const d = clamp(0.42 + rng() * 0.46, 0.30, 0.94);
const side = i % 2 === 0 ? -1 : 1;
let xn = clamp(campXn + side * (0.16 + rng() * 0.17), 0.10, 0.90);
let p = gp(xn, d);
// a wagon never parks in the fire
for (let tries = 0; tries < 6; tries++) {
const nf = nearestFire(p.x, p.y);
if (Math.hypot(p.x - nf.x, p.y - nf.y) > fhNear * p.scale * 2.2) break;
xn = clamp(xn + (xn < campXn ? -0.06 : 0.06), 0.08, 0.92);
p = gp(xn, d);
}
wagons.push({ d, xn, x: p.x, y: p.y, s: p.scale, face: rng() < 0.5 ? -1 : 1 });
}
wagons.sort((a, b) => a.d - b.d); // far wagons first
for (let wi = 0; wi < wagons.length; wi++) {
const wg = wagons[wi];
const s = wg.s, fh = fhNear * s;
const wLen = fh * 3.9, x0 = wg.x - wLen / 2, yBase = wg.y - fh * 0.10;
const bodyH = fh * 1.15, archH = fh * 0.5;
const colB = dark(pick(WAGON_COLS), 0.95);
const colDark = mixHex(colB, '#121016', 0.38);
const hAt = (u) => bodyH * (0.78 + 0.32 * Math.sin(u * Math.PI));
const base = 0.596 + 0.055 * (wi / Math.max(1, wagons.length));
const nf = nearestFire(wg.x, wg.y);
const fSide = nf.x >= wg.x ? 1 : -1;
// the body: rounded caravan flanks, plain solid masses
stamped(base, (c, jr) => {
for (let i = 0; i < 40; i++) {
const u = (i + 0.5) / 40;
const x = x0 + u * wLen;
const hgt = hAt(u);
const endK = Math.abs(u - 0.5) * 2;
let col = mixHex(colB, colDark, 0.14 + 0.30 * endK);
col = mixHex(col, fireGlowCol, clamp01(fireGlow(x, yBase - hgt * 0.5)) * 0.22);
col = hazer(col, (1 - s) * (misty ? 0.5 : 0.22));
band(c, jr, x, yBase - hgt * 0.5, hgt * 0.98, Math.PI / 2,
Math.max(2.4, wLen / 19), col, 0.74 + rng() * 0.18);
}
});
// the canvas tilt: a rounded roof, slightly askew
stamped(base + 0.008, (c, jr) => {
const tilt = (rng() - 0.5) * 0.10;
for (let i = 0; i < 10; i++) {
const u = (i + 0.5) / 10;
const x = x0 + u * wLen;
const yT = yBase - hAt(u);
const ang = tilt + (rng() - 0.5) * 0.1;
const canvasCol = mixHex(gnd(CANVAS_COL, 0.95), colDark, 0.15 + 0.25 * Math.abs(u - 0.5) * 2);
band(c, jr, x, yT - fh * 0.11, fh * 0.5, ang, Math.max(2.6, fh * 0.44),
hazer(canvasCol, (1 - s) * 0.24), 0.6 + rng() * 0.2);
}
});
// the scrollwork: a few brighter dabs, never detailed ornament — and
// dimmed by the hour: paint does not gleam in the dark
stamped(base + 0.016, (c, jr) => {
const scrollDim = night ? 0.30 : dawn || misty ? 0.42 : 1;
for (let k = 0; k < 7; k++) {
const u = 0.12 + rng() * 0.76;
const x = x0 + u * wLen;
const y = yBase - hAt(u) * (0.42 + rng() * 0.32);
touch(c, jr, x, y, fh * 0.085,
mixHex(pick(SCROLL), colDark, night ? 0.45 : dawn || misty ? 0.30 : 0.15),
(0.45 + rng() * 0.3) * scrollDim);
}
// the door: a dark cut in the flank
const du = 0.58 + rng() * 0.14;
band(c, jr, x0 + du * wLen, yBase - hAt(du) * 0.42, hAt(du) * 0.7, Math.PI / 2,
Math.max(1.6, fh * 0.16), mixHex(colDark, '#0e0c10', 0.3), 0.7);
});
// the wheels: dark rings with spokes, firelight on the fire-side rim
stamped(base + 0.024, (c, jr) => {
for (const u of [0.20, 0.80]) {
const wx = x0 + u * wLen, r = fh * 0.5, wy = yBase - r * 0.55;
const ringCol = mixHex('#1c1a16', colB, 0.18);
for (let k = 0; k < 12; k++) { // the rim: tangent slabs, a solid circle
const a = (k / 12) * TAU + rng() * 0.15;
band(c, jr, wx + Math.cos(a) * r, wy + Math.sin(a) * r * 0.88,
r * 0.46, a + Math.PI / 2, Math.max(1.4, fh * 0.075), ringCol, 0.8);
}
for (let k = 0; k < 5; k++) {
const a = (k / 5) * TAU + 0.35;
rigger(c, jr, { x: wx, y: wy, length: r * 0.92, color: ringCol,
alpha: 0.6, angle: a, width: Math.max(0.7, fh * 0.035), bow: 0 });
}
touch(c, jr, wx, wy, fh * 0.08, mixHex('#8a7a50', '#2a2620', night || dawn ? 0.5 : 0), 0.65);
// firelight catches the rim on the fire side
const fw = fireGlow(wx, wy);
if (fw > 0.10) {
const dirF = Math.atan2((nf.y - fh * 0.2) - wy, nf.x - wx);
for (let m = 0; m < 3; m++) {
const a = dirF + (rng() - 0.5) * 0.9;
touch(c, jr, wx + Math.cos(a) * r, wy + Math.sin(a) * r * 0.88,
fh * 0.07, mixHex('#f0a850', '#f8d080', rng() * 0.5),
Math.min(0.6, 0.15 + fw * 0.45));
}
}
}
});
// the shaft, the ground shadow, the hitch
stamped(base + 0.032, (c, jr) => {
const shaftLen = hitched ? fh * 1.9 : fh * 1.0;
rigger(c, jr, { x: wg.x + wg.face * wLen * 0.48, y: yBase - fh * 0.12,
length: shaftLen, color: mixHex(colDark, '#0e0c10', 0.2), alpha: 0.7,
angle: Math.atan2(fh * 0.20, wg.face * shaftLen), width: Math.max(0.9, fh * 0.05), bow: 0 });
for (let k = 0; k < 8; k++) {
const u = rng();
dab(c, x0 + u * wLen, yBase + fh * 0.05, Math.max(1.6, fh * 0.14),
gnd(mixHex('#241e18', '#14121a', 0.3), 0.95), 0.28, 0, 0.45);
}
});
// the fire-side warm rim, and at night a glowing window
stamped(base + 0.040, (c, jr) => {
const fw = fireGlow(wg.x + fSide * wLen * 0.5, yBase - bodyH * 0.5);
if (fw > 0.08) {
const xEdge = fSide > 0 ? x0 + wLen : x0;
for (let m = 0; m < 4; m++) {
const v = 0.2 + rng() * 0.6;
touch(c, jr, xEdge + (rng() - 0.5) * fh * 0.06, yBase - hAt(fSide > 0 ? 0.9 : 0.1) * v,
fh * 0.10, mixHex('#f0a850', '#f8d080', rng() * 0.5),
Math.min(0.55, 0.14 + fw * 0.4), 0, 0.8);
}
}
if (night || (dusk && rng() < 0.4)) { // the wagon window glows
const wu = 0.30 + rng() * 0.2;
const wx = x0 + wu * wLen, wy = yBase - hAt(wu) * 0.72;
touch(c, jr, wx, wy, fh * 0.13, pick(['#f0c860', '#e8b850']), 0.7);
dab(c, wx, wy, fh * 0.3, mixHex('#e8b850', '#3a3050', 0.55), 0.14);
}
});
}
// -- THE HORSES: hobbled in the grass, dark dabs with mane strokes ----------
const horses = [];
for (let i = 0; i < nHorses; i++) {
if (hitched && wagons.length) { // dawn: in harness, at the shafts' ends
const wg = wagons[i % wagons.length];
const d = clamp(wg.d + (rng() - 0.5) * 0.03, 0.3, 0.96);
// the ground plane compresses normalized offsets: a screen-side step
// out to the shaft's end (~3.7 person-heights) needs dxn = fh*L / (W*g)
const dxn = wg.face * (fhNear * (3.5 + rng() * 0.5)) / W;
const xn = clamp(wg.xn + dxn, 0.05, 0.95);
const p = gp(xn, d);
horses.push({ d, xn, x: p.x, y: p.y, s: p.scale });
} else {
const d = clamp(0.50 + rng() * 0.40, 0.35, 0.94);
let xn = clamp(campXn + (rng() < 0.5 ? -1 : 1) * (0.10 + rng() * 0.22), 0.06, 0.94);
let p = gp(xn, d);
for (let tries = 0; tries < 5; tries++) {
const nf = nearestFire(p.x, p.y);
if (Math.hypot(p.x - nf.x, p.y - nf.y) > fhNear * p.scale * 1.3) break;
xn = clamp(xn + (xn < campXn ? -0.05 : 0.05), 0.05, 0.95);
p = gp(xn, d);
}
horses.push({ d, xn, x: p.x, y: p.y, s: p.scale });
}
}
horses.sort((a, b) => a.d - b.d);
for (let hi = 0; hi < horses.length; hi++) {
const h = horses[hi];
stamped(0.656 + 0.020 * (hi / Math.max(1, horses.length)), (c, jr) => {
const fh = fhNear * h.s;
const bx = h.x, by = h.y;
const bodyCol = dark(pick(['#3a2e22', '#2c2620', '#4a3a28', '#33261e']), 0.95);
const nf = nearestFire(bx, by);
const fSide = nf.x >= bx ? 1 : -1;
const graze = golden || dawn || rng() < 0.4;
// the barrel
smear(c, jr, bx, by - fh * 0.34, fh * 0.80, (rng() - 0.5) * 0.10, fh * 0.30, bodyCol, 0.85, 0.68);
// neck and head: grazing low, or held
if (graze) {
band(c, jr, bx + fSide * fh * 0.38, by - fh * 0.30, fh * 0.30, 0.55 * fSide,
Math.max(0.8, fh * 0.12), bodyCol, 0.8);
touch(c, jr, bx + fSide * fh * 0.52, by - fh * 0.12, fh * 0.13, bodyCol, 0.85);
} else {
band(c, jr, bx + fSide * fh * 0.36, by - fh * 0.52, fh * 0.30, -Math.PI / 2 - 0.5 * fSide,
Math.max(0.8, fh * 0.12), bodyCol, 0.8);
touch(c, jr, bx + fSide * fh * 0.44, by - fh * 0.72, fh * 0.13, bodyCol, 0.85);
}
// mane strokes
for (let m = 0; m < 4; m++) {
rigger(c, jr, { x: bx + fSide * fh * (0.30 + rng() * 0.10), y: by - fh * (0.52 + rng() * 0.14),
length: fh * (0.10 + rng() * 0.08), color: dark('#1c1814', 0.95), alpha: 0.6,
angle: -Math.PI / 2 + (rng() - 0.5) * 0.5, width: Math.max(0.6, fh * 0.03), bow: fh * 0.02 });
}
// legs
for (const lx of [-0.26, 0.02, 0.24]) {
band(c, jr, bx + fh * lx, by - fh * 0.14, fh * 0.30, -Math.PI / 2 + (rng() - 0.5) * 0.08,
Math.max(0.7, fh * 0.06), dark('#221e18', 0.95), 0.7);
}
// tail
rigger(c, jr, { x: bx - fSide * fh * 0.38, y: by - fh * 0.40, length: fh * 0.30,
color: dark('#1c1814', 0.95), alpha: 0.6, angle: Math.PI / 2 + fSide * 0.25,
width: Math.max(0.8, fh * 0.05), bow: fSide * fh * 0.05 });
// the hobble: a short strap between the forelegs
if (!hitched) {
rigger(c, jr, { x: bx - fh * 0.10, y: by - fh * 0.05, length: fh * 0.14,
color: gnd('#4a4034', 0.95), alpha: 0.55, angle: 0, width: Math.max(0.6, fh * 0.03), bow: 0 });
}
// firelight on the flank, fire side only
const fw = fireGlow(bx + fSide * fh * 0.35, by - fh * 0.35);
if (fw > 0.10) {
for (let m = 0; m < 2; m++) {
touch(c, jr, bx + fSide * fh * (0.30 + rng() * 0.08), by - fh * (0.28 + rng() * 0.14),
fh * 0.10, mixHex('#f0a850', '#f8d080', rng() * 0.5),
Math.min(0.5, 0.14 + fw * 0.38), 0, 0.7);
}
}
});
}
// -- THE LAUNDRY LINE: the camp is lived in ----------------------------------
if (hasLaundry && wagons.length) {
const wg = wagons[wagons.length - 1]; // the nearest wagon
const fh = fhNear * wg.s;
const wLen = fh * 3.9, x0 = wg.x - wLen / 2, yBase = wg.y - fh * 0.10;
const lineSide = rng() < 0.5 ? -1 : 1;
const ax = lineSide < 0 ? x0 : x0 + wLen; // tied to a roof corner
const ay = yBase - fh * 1.15 * (0.78 + 0.32) - fh * 0.18;
const bx = ax + lineSide * fh * 1.9, by = ay + fh * 0.50; // to a prop pole
stamped(0.684, (c, jr) => {
const len = Math.hypot(bx - ax, by - ay);
rigger(c, jr, { x: ax, y: ay, length: len, color: gnd('#3a3830', 0.95), alpha: 0.65,
angle: Math.atan2(by - ay, bx - ax), width: Math.max(0.6, fh * 0.02), bow: fh * 0.22 });
rigger(c, jr, { x: bx, y: wg.y, length: wg.y - by + fh * 0.05, // the prop pole
color: gnd('#332a20', 0.95), alpha: 0.8, angle: -Math.PI / 2,
width: Math.max(1.0, fh * 0.045), bow: 0 });
const CLOTH = ['#d8d0c0', '#b8c0cc', '#c8b0a0', '#a8b8a0'];
for (let k = 0; k < 6; k++) {
const t = 0.1 + rng() * 0.8;
const px = lerp(ax, bx, t), py = lerp(ay, by, t) + fh * 0.22 * Math.sin(Math.PI * t) + fh * 0.06;
touch(c, jr, px, py, fh * (0.12 + rng() * 0.08), gnd(pick(CLOTH), 0.95),
0.45 + rng() * 0.25, 0, 1.25);
}
});
}
// -- THE COOKING POTS: over the fires, before the flames ---------------------
for (let i = 0; i < Math.min(2, fires.length); i++) {
const f = fires[i];
stamped(0.702 + 0.004 * i, (c, jr) => {
const fh = fhNear * f.s;
for (const dx of [-0.13, 0.13]) {
rigger(c, jr, { x: f.x + fh * dx, y: f.y, length: fh * 0.55,
color: gnd('#2a241e', 0.95), alpha: 0.6,
angle: Math.atan2(-fh * 0.52, -dx * fh), width: Math.max(0.7, fh * 0.03), bow: 0 });
}
slab(c, f.x, f.y - fh * 0.46, fh * 0.22, 0, fh * 0.14, gnd('#26221e', 0.95), 0.8);
touch(c, jr, f.x, f.y - fh * 0.52, fh * 0.05, '#3a342c', 0.7);
});
}
// -- THE FIGURES: the camp's people, silhouettes against the glow -----------
const COATS = ['#3a3044', '#2e3444', '#4a3c30', '#563e34', '#38444e', '#5c4052', '#6e4a30'];
const ACCENTS = ['#c03830', '#d89030', '#3868a8', '#8a4a78', '#c8a028', '#3c7a4a'];
const people = [];
for (const f of fires) { // gathered at the fires
const n = 1 + Math.floor(rng() * 2);
for (let k = 0; k < n; k++) {
people.push({
d: clamp(f.d + (rng() - 0.5) * 0.07, 0.45, 0.95),
xn: clamp(f.xn + (rng() < 0.5 ? -1 : 1) * (0.035 + rng() * 0.045), 0.08, 0.92),
kind: rng() < 0.55 ? 'standing' : 'seated',
});
}
}
people.push({ d: clamp(0.62 + rng() * 0.24, 0.5, 0.92), // the woman with the pot
xn: clamp(campXn + (rng() - 0.5) * 0.30, 0.08, 0.92), kind: 'pot' });
while (people.length < nFigWanted) { // wanderers of the camp
people.push({ d: clamp(0.48 + rng() * 0.46, 0.4, 0.94),
xn: clamp(rng(), 0.08, 0.92), kind: rng() < 0.6 ? 'standing' : 'seated' });
}
while (people.length > nFigWanted) people.pop();
for (let i = 0; i < nChild; i++) { // children between the wagons
let d, xn;
if (wagons.length >= 2) {
const a = wagons[0], b = wagons[1];
d = clamp((a.d + b.d) / 2 + (rng() - 0.5) * 0.06, 0.35, 0.95);
xn = clamp((a.xn + b.xn) / 2 + (rng() - 0.5) * 0.08, 0.08, 0.92);
} else {
const a = wagons[0] || { d: 0.6, xn: campXn };
d = clamp(a.d + (rng() - 0.5) * 0.08, 0.35, 0.95);
xn = clamp((a.xn + campXn) / 2 + (rng() - 0.5) * 0.08, 0.08, 0.92);
}
people.push({ d, xn, kind: 'child' });
}
for (let i = 0; i < nDogs; i++) {
const wg = wagons.length ? wagons[Math.floor(rng() * wagons.length)] : { d: 0.65, xn: campXn };
people.push({ d: clamp(wg.d + (rng() - 0.5) * 0.10, 0.35, 0.95),
xn: clamp(wg.xn + (rng() < 0.5 ? -1 : 1) * (0.05 + rng() * 0.06), 0.06, 0.94), kind: 'dog' });
}
people.sort((a, b) => a.d - b.d);
for (let pi = 0; pi < people.length; pi++) {
const pg = people[pi];
const g = gOf(pg.d);
const p = gp(pg.xn, pg.d);
const slot = 0.714 + 0.046 * (pi / Math.max(1, people.length));
if (pg.kind === 'dog') {
stamped(slot, (c, jr) => {
const fh = fhNear * p.scale * 0.45;
const bodyCol = dark(pick(['#3c3228', '#2c2620', '#4a3c2c']), 0.95);
smear(c, jr, p.x, p.y - fh * 0.4, fh * 1.1, (rng() - 0.5) * 0.1, fh * 0.42, bodyCol, 0.8, 0.6);
touch(c, jr, p.x + fh * 0.62, p.y - fh * 0.62, fh * 0.26, dark('#2c241c', 0.95), 0.85);
for (const lx of [-0.3, 0.25]) {
band(c, jr, p.x + fh * lx, p.y - fh * 0.16, fh * 0.32, -Math.PI / 2,
Math.max(0.6, fh * 0.09), dark('#221e18', 0.95), 0.7);
}
rigger(c, jr, { x: p.x - fh * 0.55, y: p.y - fh * 0.5, length: fh * 0.5,
color: bodyCol, alpha: 0.6, angle: Math.PI / 2 + 0.7, width: Math.max(0.6, fh * 0.08), bow: fh * 0.2 });
rigger(c, jr, { x: p.x - fh * 0.4, y: p.y, length: fh * 0.4, // the tether
color: gnd('#4a4034', 0.95), alpha: 0.45, angle: 0.2, width: Math.max(0.5, fh * 0.05), bow: 0 });
// firelight on the flank
const nf = nearestFire(p.x, p.y);
const fSide = nf.x >= p.x ? 1 : -1;
const fw = fireGlow(p.x + fSide * fh * 0.5, p.y - fh * 0.4);
if (fw > 0.12) touch(c, jr, p.x + fSide * fh * 0.5, p.y - fh * 0.4, fh * 0.3,
mixHex('#f0a850', '#f8d080', 0.4), Math.min(0.45, 0.12 + fw * 0.35), 0, 0.7);
});
continue;
}
const child = pg.kind === 'child';
stamped(slot, (c, jr) => {
const fh = fhNear * p.scale * (child ? 0.58 : 1);
const x = p.x, y = p.y;
const nf = nearestFire(x, y);
const fSide = nf.x >= x ? 1 : -1;
const lean = (rng() - 0.5) * (child ? 0.26 : 0.12);
const coat = dark(child ? pick(['#8a4a3c', '#3c5a7c', '#5c6a3c']) : pick(COATS), 0.95);
const acc = dark(pick(ACCENTS), 0.85);
const seated = pg.kind === 'seated';
const fw = fireGlow(x, y - fh * 0.5);
if (seated) {
// seated: a low warm-side mass, shoulders, head — no face, no contour
smear(c, jr, x, y - fh * 0.22, fh * 0.52, (rng() - 0.5) * 0.1, fh * 0.30,
coat, 0.82, 0.55);
touch(c, jr, x + fSide * fh * 0.06, y - fh * 0.40, fh * 0.24,
dark(mixHex(coat, '#c8c2b4', 0.10), 0.95), 0.65, 0, 0.6);
touch(c, jr, x + lean * fh * 0.3, y - fh * 0.58, fh * 0.14,
dark(rng() < 0.5 ? '#1e1c22' : mixHex('#8a6248', '#3a3430', 0.4), 0.95), 0.85);
touch(c, jr, x, y - fh * 0.52, fh * 0.15, acc, 0.7, 0, 0.6);
band(c, jr, x - fSide * fh * 0.24, y - fh * 0.08, fh * 0.20, -Math.PI / 2 + 0.3 * -fSide,
Math.max(0.8, fh * 0.10), dark('#221e26', 0.95), 0.7); // the outstretched legs
} else {
// standing: legs, coat mass, shoulders, head
band(c, jr, x - fh * 0.08, y - fh * 0.14, fh * 0.28, -Math.PI / 2 + lean * 0.4,
Math.max(0.8, fh * 0.09), dark('#221e26', 0.95), 0.7);
band(c, jr, x + fh * 0.08, y - fh * 0.14, fh * 0.28, -Math.PI / 2 + lean * 0.4,
Math.max(0.8, fh * 0.09), dark('#221e26', 0.95), 0.7);
band(c, jr, x, y - fh * 0.42, fh * 0.52, -Math.PI / 2 + lean, fh * 0.30,
coat, 0.80);
band(c, jr, x + fSide * fh * 0.04, y - fh * 0.36, fh * 0.38, -Math.PI / 2 + lean,
fh * 0.20, dark(mixHex(coat, '#0e0c10', 0.22), 0.95), 0.55);
touch(c, jr, x, y - fh * 0.70, fh * 0.26, dark(mixHex(coat, '#c8c2b4', 0.10), 0.95), 0.65, 0, 0.55);
touch(c, jr, x + lean * fh * 0.3, y - fh * 0.86, fh * 0.14,
dark(rng() < 0.5 ? '#1e1c22' : mixHex('#8a6248', '#3a3430', 0.4), 0.95), 0.85);
touch(c, jr, x, y - fh * 0.66, fh * 0.16, acc, 0.75, 0, 0.6);
}
// the woman with the pot: one arm up, the pot riding her hand
if (pg.kind === 'pot') {
rigger(c, jr, { x: x + fSide * fh * 0.14, y: y - fh * 0.62, length: fh * 0.42,
color: coat, alpha: 0.7, angle: -Math.PI / 2 + fSide * 0.35,
width: Math.max(0.7, fh * 0.06), bow: 0 });
touch(c, jr, x + fSide * fh * 0.28, y - fh * 1.06, fh * 0.20, dark('#26221e', 0.95), 0.85, 0, 0.8);
touch(c, jr, x, y - fh * 0.48, fh * 0.30, dark(mixHex('#8a3c50', '#5c3444', 0.4), 0.95), 0.6, 0, 0.85);
}
// the warm rim: firelight on the fire side only — the cool side stays cool
if (fw > 0.08) {
for (let m = 0; m < 2; m++) {
touch(c, jr, x + fSide * fh * (0.12 + rng() * 0.06), y - fh * (0.32 + rng() * 0.34),
fh * 0.09, mixHex('#f0a850', '#f8d080', rng() * 0.5),
Math.min(0.55, 0.16 + fw * 0.4), 0, 0.8);
}
}
});
}
// -- THE FLAMES: upward dabs of orange-gold, embers at the base --------------
for (let i = 0; i < fires.length; i++) {
const f = fires[i];
stamped(0.775 + 0.020 * (i / Math.max(1, fires.length)), (c, jr) => {
const fh = fhNear * f.s;
const fa = (f.main ? 1 : 0.85) * flameAmp;
if (fa <= 0.02) { // dead fire: a dull ember bed under the smoke
for (let k = 0; k < 7; k++) {
touch(c, jr, f.x + gauss() * fh * 0.10, f.y - fh * 0.03, fh * 0.05,
pick(['#6a3828', '#54302a', '#7a4430']), 0.20 + rng() * 0.15);
}
return;
}
const nF = Math.round(56 * fa) + 12;
for (let k = 0; k < nF; k++) {
const t = Math.pow(rng(), 0.8); // 0 base, 1 tip
const x = f.x + gauss() * fh * 0.11 * (1 - t * 0.7);
const y = f.y - fh * 0.06 - t * fh * 0.75;
const col = t < 0.35 ? pick(['#f8dc80', '#f4c060'])
: t < 0.7 ? pick(['#f4a840', '#e88830'])
: pick(['#d86828', '#b84c20']);
touch(c, jr, x, y, fh * (0.06 + 0.06 * (1 - t)), col, (0.55 + rng() * 0.30) * fa, 0, 1.25);
}
for (let k = 0; k < 12; k++) { // the ember bed
touch(c, jr, f.x + gauss() * fh * 0.10, f.y - fh * 0.03, fh * 0.04,
pick(['#f4b050', '#f8d070', '#e88830']), 0.55 * fa);
}
for (let k = 0; k < 8; k++) { // the white heart of the flame
touch(c, jr, f.x + gauss() * fh * 0.05, f.y - fh * (0.05 + rng() * 0.22),
fh * 0.045, pick(['#fbe4a8', '#f8e0a0', '#fcd890']), 0.6 * fa);
}
});
}
// sparks: night only, rising with the smoke
if (night && flameAmp > 0.02) {
for (let i = 0; i < fires.length; i++) {
const f = fires[i];
stamped(0.838 + 0.004 * i, (c, jr) => {
const fh = fhNear * f.s;
for (let k = 0; k < 12; k++) {
const x = f.x + gauss() * fh * 0.16 + wind * rng() * fh * 0.4;
const y = f.y - fh * (0.7 + rng() * 1.1);
touch(c, jr, x, y, fh * (0.012 + rng() * 0.014),
pick(['#f8c860', '#f0e0a0', '#f4b850']), 0.45 + rng() * 0.35);
}
});
}
}
// -- ground glints: the fires' light lying on the grass ----------------------
for (let i = 0, nGl = Math.round(55 * covK); i < nGl && fires.length; i++) {
const f = fires[Math.floor(rng() * fires.length)];
const fh = fhNear * f.s;
stamped(0.856 + rng() * 0.014, (c, jr) => {
const a = rng() * TAU, r = Math.pow(rng(), 0.7) * fh * 1.6;
smear(c, jr, f.x + Math.cos(a) * r, f.y + Math.abs(Math.sin(a)) * r * 0.4,
fh * (0.08 + rng() * 0.14), (rng() - 0.5) * 0.2, Math.max(0.9, fh * 0.03),
mixHex('#f0b060', fireGlowCol, rng() * 0.5), 0.08 + rng() * 0.12, 0.4);
});
}
// -- mist ghosts the camp half away ------------------------------------------
if (misty || dawn) {
for (let i = 0, nM = misty ? 24 : 14; i < nM; i++) {
const d = 0.30 + rng() * 0.55;
const p = gp(campXn, d);
stamped(0.884 + 0.018 * rng(), veil(p.x + (rng() - 0.5) * W * 0.5,
p.y - H * (0.02 + rng() * 0.10), W * (0.10 + rng() * 0.22), (rng() - 0.5) * 0.06,
Math.max(2, H * (0.014 + rng() * 0.028)), mixHex('#e2e6e2', '#c8d0d8', rng() * 0.5),
(0.05 + rng() * 0.07) * (misty ? 1 : 0.7), 0.25));
}
}
// -- the unifying breath -------------------------------------------------------
for (let i = 0; i < 16; i++) {
const y = rng() * H;
const col = y < hzY ? mixHex(bands[1], '#b8c2cc', rng() * 0.4)
: gnd(mixHex(GPAL.mid[0], '#3a3428', rng() * 0.3));
stamped(0.920 + 0.012 * rng(), veil(rng() * W, y, W * (0.08 + rng() * 0.18), (rng() - 0.5) * 0.08,
Math.max(2, H * (0.015 + rng() * 0.03)), col, 0.03 + rng() * 0.04,
hand.mode === 'impasto' ? 0.45 : 0.3));
}
// -- the final veil: mist and rain soften every edge one last time --------------
if (misty || rainy || snowy || stormy) {
const veilCol = mixHex('#e2e6e4', night ? '#3a4456' : dawn ? '#c8d2da' : '#d8ded8',
night ? 0.5 : stormy ? 0.35 : 0.25);
for (let i = 0; i < 12; i++) {
stamped(0.968 + 0.020 * rng(), (c, jr) => {
band(c, jr, rng() * W, rng() * H, W * (0.14 + rng() * 0.28), (rng() - 0.5) * 0.04,
H * (0.02 + rng() * 0.04), veilCol, 0.03 + rng() * 0.04);
});
}
}
strokes = list.sort((a, b) => a.p - b.p);
},
draw(ctx, W, H, _rng, from, to, _conditions) {
if (!strokes) return;
for (let i = 0; i < strokes.length; i++) {
const s = strokes[i];
if (s.p >= from && s.p < to) s.fn(ctx);
}
},
};
...@@ -18,5 +18,7 @@ import moulin from './moulin.js'; ...@@ -18,5 +18,7 @@ import moulin from './moulin.js';
import loire from './loire.js'; import loire from './loire.js';
import bois from './bois.js'; import bois from './bois.js';
import marche from './marche.js'; import marche from './marche.js';
import gitans from './gitans.js';
import tour from './tour.js';
export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols, verger, port, moulin, loire, bois, marche]; export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols, verger, port, moulin, loire, bois, marche, gitans, tour];
// La Tour — the Eiffel Tower UNDER CONSTRUCTION, 1887-88, seen as Seurat saw
// it from the Champ-de-Mars: not a monument but an iron skeleton rising over
// Paris — four curving legs parting at the base, the first platform a
// horizontal band, the lattice continuing upward as a tapering pylon, the sky
// visible THROUGH it everywhere. A drawing in air, not a solid mass. The
// construction dice pick the build stage: legs only / first platform / second
// platform / nearly complete, a crane at the top. Paris goes about its day
// beneath: rows of Haussmann façades with dim kindling windows, the
// Champ-de-Mars with its plane-tree alleys, carriage dots on the avenues, the
// Seine glinting at one edge (dice), promenaders looking up (gaze implied by
// tilt, no faces — the marché discipline), a photographer's tripod (the tower
// was photographed constantly). CONDITIONS: morning haze half-dissolves the
// tower above the rooftops (its top vanishing — the most poetic variant),
// noon leaves the lattice crisp against blue, goldenHour lays rose light on
// the sun-facing face, dusk/night string construction LANTERNS along the
// platforms and scaffolds with the lattice gone black against deep violet and
// gas lamps below; seasons dress the plane trees and the esplanade; mist
// leaves the legs only (the top gone), rain brings streaks and umbrellas.
//
// DICE (init stream, after the conditions stream). Build stage 1-4; tower x
// offset and base depth; tree count 7-11; figures 2-8; carriages 3-10; the
// Seine at one edge (0.55); the arch between the legs (0.7); the tripod
// (0.6); workers 0-8 on the platform edges.
//
// SKELETON AND HAND: the load-bearing strokes — the prime, the sky bands, the
// leg profiles, the platform slabs, the façade columns, the esplanade — ride the plain house
// stroke (marche/gitans precedent: masses plain, accents through the hand),
// because a pointillisme wrist must not dissolve the iron skeleton. Every
// expressive touch — the lattice cross-strokes, the grain of pointillé inside
// the silhouette, windows, lanterns, figures, carriages — routes through
// brushes.handFor(conditions.technique) + handDab.
//
// Contract: identical to scenes/gitans.js — every stroke precomputed in init()
// with a fixed progress stamp, draw() paints exactly the stamps in [from, to),
// the persistent canvas accumulates, same seed -> same painting. Per-stroke
// jitter and weather touches ride their own mulberry32 streams; NOTHING else
// consumes randomness at draw time. TONAL SPINE (pixel-verified): sky
// lightest, city mids, iron lattice darker-but-not-black, lamp/glow accents
// brightest-warm at night.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, Horizon, brokenColor, aerialFade, skyline,
dab, roundDab, rigger, stroke,
} from '../brushes.js';
let strokes = null;
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
export default {
id: 'tour',
title: 'La Tour',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'La tour, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1889), null);
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', morning = tod === 'morning', noon = tod === 'noon';
const misty = weather === 'mist', rainy = weather === 'rain';
const stormy = weather === 'overcast', snowy = weather === 'snow';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const lerp = (a, b, t) => a + (b - a) * t;
// one jitter stream per stroke; weather touches ride ~12% of strokes
const stamped = (p, fn) => {
const jr = mulberry32((0x7072 + Math.floor(rng() * 0xffffff) * 691) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; the scene keeps painting */ }
}
});
};
// -- THE HAND ----------------------------------------------------------
const hand = handFor(cond.technique);
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean;
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(1.6, Math.max(1, 1 / Math.max(0.25, slMean * kMean)));
const inkK = hand.alphaCurve === 'soft' ? 2.3 : hand.alphaCurve === 'bleed' ? 1.7
: hand.alphaCurve === 'matte' ? 1.25 : 1;
// a short expressive touch, routed through the rolled hand
const touch = (cc, jr, x, y, len, ang, w, c, a, squash = 1) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(w), c, a: aa, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, c, aa),
});
};
// the soft air — haze bands, the unifying breath — stays translucent
const veil = (x, y, len, ang, w, c, a, squash = 0.4) => (cc) => {
const half = Math.max(1, len * 0.5);
const ab = hand.alphaBase;
dab(cc, x, y, half, c, a * ab, ang, clamp(squash, 0.06, 8));
dab(cc, x, y, half * 1.45, c, a * 0.34 * ab, ang, clamp(squash * 0.92, 0.06, 8));
};
// the skeleton band, hand-independent: the composition must survive
// every wrist
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.38,
});
// the skeleton slab: a SOLID bar (dab chains show their ribs when length
// dwarfs width — platform bands and façade courses must not)
const slab = (c, x, y, len, ang, w, col, a) => {
const x0 = x - Math.cos(ang) * len * 0.5, y0 = y - Math.sin(ang) * len * 0.5;
const x1 = x + Math.cos(ang) * len * 0.5, y1 = y + Math.sin(ang) * len * 0.5;
const d = Math.hypot(x1 - x0, y1 - y0);
const n = Math.max(2, Math.ceil(d / Math.max(0.7, w * 0.45)));
for (let k = 0; k <= n; k++) {
dab(c, lerp(x0, x1, k / n), lerp(y0, y1, k / n), Math.max(0.8, w * 0.62), col, a, 0, 1);
}
};
// -- THE GROUND PLANE ----------------------------------------------------
const vxN = 0.46 + rng() * 0.08;
const hzY = H * clamp01(cond.horizonY);
const horizon = new Horizon(hzY, vxN * W, 1.5, H);
const gp = (xn, d) => horizon.computeGroundPlane(clamp01(xn) * W, d, H);
const gOf = (d) => horizon.ground(d);
const fhNear = H * 0.055; // a person at depth 1
// -- sky colour sampler (from the house gradient stops) -------------------
const STOPS = skyGradient(cond).map((s) => [s.at, s.color]);
const skyAt = (yn) => {
const y = clamp01(yn);
for (let i = 1; i < STOPS.length; i++) {
if (y <= STOPS[i][0]) {
const span = STOPS[i][0] - STOPS[i - 1][0];
return mixHex(STOPS[i - 1][1], STOPS[i][1], span > 1e-6 ? (y - STOPS[i - 1][0]) / span : 0);
}
}
return STOPS[STOPS.length - 1][1];
};
// -- grading: the hour leans the whole scene ------------------------------
const gnd = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#1d2740', 0.70 * k);
else if (dusk) o = mixHex(o, '#423a56', 0.42 * k);
else if (dawn) o = mixHex(o, '#8a8ca0', 0.30 * k);
else if (golden) o = mixHex(o, '#c09060', 0.18 * k);
else o = mixHex(o, '#a8a4a2', 0.12 * k);
if (misty) o = mixHex(o, '#d6dad6', 0.10 * k);
if (rainy) o = mixHex(o, '#6a7886', 0.14 * k);
if (stormy) o = mixHex(o, '#5a6270', 0.16 * k);
if (snowy) o = mixHex(o, '#dfe5ea', 0.30 * k);
return o;
};
const dark = (h, k = 1) => { // for the city's dark souls — sinks, never lifts
let o = h;
if (night) o = mixHex(o, '#10162a', 0.52 * k);
else if (dusk) o = mixHex(o, '#26243a', 0.38 * k);
else if (dawn) o = mixHex(o, '#464860', 0.30 * k);
else if (golden) o = mixHex(o, '#5e4a44', 0.24 * k);
else o = mixHex(o, '#3a3a44', 0.16 * k);
if (misty) o = mixHex(o, '#8a9090', 0.07 * k);
if (rainy) o = mixHex(o, '#3a4450', 0.08 * k);
return o;
};
const hazeCol = gnd('#b8b6b0', 0.85);
const hazer = (h, k) => mixHex(h, hazeCol, clamp01(k));
// -- DICE -----------------------------------------------------------------
const towerXn = clamp(0.5 + (cond.motifSide === 'left' ? -1 : 1) * (0.03 + rng() * 0.05), 0.36, 0.64);
const tb = 0.40 + rng() * 0.22; // the tower base's depth on the esplanade
const baseP = gp(towerXn, tb);
const cx = baseP.x, baseY = baseP.y;
const hFull = Math.min(baseY - H * 0.05, H * 0.74); // design height (px)
const bw = hFull * (0.185 + rng() * 0.025); // base half-width: the real tower is 125m across for 312m tall
const sr = rng();
const stage = sr < 0.22 ? 1 : sr < 0.50 ? 2 : sr < 0.78 ? 3 : 4;
const BUILT_T = { 1: 0.185, 2: 0.385, 3: 0.625, 4: 0.985 }[stage];
const nTrees = 7 + Math.floor(rng() * 5);
const nFigures = 2 + Math.floor(rng() * 7);
const nCarriages = 3 + Math.floor(rng() * 8);
const hasSeine = rng() < 0.55;
const seineSide = rng() < 0.5 ? -1 : 1;
const hasArch = rng() < 0.7;
const hasTripod = rng() < 0.6;
const nWorkers = Math.floor(rng() * 9);
// -- THE TOWER's geometry: the 1887 profile --------------------------------
// half-width (of base half-width) at height fraction t: the four legs
// parting at the base, the waist, the tapering pylon
const PROF = [
[0, 1], [0.03, 0.90], [0.07, 0.79], [0.11, 0.695], [0.15, 0.62], [0.18, 0.56],
[0.24, 0.475], [0.30, 0.395], [0.37, 0.32], [0.46, 0.25], [0.55, 0.195],
[0.65, 0.15], [0.75, 0.11], [0.85, 0.072], [0.92, 0.05], [0.97, 0.036], [1, 0.026],
];
const wAt = (t) => {
const u = clamp(t, 0, 1);
for (let i = 1; i < PROF.length; i++) {
if (u <= PROF[i][0]) {
const [t0, w0] = PROF[i - 1], [t1, w1] = PROF[i];
return lerp(w0, w1, (u - t0) / (t1 - t0));
}
}
return PROF[PROF.length - 1][1];
};
const hw = (t) => wAt(t) * bw;
const yAt = (t) => baseY - hFull * t;
// -- the iron: grey-violet by day, black at night, hazed with height ------
const iron = night ? '#10141f' : gnd(dusk ? '#2c2e48' : dawn ? '#4e5164'
: golden ? '#4e4462' : '#4e4f62', 0.9);
const ironDark = mixHex(iron, '#0e0c16', night ? 0.4 : 0.3);
const litSide = (golden || dawn || morning || noon);
const ironLit = mixHex(iron, golden ? '#d89068' : dawn ? '#c89878' : '#b0a8a0', 0.5);
const sunSide = (cond.sunX * W >= cx) ? 1 : -1;
const litK = golden ? 0.40 : dawn ? 0.24 : morning ? 0.15 : 0.10;
// the haze ladder: the tower half-dissolves above the rooftops in the
// morning mist — the top vanishing into the sky
const hazeTop = misty ? 0.85 : morning ? 0.62 : dawn ? 0.38 : stormy ? 0.32
: golden ? 0.18 : rainy ? 0.22 : 0.10;
const tRoof = clamp01((baseY - (hzY - H * 0.04)) / hFull); // where the city ends
const towerHaze = (t) => hazeTop * Math.pow(clamp01((t - tRoof * 0.55) / (1.04 - tRoof * 0.55)), 1.35);
const ironAt = (t, px, py) => {
let col = iron;
if (litSide && (px - cx) * sunSide > 0) col = mixHex(col, ironLit, litK);
if (rainy) col = mixHex(col, '#333c4a', 0.30);
if (snowy) col = mixHex(col, '#2e3542', 0.42);
return mixHex(col, skyAt(clamp01(py / H)), towerHaze(t));
};
const ironA = (t, a) => a * (1 - 0.72 * towerHaze(t)) * (rainy ? 1.15 : snowy ? 1.3 : 1);
// -- THE PRIME: sky above the horizon, esplanade below ---------------------
const GPAL = groundPalette(season, cond.warmth);
const parkFar = snowy ? mixHex(gnd(GPAL.mid[0], 0.9), '#e6ecf2', 0.72) : gnd(GPAL.mid[0], 0.9);
const parkMid = snowy ? mixHex(gnd(GPAL.mid[1], 0.95), '#e2e9f0', 0.72) : gnd(GPAL.mid[1], 0.95);
const parkNear = snowy ? mixHex(gnd(GPAL.shade[0], 1), '#dce4ec', 0.66) : gnd(GPAL.shade[0], 1);
const prime = (c) => {
const g = c.createLinearGradient(0, 0, 0, H);
for (const s of skyGradient(cond)) {
if (s.at > hzY / H + 0.015) break; // the sky only
g.addColorStop(clamp01(s.at), night ? mixHex(s.color, '#0e1628', 0.30) : s.color);
}
g.addColorStop(clamp01(hzY / H), hazer(parkFar, 0.30));
g.addColorStop(clamp01(hzY / H + 0.10), mixHex(parkMid, parkFar, 0.55));
g.addColorStop(1, parkNear);
c.fillStyle = g;
c.fillRect(0, 0, W, H);
};
prime(ctx);
add(0.001, prime);
// -- THE SKY: broad hatched bands ------------------------------------------
for (let i = 0; i < 620; i++) {
const x = -W * 0.03 + rng() * W * 1.06;
const hi = Math.pow(rng(), 0.9);
const y = hi * Math.max(4, hzY - H * 0.012);
const col = mixHex(skyAt(y / H), skyAt(clamp01(y / H + 0.08)), rng() * 0.4);
const len = W * (0.04 + rng() * 0.15) * (0.7 + hi * 0.5);
const wd = H * (0.006 + rng() * 0.018) * (0.75 + hi * 0.5);
const ang = (rng() - 0.5) * 0.22;
stamped(0.010 + 0.088 * hi + rng() * 0.012, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, (0.10 + jr() * 0.16) * (night ? 0.85 : 1));
});
}
// the warm breath low in the sky where the light dies
if (!night) {
const warmSky = dusk ? '#c08358' : golden ? '#f2c47e' : dawn ? '#ecc194' : '#f0e0b0';
for (let i = 0; i < 70; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY - Math.pow(rng(), 1.7) * H * 0.15;
stamped(0.092 + rng() * 0.030, (c, jr) => {
band(c, jr, x, y, W * (0.04 + jr() * 0.10), (jr() - 0.5) * 0.08,
H * (0.005 + jr() * 0.012), mixHex(warmSky, skyAt(hzY / H), jr() * 0.35),
0.10 + jr() * 0.16);
});
}
}
// -- sun or moon ------------------------------------------------------------
{
const sy = Math.max(H * 0.04, Math.min(cond.sunY * H, hzY - H * 0.03));
const sx = cond.sunX * W;
if (night) {
stamped(0.126, (c) => {
dab(c, sx, sy, H * 0.012, '#e8ecf2', 0.7);
dab(c, sx - H * 0.004, sy - H * 0.003, H * 0.006, '#f4f6f8', 0.5);
});
for (let i = 0; i < 7; i++) {
const a = rng() * TAU, r = H * (0.02 + rng() * 0.03);
stamped(0.129 + rng() * 0.008, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.010 + rng() * 0.012), '#4a5670', 0.04 + rng() * 0.05));
}
} else {
const core = golden ? '#f4c882' : dusk ? '#e8a878' : dawn ? '#f0d2a0' : '#f6e2b4';
const dim = cond.sunStrength == null ? 1 : cond.sunStrength;
stamped(0.126, (c) => {
dab(c, sx, sy, H * 0.015, core, 0.5 * dim);
dab(c, sx, sy, H * 0.008, mixHex(core, '#fff8e4', 0.5), 0.55 * dim);
});
for (let i = 0; i < 8; i++) {
const a = rng() * TAU, r = H * (0.022 + rng() * 0.04);
stamped(0.129 + rng() * 0.008, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.010 + rng() * 0.015), mixHex(core, skyAt(0.3), 0.5),
(0.04 + rng() * 0.05) * Math.max(0.35, dim)));
}
}
}
// -- THE STARS ---------------------------------------------------------------
if (night) {
const starWx = { clear: 1, mist: 0.35, overcast: 0.08, rain: 0.2, snow: 0.25 }[weather];
const STAR_COLS = ['#d8e4f8', '#e8eefa', '#c2d2ee'];
const nStars = Math.round(85 * starWx);
for (let i = 0; i < nStars; i++) {
const x = rng() * W;
const y = Math.pow(rng(), 1.2) * Math.max(8, hzY - H * 0.04);
const b = rng();
const col = pick(STAR_COLS);
stamped(0.112 + rng() * 0.012, (c, jr) => {
touch(c, jr, x, y, H * (0.001 + b * b * 0.002), 0,
H * (0.0012 + b * b * 0.003), col, 0.30 + b * 0.5);
});
}
}
// -- cloud banks --------------------------------------------------------------
{
const nClouds = 2 + Math.floor(rng() * 3);
for (let ci = 0; ci < nClouds; ci++) {
const ccx = 0.12 + rng() * 0.76, ccy = 0.05 + rng() * 0.42;
const sx = 0.10 + rng() * 0.12, sy = 0.014 + rng() * 0.02;
const n = 14 + Math.floor(rng() * 12);
const cols = night ? ['#2c3a58', '#24304a', '#36466a']
: pick([
['#e2dcea', '#d4cede', '#eee8e2'],
['#c4ceda', '#b4c0cc', '#d8dfe4'],
['#eed6b8', '#e4c8a2', '#f4e6d0'],
]);
for (let i = 0; i < n; i++) {
const x = (ccx + gauss() * sx) * W;
const y = (ccy + gauss() * sy) * H;
const col = mixHex(pick(cols), skyAt(y / H), 0.25);
stamped(0.138 + rng() * 0.020, (c, jr) => {
band(c, jr, x, y, W * (0.03 + jr() * 0.07), (jr() - 0.5) * 0.10,
H * (0.008 + jr() * 0.014), col, 0.05 + jr() * 0.08);
});
}
}
}
// -- THE CITY: the far parapet knitting the horizon ---------------------------
for (let i = 0; i < 150; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY + H * (0.003 + rng() * 0.024);
const h2 = H * (0.004 + rng() * 0.012);
const w2 = W * (0.012 + rng() * 0.032);
const col = hazer(dark(pick(['#4a463e', '#565046', '#4e4a52', '#5a5044']), 0.95), 0.52 + rng() * 0.16);
stamped(0.162 + rng() * 0.020, (c, jr) => {
band(c, jr, x + w2 * 0.5, y - h2 * 0.5, h2, -Math.PI / 2 + (jr() - 0.5) * 0.2,
Math.max(1.4, w2 * 0.30), col, 0.22 + jr() * 0.20);
});
}
// -- THE HAUSMANN FAÇADES: three depth rows of muted cream/grey masses -------
const city = skyline(null, rng, {
horizon, frameW: W, frameH: H, side: 'both',
density: 0.55 + rng() * 0.30,
heightVariance: 0.35 + rng() * 0.35,
style: 'parisian', night: true, maxDepth: 0.40 + rng() * 0.14,
});
const rowOf = (depth) => depth < 0.22 ? 0 : depth < 0.42 ? 1 : 2;
const ROW_P = [
{ mass: [0.186, 0.235], win: [0.330, 0.358] },
{ mass: [0.235, 0.290], win: [0.358, 0.386] },
{ mass: [0.290, 0.345], win: [0.386, 0.412] },
];
const FACADES = ['#b4a084', '#a89070', '#c0ac8e', '#98826a', '#b0a488'];
const buildings = city.buildings.slice().sort((a, b) => a.depth - b.depth);
const lightAmt = night ? 0 : clamp01(cond.sunStrength * (cond.warmth + 0.15)
* (dusk || golden ? 0.9 : dawn ? 0.55 : 0.75));
const warmLight = dawn ? '#e8b088' : golden ? '#f2bc7c' : '#e09a5e';
for (const b of buildings) {
const ri = rowOf(b.depth);
const [m0, m1] = ROW_P[ri].mass;
const nearness = clamp01((b.depth - 0.04) / 0.7);
const fade = aerialFade(0.2 + nearness * 0.8);
const hazeMix = Math.min(0.72, (1 - nearness) * (0.40 + (misty ? 0.28 : 0)) + 0.06);
const base = mixHex(gnd(pick(FACADES), 0.95), skyAt(hzY / H - 0.03), hazeMix);
const cols = brokenColor(null, rng, base, 0.5);
const sunB = cond.sunX * W >= b.x + b.w / 2 ? 1 : -1;
// the mass: full-height columns
const nCol = clamp(Math.round(b.w / Math.max(2, W * 0.011)), 3, 10);
for (let i = 0; i < nCol; i++) {
const u = (i + 0.5) / nCol;
const x = b.x + b.w * u + (rng() - 0.5) * b.w * 0.06;
const litZone = sunB > 0 ? u > 0.6 : u < 0.4;
let col = mixHex(base, cols[i % cols.length], 0.35);
if (litZone && lightAmt > 0.03) col = mixHex(col, warmLight, Math.min(0.6, 0.08 + lightAmt * 0.4));
const wd = Math.max(1.8, (b.w / nCol) * (1.5 + rng() * 0.5));
stamped(m0 + (m1 - m0) * 0.55 * (i / Math.max(1, nCol)) + rng() * 0.004, (c, jr) => {
band(c, jr, x, b.yBase - b.h * 0.5, b.h * 1.0, -Math.PI / 2 + (jr() - 0.5) * 0.05,
wd, col, Math.min(1, (0.8 + jr() * 0.16) * fade.alpha));
});
}
// broken colour hatching, through the hand
const n = clamp(Math.round((b.w * b.h) / (W * H) * 2800 * covK), 6, 72);
for (let i = 0; i < n; i++) {
const x = b.x + rng() * b.w;
const y = b.yBase - rng() * b.h;
const u = (x - b.x) / Math.max(1e-3, b.w);
const litZone = sunB > 0 ? u > 0.6 : u < 0.4;
let col = cols[Math.floor(rng() * cols.length)];
if (litZone && lightAmt > 0.03) col = mixHex(col, warmLight, Math.min(0.7, 0.12 + lightAmt * 0.45 * rng()));
const floorB = rng() < 0.22 && nearness > 0.3;
const ang = floorB ? (rng() - 0.5) * 0.1 : -Math.PI / 2 + (rng() - 0.5) * 0.26;
const len = floorB ? b.w * (0.22 + rng() * 0.5) : b.h * (0.12 + rng() * 0.3);
const wd = Math.max(1.2, Math.min(b.h * 0.13, H * 0.006 * (0.5 + nearness * 1.1)) * (0.8 + rng() * 0.5));
stamped(m0 + (m1 - m0) * (0.35 + 0.6 * (i / Math.max(1, n))) + rng() * 0.004, (c, jr) => {
touch(c, jr, x, y, len, ang, wd, col, (0.40 + rng() * 0.28) * fade.alpha, 0.7);
});
}
// the roofline: a mansard hip knitted over the top; the base seated
const yTop = b.yBase - b.h;
stamped(m0 + (m1 - m0) * 0.96, (c, jr) => {
slab(c, b.x + b.w * 0.5, yTop, b.w * 1.02, 0, Math.max(1.6, b.h * 0.10),
mixHex(dark('#5c5866', 0.95), skyAt(hzY / H - 0.05), hazeMix * 0.8), 0.5);
slab(c, b.x + b.w * 0.5, b.yBase + 1, b.w * 1.06, 0, Math.max(1.2, b.h * 0.06),
dark('#2e2a30', 0.95), 0.22);
});
}
// -- the windows: the city kindling -------------------------------------------
{
const litFrac = night ? 0.9 : dusk ? 0.8 : dawn ? 0.3 : golden ? 0.45
: (weather === 'overcast' ? 0.14 : 0.08);
const wAlphaK = night ? 1 : dusk ? 0.85 : dawn ? 0.5 : golden ? 0.7 : 0.35;
const wins = city.windows.slice().sort((a, b) => b.y - a.y); // bottom first
for (const w of wins) {
const ri = rowOf(w.depth);
const [w0, w1] = ROW_P[ri].win;
const farRow = ri === 0;
if (rng() >= litFrac) {
// a dark window is data too — but by day some read as dim pane dots
if (rng() < 0.35 && !farRow) {
const pw = w0 + (w1 - w0) * rng() + rng() * 0.002;
const dcol = mixHex('#2c303c', skyAt(hzY / H), 0.35 + rng() * 0.25);
stamped(pw, (c) => {
dab(c, w.x, w.y, Math.max(1.0, w.w * 0.4), dcol, 0.30 + rng() * 0.20);
});
}
continue;
}
const alpha = Math.min(0.85, 0.45 + rng() * 0.45) * wAlphaK;
const col = dawn ? mixHex(w.color, '#d8b090', 0.3) : w.color;
stamped(w0 + (w1 - w0) * rng() + rng() * 0.002, (c, jr) => {
if (!farRow) dab(c, w.x, w.y, Math.max(1.4, w.w * 0.8), col, 0.07 * wAlphaK);
touch(c, jr, w.x, w.y, 0, 0, Math.max(0.9, w.w * (farRow ? 0.3 : 0.38)), col, alpha);
});
}
}
// -- THE CHAMP-DE-MARS: the esplanade receding under the tower -----------------
for (let i = 0, nG = Math.round(540 * covK); i < nG; i++) {
const d = 1 - Math.pow(rng(), 1.4) * 0.95;
const g = gOf(d);
const xn = -0.02 + rng() * 1.04;
const p = gp(xn, d);
const r = rng();
let col = r < 0.4 ? pick(GPAL.mid) : r < 0.85 ? pick(GPAL.shade) : pick(GPAL.light);
col = gnd(col, 0.92);
// close values: blend into the prime's own tone at this depth, so even
// the impasto hand can never turn the esplanade into a scree
col = mixHex(col, mixHex(parkFar, parkNear, g), 0.62);
if (snowy) col = mixHex(col, pick(['#e8eef4', '#dfe7ee', '#f2f6fa']), 0.68);
if (night) col = mixHex(col, '#141c30', 0.30);
else if (dawn) col = mixHex(col, '#6a7086', 0.28);
else if (golden) col = mixHex(col, '#8a7048', 0.14);
col = hazer(col, (1 - g) * (misty ? 0.55 : 0.28));
const sz = Math.max(1.2, H * (0.005 + rng() * 0.010) * (0.3 + g * 1.15));
stamped(0.416 + 0.048 * (1 - d) + rng() * 0.010, (c, jr) => {
// the esplanade is a MASS: plain soft dabs, no slab rims — the hand
// speaks in the lattice, the trees, the figures, not in the lawn
dab(c, p.x, p.y, Math.max(1.2, sz * 0.75), col, 0.10 + jr() * 0.12,
(jr() - 0.5) * 0.4, 0.6);
});
}
// the gravel alleys running toward the tower
for (const sideA of [-1, 1]) {
const xn0 = clamp(towerXn + sideA * (0.13 + rng() * 0.06), 0.05, 0.95);
const a0 = gp(xn0, 0.98), a1 = gp(towerXn + sideA * 0.02, tb * 0.85);
const alleyAng = Math.atan2(a1.y - a0.y, a1.x - a0.x);
for (let i = 0; i < 34; i++) {
const t = rng();
const xn = lerp(xn0, towerXn + sideA * 0.02, t);
const d = lerp(0.98, tb * 0.85, t);
const p = gp(xn, d);
let col = gnd(pick(GPAL.light), 0.95);
if (snowy) col = mixHex(col, '#f0f4f8', 0.5);
if (golden || dawn) col = mixHex(col, warmLight, 0.10 + 0.12 * rng());
stamped(0.470 + 0.020 * t + rng() * 0.008, (c, jr) => {
band(c, jr, p.x, p.y, Math.max(4, W * 0.022 * (0.3 + p.scale)),
alleyAng + (jr() - 0.5) * 0.10, Math.max(1.2, H * 0.006 * (0.3 + p.scale * 1.2)),
hazer(col, (1 - p.scale) * 0.3), 0.10 + jr() * 0.10);
});
}
}
// -- snow sparkle on the esplanade ------------------------------------------
if (snowy) {
for (let i = 0, nF = Math.round(90 * covK); i < nF; i++) {
const d = 0.30 + rng() * 0.65;
const p = gp(rng(), d);
stamped(0.494 + rng() * 0.010, (c, jr) => {
touch(c, jr, p.x, p.y, Math.max(1.0, H * 0.003 * (0.4 + gOf(d))), 0,
Math.max(0.9, H * 0.0032 * (0.4 + gOf(d))), pick(['#eef4fa', '#f8fbfe']),
0.25 + rng() * 0.3, 0.6);
});
}
}
// -- THE SEINE at one edge (dice) ----------------------------------------------
if (hasSeine) {
const bx0 = seineSide < 0 ? W * 0.004 : W * 0.925;
const bwid = W * 0.07;
const waterBase = gnd(night ? '#20293e' : '#7c8ba0', 0.9);
stamped(0.522, (c, jr) => { // the wash: a band of reflected sky first
for (let k = 0; k < 8; k++) {
const y = hzY + H * (0.012 + Math.pow(jr(), 0.8) * 0.30);
band(c, jr, bx0 + bwid * 0.5, y, bwid * (0.8 + jr() * 0.25), (jr() - 0.5) * 0.04,
Math.max(3, H * (0.016 + jr() * 0.02)),
mixHex(waterBase, skyAt(y / H), 0.55 + jr() * 0.25), 0.30 + jr() * 0.20);
}
});
for (let i = 0; i < 16; i++) { // broken colour over the wash
const y = hzY + H * (0.01 + Math.pow(rng(), 0.8) * 0.34);
stamped(0.524 + rng() * 0.008, (c, jr) => {
band(c, jr, bx0 + bwid * (0.2 + jr() * 0.6), y, bwid * (0.4 + jr() * 0.6),
(jr() - 0.5) * 0.08, Math.max(1.6, H * (0.008 + jr() * 0.012)),
mixHex(waterBase, skyAt(y / H), 0.35 + jr() * 0.3), 0.20 + jr() * 0.20);
});
}
for (let i = 0; i < 26; i++) { // the glints
const y = hzY + H * (0.015 + Math.pow(rng(), 0.7) * 0.30);
const x = bx0 + rng() * bwid;
const glint = night ? pick(['#e8c46a', '#f5d98a'])
: golden || dawn ? pick(['#f2d8a0', '#eec488']) : pick(['#dce4ea', '#eef2f4']);
stamped(0.534 + rng() * 0.008, (c, jr) => {
band(c, jr, x, y, W * (0.006 + jr() * 0.02), (jr() - 0.5) * 0.06,
Math.max(0.8, H * 0.0025), glint, night ? 0.35 + jr() * 0.3 : 0.22 + jr() * 0.22);
});
}
stamped(0.530, (c, jr) => { // the quay bank line
band(c, jr, bx0 + bwid * 0.5, hzY + H * 0.005, bwid, 0,
Math.max(1.4, H * 0.005), dark('#3c3a34', 0.95), 0.4);
});
}
// -- THE PLANE TREES along the alleys -------------------------------------------
const TREE_COLS = {
spring: ['#7ca24a', '#94ba52', '#5c8258'],
summer: ['#5c7a3c', '#6c8a48', '#4c6a34'],
autumn: ['#b07838', '#c08a48', '#9a6830'],
winter: ['#6a6460', '#5a564e', '#78726a'],
}[season];
for (let ti = 0; ti < nTrees; ti++) {
const d = 0.16 + rng() * 0.72;
let xn = clamp(rng(), 0.03, 0.97);
if (Math.abs(xn - towerXn) < 0.13) xn = clamp(towerXn + (xn < towerXn ? -0.13 : 0.13), 0.03, 0.97);
const p = gp(xn, d);
const s = p.scale;
const th = fhNear * s * (1.7 + rng() * 1.0);
const winterBare = season === 'winter' || (season === 'autumn' && rng() < 0.3);
const trunkCol = dark('#3a3026', 0.95);
const slot = d < tb ? 0.498 + 0.004 * ti : 0.806 + 0.003 * ti;
stamped(slot, (c, jr) => {
band(c, jr, p.x, p.y - th * 0.28, th * 0.56, -Math.PI / 2 + (jr() - 0.5) * 0.08,
Math.max(1.5, th * 0.095), trunkCol, 0.8);
if (winterBare) {
for (let k = 0; k < 8; k++) {
const a = -Math.PI / 2 + (jr() - 0.5) * 2.0;
rigger(c, jr, { x: p.x + (jr() - 0.5) * th * 0.10, y: p.y - th * (0.48 + jr() * 0.30),
length: th * (0.18 + jr() * 0.22), color: trunkCol, alpha: 0.6,
angle: a, width: Math.max(0.6, th * 0.024), bow: th * 0.03 });
}
} else {
for (let k = 0, nC = 44; k < nC; k++) {
const a = jr() * TAU, r = Math.pow(jr(), 0.55) * th * 0.42;
const cxp = p.x + Math.cos(a) * r;
const cyp = p.y - th * (0.74 + Math.sin(a) * r / th * 0.9);
let col = hazer(gnd(TREE_COLS[Math.floor(jr() * TREE_COLS.length)], 0.95),
(1 - s) * (misty ? 0.55 : 0.28));
if (snowy) col = mixHex(col, '#e8eef4', 0.4);
if (night) col = mixHex(col, '#182032', 0.3);
touch(c, jr, cxp, cyp, Math.max(1.8, th * (0.13 + jr() * 0.13)),
(jr() - 0.5) * 0.6, Math.max(1.6, th * 0.13), col, 0.30 + jr() * 0.26, 0.7);
}
}
});
}
// -- THE TOWER LEGS: four curving profiles, a drawing in air ---------------------
const legColFn = () => (tt, px, py) => ironAt(clamp((baseY - py) / hFull, 0, 1), px, py);
const legAFn = (t0, t1) => (u) => ironA(t0 + u * (t1 - t0), 1);
for (const side of [-1, 1]) {
// outer edge: base to the built top — the silhouette itself
{
const t1 = BUILT_T, n = 14;
const pts = [];
for (let i = 0; i <= n; i++) {
const t = (i / n) * t1;
pts.push([cx + side * hw(t) + (rng() - 0.5) * bw * 0.012, yAt(t) + (rng() - 0.5) * H * 0.002]);
}
stamped(0.545 + rng() * 0.006, (c, jr) => {
stroke(c, jr, {
path: pts, brush: 'round', width: Math.max(2.4, bw * 0.055),
alpha: 0.85, colorFn: legColFn(), alphaFn: legAFn(0, t1), taper: 'both', spacing: 0.4,
});
});
// inner edge: the leg is a lattice box — its second face to platform 2
const t1i = Math.min(BUILT_T, 0.37), ni = 10;
const ptsi = [];
for (let i = 0; i <= ni; i++) {
const t = (i / ni) * t1i;
ptsi.push([cx + side * hw(t) * 0.80 + (rng() - 0.5) * bw * 0.010, yAt(t) + (rng() - 0.5) * H * 0.002]);
}
stamped(0.551 + rng() * 0.006, (c, jr) => {
stroke(c, jr, {
path: ptsi, brush: 'round', width: Math.max(1.6, bw * 0.032),
alpha: 0.7, colorFn: legColFn(), alphaFn: legAFn(0, t1i), taper: 'both', spacing: 0.45,
});
});
}
// the rear leg, the far side seen through the bays: lighter, hazier
if (BUILT_T > 0.10) {
const t1r = Math.min(BUILT_T, 0.37), nr = 8;
const ptsr = [];
for (let i = 0; i <= nr; i++) {
const t = (i / nr) * t1r;
const xr = lerp(bw * 0.45, hw(t) * 0.55, Math.min(1, t / 0.37));
ptsr.push([cx + side * xr + (rng() - 0.5) * bw * 0.010, yAt(t) + (rng() - 0.5) * H * 0.002]);
}
stamped(0.557 + rng() * 0.005, (c, jr) => {
stroke(c, jr, {
path: ptsr, brush: 'round', width: Math.max(1.3, bw * 0.026),
alpha: 0.5, taper: 'both', spacing: 0.45,
colorFn: (tt, px, py) => mixHex(ironAt(clamp((baseY - py) / hFull, 0, 1), px, py),
skyAt(clamp01(py / H)), 0.30),
alphaFn: (u) => ironA(u * t1r, 0.75),
});
});
}
}
// the pylon's inner face above platform 2
if (BUILT_T > 0.38) {
for (const side of [-1, 1]) {
const t0 = 0.37, t1 = BUILT_T, n = 9;
const pts = [];
for (let i = 0; i <= n; i++) {
const t = lerp(t0, t1, i / n);
pts.push([cx + side * hw(t) * 0.72 + (rng() - 0.5) * bw * 0.008, yAt(t)]);
}
stamped(0.561 + rng() * 0.004, (c, jr) => {
stroke(c, jr, {
path: pts, brush: 'round', width: Math.max(1.2, bw * 0.022),
alpha: 0.55, colorFn: legColFn(), alphaFn: legAFn(t0, t1), taper: 'both', spacing: 0.45,
});
});
}
}
// cross-ticks lacing the outer legs' two faces
stamped(0.566, (c, jr) => {
for (let t = 0.02; t < Math.min(BUILT_T, 0.37); t += 0.035) {
for (const side of [-1, 1]) {
const y = yAt(t);
stroke(c, jr, {
path: { x0: cx + side * hw(t) * 0.80, y0: y, x1: cx + side * hw(t), y1: y + bw * 0.015 },
brush: 'round', width: Math.max(0.8, bw * 0.018),
color: ironAt(t, cx + side * hw(t), y), alpha: ironA(t, 0.6),
taper: 'none', spacing: 0.45 + jr() * 0.2,
});
}
}
});
// the arch between the legs (dice): a thin curve catching the light
if (hasArch) {
stamped(0.570, (c, jr) => {
const pts = [];
for (let i = 0; i <= 8; i++) {
const u = i / 8;
const t = 0.03 + Math.sin(u * Math.PI) * 0.125;
pts.push([cx + lerp(-1, 1, u) * hw(0.03) * 0.80, yAt(t)]);
}
stroke(c, jr, {
path: pts, brush: 'round', width: Math.max(1.0, bw * 0.015), alpha: 0.45,
color: mixHex(iron, skyAt(hzY / H - 0.1), 0.35), taper: 'both', spacing: 0.4,
});
});
}
// -- THE LATTICE: broken diagonal cross-strokes, the sky through them -----------
{
const cellPx = Math.max(6, hFull * 0.033);
const dt = cellPx / hFull;
const pLat0 = 0.578, pLat1 = 0.685;
let cells = [];
for (let t = 0.012; t < BUILT_T - 0.002; t += dt) {
const w = hw(t), y = yAt(t);
const nC = Math.max(1, Math.round((2 * w) / cellPx));
for (let ci = 0; ci < nC; ci++) {
const x0 = cx - w + (2 * w * ci) / nC;
const x1 = cx - w + (2 * w * (ci + 1)) / nC;
cells.push({ t, y, x0, x1 });
}
}
cells.sort((a, b) => a.t - b.t); // built from the ground up
const nCl = Math.round(cells.length * covK);
for (let k = 0; k < nCl; k++) {
const cell = cells[k % cells.length];
const { t, y, x0, x1 } = cell;
const inset = 1 - 0.10 - rng() * 0.06;
const cxm = (x0 + x1) / 2;
const x0i = lerp(x0, cxm, inset), x1i = lerp(x1, cxm, inset);
const jx0 = (rng() - 0.5) * cellPx * 0.2, jx1 = (rng() - 0.5) * cellPx * 0.2;
const jy = (rng() - 0.5) * cellPx * 0.16;
const col = ironAt(t, cxm, y);
const aL = ironA(t, 0.62 + rng() * 0.3);
const aL2 = aL * (0.8 + rng() * 0.2);
const wL = Math.max(1.1, cellPx * 0.095);
const solo = rng() < 0.22; // some cells carry a single diagonal
const pK = pLat0 + (pLat1 - pLat0) * (k / Math.max(1, nCl)) + rng() * 0.003;
stamped(pK, (c, jr) => {
// the X: two diagonals through the rolled hand
const mx = cxm, my = y + jy * 0.3;
const dLen = Math.hypot(x1i - x0i, cellPx * 0.9) * 0.94;
const angA = Math.atan2(cellPx * 0.9, x1i - x0i);
const angB = Math.atan2(cellPx * 0.9, x0i - x1i);
touch(c, jr, mx, my - cellPx * 0.45 + jy, dLen, angA, wL, col, aL, 0.7);
if (!solo) touch(c, jr, mx, my - cellPx * 0.45 + jy, dLen, angB, wL, col, aL2, 0.7);
// the horizontal chord along the cell's top: a thin structural note
stroke(c, jr, {
path: { x0: x0 + jx0, y0: y - cellPx * 0.5 + jy, x1: x1 + jx1, y1: y - cellPx * 0.5 + jy },
brush: 'round', width: Math.max(0.7, cellPx * 0.045), color: col,
alpha: ironA(t, 0.42), taper: 'none', spacing: 0.5,
});
});
}
// the vertical members at the cell boundaries
for (let t = 0.012; t < BUILT_T - 0.002; t += dt) {
const w = hw(t), y = yAt(t);
const nC = Math.max(1, Math.round((2 * w) / cellPx));
stamped(0.640 + (t / BUILT_T) * 0.030, (c, jr) => {
for (let ci = 0; ci <= nC; ci++) {
const x = cx - w + (2 * w * ci) / nC + (jr() - 0.5) * cellPx * 0.12;
stroke(c, jr, {
path: { x0: x, y0: y - cellPx * 0.5, x1: x, y1: y + cellPx * 0.5 },
brush: 'round', width: Math.max(0.7, cellPx * 0.05),
color: ironAt(t, x, y), alpha: ironA(t, 0.5), taper: 'none', spacing: 0.55,
});
}
});
}
}
// -- THE GRAIN: pointillé inside the silhouette, dense at the edges -------------
{
const grainCell = Math.max(6, hFull * 0.033);
const nDots = Math.round(1300 * covK);
const nStamps = 135;
const per = Math.ceil(nDots / nStamps);
for (let si = 0; si < nStamps; si++) {
const dots = [];
for (let k = 0; k < per; k++) {
const t = Math.pow(rng(), 1.15) * BUILT_T;
let x = cx + (rng() * 2 - 1) * hw(t) * 0.94;
if (rng() < 0.35) { // snap near a profile: the lattice reads at the edges
const side = rng() < 0.5 ? -1 : 1;
x = cx + side * hw(t) * (0.68 + rng() * 0.26);
}
const y = yAt(t) + (rng() - 0.5) * grainCell * 0.6;
dots.push({
x, y, t,
sz: Math.max(0.7, hFull * (0.0016 + rng() * 0.0026)),
a: 0.14 + rng() * 0.22,
});
}
stamped(0.690 + 0.058 * (si / nStamps) + rng() * 0.003, (c) => {
for (const d of dots) {
if (Math.abs(d.x - cx) > hw(d.t) * 0.96) continue; // never outside the silhouette
dab(c, d.x, d.y, d.sz, ironAt(d.t, d.x, d.y), ironA(d.t, d.a));
}
});
}
}
// -- the working platform at the construction frontier -------------------------
{
const wF = hw(BUILT_T) * 1.04;
stamped(0.750, (c, jr) => {
slab(c, cx, yAt(BUILT_T), 2 * wF, 0, Math.max(1.8, hFull * 0.009),
mixHex(ironAt(BUILT_T, cx, yAt(BUILT_T)), '#0e0c16', 0.10), 0.5);
});
// footings: the four feet bite the earth, a soft shadow pools beneath
stamped(0.753, (c, jr) => {
for (const side of [-1, 1]) {
for (const k of [1.0, 0.45]) {
const fx = cx + side * (k === 1.0 ? bw : bw * 0.45) * 0.98;
dab(c, fx, baseY - 1, Math.max(2.2, bw * 0.06), mixHex(ironDark, '#0a0810', 0.2), 0.55, 0, 0.5);
}
}
dab(c, cx, baseY + hFull * 0.004, bw * 1.25, gnd('#3a382e', 0.95),
night ? 0.30 : 0.18, 0, 0.28);
});
}
// -- THE PLATFORMS: horizontal bands, cantilevered ------------------------------
const plats = [];
if (BUILT_T > 0.19) plats.push(0.18);
if (BUILT_T > 0.39) plats.push(0.37);
for (let i = 0; i < plats.length; i++) {
const t = plats[i];
const y = yAt(t), w = hw(t) * 1.10;
stamped(0.752 + 0.006 * i, (c, jr) => {
slab(c, cx, y, 2 * w, 0, Math.max(2.0, hFull * 0.011),
mixHex(ironAt(t, cx, y), '#0e0c16', 0.08), 0.55);
slab(c, cx, y + hFull * 0.008, 2 * w * 0.88, 0, Math.max(1.2, hFull * 0.006),
mixHex(ironAt(t, cx, y + hFull * 0.02), skyAt((y + hFull * 0.02) / H), 0.35), 0.3);
});
}
// -- CRANES AND SCAFFOLDING at the construction frontier ------------------------
{
const tTop = BUILT_T;
const mastX = cx + (rng() - 0.5) * hw(tTop) * 0.8;
const mastH = hFull * (0.028 + rng() * 0.015);
const jibDir = rng() < 0.5 ? -1 : 1;
const jibLen = hFull * (0.018 + rng() * 0.012);
const yTop = yAt(tTop);
stamped(0.766, (c, jr) => {
const colC = ironAt(tTop, mastX, yTop);
rigger(c, jr, { x: mastX, y: yTop + hFull * 0.01, length: mastH,
color: colC, alpha: ironA(tTop, 0.8), angle: -Math.PI / 2 + (jr() - 0.5) * 0.04,
width: Math.max(1.0, hFull * 0.0028), bow: 0 });
rigger(c, jr, { x: mastX, y: yTop - mastH, length: jibLen,
color: colC, alpha: ironA(tTop, 0.75), angle: jibDir > 0 ? 0 : Math.PI,
width: Math.max(0.9, hFull * 0.0026), bow: 0 });
rigger(c, jr, { x: mastX, y: yTop - mastH, length: jibLen * 0.35, // the counter-jib
color: colC, alpha: ironA(tTop, 0.7), angle: jibDir > 0 ? Math.PI : 0,
width: Math.max(0.9, hFull * 0.003), bow: 0 });
rigger(c, jr, { x: mastX + jibDir * jibLen * 0.85, y: yTop - mastH, length: mastH * 0.55, // the cable
color: colC, alpha: ironA(tTop, 0.55), angle: Math.PI / 2,
width: Math.max(0.6, hFull * 0.0018), bow: 0 });
touch(c, jr, mastX + jibDir * jibLen * 0.85, yTop - mastH * 0.45, 0, 0,
Math.max(0.9, hFull * 0.0035), mixHex(colC, '#0e0c16', 0.2), 0.8);
});
// scaffolding poles and braces at the frontier
stamped(0.772, (c, jr) => {
const w = hw(tTop * 0.96);
const nS = 5;
for (let i = 0; i < nS; i++) {
const x = cx - w + (2 * w * i) / (nS - 1) + (jr() - 0.5) * w * 0.06;
const sh = hFull * (0.012 + jr() * 0.014);
rigger(c, jr, { x, y: yAt(tTop) + hFull * 0.004, length: sh,
color: ironAt(tTop, x, yAt(tTop)), alpha: ironA(tTop, 0.6),
angle: -Math.PI / 2 + (jr() - 0.5) * 0.08, width: Math.max(0.8, hFull * 0.0022), bow: 0 });
}
for (let i = 0; i < 2; i++) {
const x = cx + (jr() - 0.5) * w;
rigger(c, jr, { x, y: yAt(tTop) - hFull * 0.012, length: hFull * 0.02,
color: ironAt(tTop, x, yAt(tTop)), alpha: ironA(tTop, 0.5),
angle: (jr() < 0.5 ? 0.6 : Math.PI - 0.6), width: Math.max(0.7, hFull * 0.002), bow: 0 });
}
});
// stage 1: scaffold hugging the legs themselves
if (stage === 1) {
stamped(0.776, (c, jr) => {
for (const side of [-1, 1]) {
for (let k = 0; k < 4; k++) {
const t = 0.03 + k * 0.045;
const x = cx + side * hw(t) * 0.99;
rigger(c, jr, { x, y: yAt(t) + hFull * 0.008, length: hFull * 0.030,
color: ironAt(t, x, yAt(t)), alpha: ironA(t, 0.45), angle: -Math.PI / 2,
width: Math.max(0.8, hFull * 0.0020), bow: 0 });
}
}
});
}
}
// -- THE WORKERS: tiny dots on the platform edges (dice 0-8) ---------------------
if (plats.length && nWorkers > 0) {
const wPts = [];
for (let k = 0; k < nWorkers; k++) {
const t = plats[k % plats.length];
wPts.push({ x: cx + (rng() * 2 - 1) * hw(t) * 1.1, y: yAt(t) - hFull * 0.008 });
}
stamped(0.780, (c) => {
for (const w of wPts) {
dab(c, w.x, w.y, Math.max(0.9, hFull * 0.0022), mixHex(ironDark, '#0a0810', 0.3),
night ? 0.5 : 0.8);
}
});
}
// -- THE LANTERNS: dusk/night string the construction site with warm dots --------
if (dusk || night) {
const lampK = night ? 1 : 0.75;
const LAMP_COLS = ['#f4c860', '#e8b850', '#f8dc86'];
const lampDots = [];
for (const t of plats) { // strung along each built platform edge
const y = yAt(t) - hFull * 0.010;
const w = hw(t) * 1.15;
const nL = Math.max(3, Math.round((2 * w) / (hFull * 0.06)));
for (let i = 0; i < nL; i++) {
lampDots.push({
x: cx - w + (2 * w * (i + 0.5)) / nL, y,
col: pick(LAMP_COLS), a: 0.85, halo: 0.22, haloW: 0.014,
});
}
}
{ // along the frontier itself
const wT = hw(BUILT_T) * 1.05;
const nT = Math.max(2, Math.round((2 * wT) / (hFull * 0.08)));
for (let i = 0; i < nT; i++) {
lampDots.push({
x: cx - wT + (2 * wT * (i + 0.5)) / nT, y: yAt(BUILT_T) - hFull * 0.004,
col: pick(LAMP_COLS.slice(0, 2)), a: 0.75, halo: 0.19, haloW: 0.012,
});
}
}
for (let i = 0; i < 4; i++) { // scaffold lanterns on poles at the base
const side = i % 2 === 0 ? -1 : 1;
lampDots.push({
x: cx + side * bw * (0.5 + rng() * 0.55), y: baseY - hFull * (0.03 + rng() * 0.08),
col: pick(LAMP_COLS.slice(0, 2)), a: 0.8, halo: 0.20, haloW: 0.013,
});
}
stamped(0.784, (c, jr) => {
for (const d of lampDots) {
dab(c, d.x, d.y, Math.max(2.0, hFull * d.haloW), mixHex(d.col, '#3a3048', 0.45), d.halo * lampK);
touch(c, jr, d.x, d.y, 0, 0, Math.max(1.0, hFull * 0.0035), d.col, d.a * lampK);
}
});
// gas lamps along the alleys below
for (let i = 0; i < 6; i++) {
const side = i % 2 === 0 ? -1 : 1;
const d = 0.5 + rng() * 0.45;
const xn = clamp(towerXn + side * (0.10 + rng() * 0.16), 0.04, 0.96);
const p = gp(xn, d);
const poleH = fhNear * p.scale * 1.5;
stamped(0.866 + 0.003 * i, (c, jr) => {
rigger(c, jr, { x: p.x, y: p.y, length: poleH, color: dark('#1c1a20', 0.95),
alpha: 0.7, angle: -Math.PI / 2, width: Math.max(0.9, fhNear * p.scale * 0.045), bow: 0 });
const col = '#f5d98a';
dab(c, p.x, p.y - poleH, Math.max(2.2, fhNear * p.scale * 0.35),
mixHex(col, '#3a3048', 0.5), 0.18 * lampK);
touch(c, jr, p.x, p.y - poleH, 0, 0, Math.max(1.1, fhNear * p.scale * 0.10), col, 0.8 * lampK);
});
}
}
// -- THE PROMENADERS: looking up, no faces, the gaze implied by tilt -------------
const COATS = ['#3a3044', '#2e3444', '#4a3c30', '#563e34', '#38444e', '#6e4a30'];
const ACCENTS = ['#c03830', '#d89030', '#3868a8', '#8a4a78', '#c8a028'];
for (let pi = 0; pi < nFigures; pi++) {
const d = 0.45 + rng() * 0.50;
let xn = clamp(rng(), 0.06, 0.94);
if (Math.abs(xn - towerXn) < 0.10) xn = clamp(towerXn + (xn < towerXn ? -0.10 : 0.10), 0.05, 0.95);
const p = gp(xn, d);
const seated = rng() < 0.3;
const coat = dark(pick(COATS), 0.95);
const acc = dark(pick(ACCENTS), 0.85);
const lean = Math.sign(cx - p.x) * (0.05 + rng() * 0.10); // tilted toward the tower
const fh = fhNear * p.scale;
const headCol = dark(rng() < 0.5 ? '#1e1c22' : mixHex('#8a6248', '#3a3430', 0.4), 0.95);
const umbrella = rainy && rng() < 0.6;
const umbCol = umbrella ? dark(pick(['#2e3444', '#4a3c30', '#5e3438']), 0.9) : null;
stamped(0.815 + 0.030 * (pi / Math.max(1, nFigures)), (c, jr) => {
const headY = seated ? fh * 0.40 : fh * 0.86;
if (seated) {
touch(c, jr, p.x, p.y - fh * 0.20, fh * 0.4, 0, fh * 0.26, coat, 0.8, 0.55);
} else {
band(c, jr, p.x - fh * 0.07, p.y - fh * 0.14, fh * 0.28, -Math.PI / 2 + lean * 0.4,
Math.max(0.8, fh * 0.08), dark('#221e26', 0.95), 0.7);
band(c, jr, p.x + fh * 0.07, p.y - fh * 0.14, fh * 0.28, -Math.PI / 2 + lean * 0.4,
Math.max(0.8, fh * 0.08), dark('#221e26', 0.95), 0.7);
band(c, jr, p.x, p.y - fh * 0.40, fh * 0.5, -Math.PI / 2 + lean, fh * 0.26, coat, 0.8);
}
touch(c, jr, p.x + lean * fh * 0.2, p.y - headY * 0.8, fh * 0.18, 0, fh * 0.14, acc, 0.7, 0.6);
// the head, offset toward the tower: the gaze
touch(c, jr, p.x + Math.sign(cx - p.x) * fh * 0.05 + lean * fh * 0.3,
p.y - headY, 0, 0, fh * 0.13, headCol, 0.85);
// rain: umbrellas among the promenaders
if (umbrella) {
dab(c, p.x, p.y - headY - fh * 0.16, fh * 0.34, umbCol, 0.8, 0, 0.35);
rigger(c, jr, { x: p.x, y: p.y - headY - fh * 0.05, length: fh * 0.18,
color: dark('#221e26', 0.95), alpha: 0.7, angle: Math.PI / 2,
width: Math.max(0.6, fh * 0.035), bow: 0 });
}
});
}
// -- THE PHOTOGRAPHER's tripod (dice) ---------------------------------------------
if (hasTripod) {
const d = 0.82 + rng() * 0.12;
let xn = clamp(rng(), 0.08, 0.92);
if (Math.abs(xn - towerXn) < 0.10) xn = clamp(towerXn + (xn < towerXn ? -0.10 : 0.10), 0.06, 0.94);
const p = gp(xn, d);
const fh = fhNear * p.scale;
stamped(0.852, (c, jr) => {
for (const a of [-2.15, -Math.PI / 2, -1.0]) {
rigger(c, jr, { x: p.x, y: p.y - fh * 0.40, length: fh * 0.42,
color: dark('#26221e', 0.95), alpha: 0.8, angle: a,
width: Math.max(0.7, fh * 0.030), bow: 0 });
}
slab(c, p.x, p.y - fh * 0.44, fh * 0.13, 0, fh * 0.075, dark('#1c1a18', 0.95), 0.85);
// the photographer, bent to the plate
touch(c, jr, p.x + fh * 0.24, p.y - fh * 0.24, fh * 0.26, 0.8, fh * 0.14,
dark('#332e28', 0.95), 0.8, 0.6);
touch(c, jr, p.x + fh * 0.34, p.y - fh * 0.34, 0, 0, fh * 0.10, dark('#1e1c22', 0.95), 0.85);
});
}
// -- THE CARRIAGES: dots on the avenues ---------------------------------------------
for (let i = 0; i < nCarriages; i++) {
const d = 0.74 + rng() * 0.22;
let xn = clamp(rng(), 0.05, 0.95);
if (Math.abs(xn - towerXn) < 0.09) xn = clamp(towerXn + (xn < towerXn ? -0.09 : 0.09), 0.04, 0.96);
const p = gp(xn, d);
const s = fhNear * p.scale;
stamped(0.858 + 0.006 * (i / Math.max(1, nCarriages)), (c, jr) => {
const col = dark(pick(['#26221e', '#2c2824', '#332a26']), 0.95);
slab(c, p.x - s * 0.10, p.y - s * 0.07, s * 0.42, 0, s * 0.11, col, 0.8);
touch(c, jr, p.x + s * 0.30, p.y - s * 0.08, 0, 0, s * 0.13, dark('#4a3c30', 0.95), 0.75);
band(c, jr, p.x, p.y + s * 0.01, s * 0.38, 0, Math.max(0.8, s * 0.045),
dark('#1c1a18', 0.95), 0.16);
});
}
// -- MIST: the tower half-ghosted, the city dissolved ---------------------------------
if (misty || dawn) {
for (let i = 0, nM = misty ? 26 : 14; i < nM; i++) {
const hugTower = rng() < 0.55;
const y = hugTower
? yAt(0.1 + rng() * Math.min(0.8, BUILT_T))
: hzY - H * (0.01 + rng() * 0.10);
stamped(0.874 + 0.020 * rng(), (c, jr) => {
veil(jr() * W, y, W * (0.10 + jr() * 0.24), (jr() - 0.5) * 0.06,
Math.max(2, H * (0.014 + jr() * 0.030)), mixHex('#e2e6e2', skyAt(y / H), 0.35),
(0.05 + jr() * 0.07) * (misty ? 1 : 0.7))(c);
});
}
}
// -- the unifying breath --------------------------------------------------------------
for (let i = 0; i < 16; i++) {
const y = rng() * H;
const col = y < hzY ? mixHex(skyAt(y / H), '#b8c2cc', rng() * 0.35)
: gnd(mixHex(GPAL.mid[0], '#3a3428', rng() * 0.3));
stamped(0.920 + 0.012 * rng(), (c, jr) => {
veil(jr() * W, y, W * (0.08 + jr() * 0.18), (jr() - 0.5) * 0.08,
Math.max(2, H * (0.015 + jr() * 0.03)), col, 0.03 + jr() * 0.04,
hand.mode === 'impasto' ? 0.45 : 0.3)(c);
});
}
// -- the final veil: weather softens every edge one last time --------------------------
if (misty || rainy || snowy || stormy) {
const veilCol = mixHex('#e2e6e4', night ? '#3a4456' : '#d8ded8', night ? 0.5 : 0.25);
for (let i = 0; i < 12; i++) {
stamped(0.968 + 0.020 * rng(), (c, jr) => {
band(c, jr, jr() * W, jr() * H, W * (0.14 + jr() * 0.28), (jr() - 0.5) * 0.04,
H * (0.02 + jr() * 0.04), veilCol, 0.03 + jr() * 0.04);
});
}
}
strokes = list.sort((a, b) => a.p - b.p);
},
draw(ctx, W, H, _rng, from, to, _conditions) {
if (!strokes) return;
for (let i = 0; i < strokes.length; i++) {
const s = strokes[i];
if (s.p >= from && s.p < to) s.fn(ctx);
}
},
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment