Commit 4fe2289a by PLN (Algolia)

impression: the atelier hangs at /impression

parent 4a78f686
// Atelier Impressionniste — BRUSHES: the technique library.
// Zero dependencies, importable by any scene module:
//
// import { dab, fanDab, stroke, Horizon, skyline, brokenColor } from './brushes.js';
//
// ---------------------------------------------------------------------------
// HOUSE RULES (binding for every function below, and for painters using them)
//
// 1. DETERMINISM. Every function is a pure function of its arguments plus the
// rng stream passed in. No Math.random, no Date, no module-level mutable
// state. Same seed + same call sequence => same pixels, every machine.
// Call order matters: two functions consuming the same rng in different
// order diverge. Precompute all randomness in init(), stamp with progress,
// close over constants, exactly like scenes/coquelicots.js does.
// 2. ACCUMULATE-ONLY. Nothing here ever clears, composites 'copy', or resets
// the canvas. Paint layers onto what exists, like oil over oil.
// 3. DATA OR PAINT — documented per export. Functions named as verbs
// (dab, stroke, knife...) PAINT. Functions named as nouns or adjectives
// (brokenColor, aerialFade, thirds, skyline, mix...) RETURN DATA and touch
// no pixels. The one apparent exception, skyline(), takes ctx only for
// signature symmetry and never draws.
// 4. PROGRESS-WINDOW COMPATIBLE. Painters call these inside per-stroke
// closures `() => dab(...)` stamped into a sorted list, so draw(ctx, W, H,
// rng, from, to) replays identically for any slicing of [0, 1).
// 5. COLORS are hex strings ('#rrggbb', '#rgb' tolerated) in and out, unless
// a function documents otherwise. alphas are 0..1. Sizes are pixels in the
// current transform (design units, since scenes scale ctx by dpr).
//
// Progress convention for painters: sort strokes by stamp p; draw() executes
// stamps with from <= p < to. Sort once in init, never per frame.
//
// THE HAND (technique axis). scene-api.makeConditions rolls conditions.technique
// — 'empatement' | 'vernis' | 'pointillisme' | 'sec' | 'mouille' — rolled FREE,
// independent of hour/season/weather. A scene routes each of its touch
// primitives through handDab(ctx, rng, handFor(conditions.technique), p): the
// hand table then decides size, ink load, edge, layering and brush character.
// With the default hand (no technique, or handFor(undefined)) handDab simply
// executes p.plain(ctx) — the scene's own original stroke, byte-for-byte the
// pre-hand behaviour, so retrofitting a scene never repaints its past.
// ---------------------------------------------------------------------------
const TAU = Math.PI * 2;
// ---------------------------------------------------------------------------
// COLOR — data in, data out. Never paints.
// ---------------------------------------------------------------------------
function parseColor(c) {
if (Array.isArray(c)) return [c[0] | 0, c[1] | 0, c[2] | 0];
let s = String(c).trim();
if (s[0] === '#') {
s = s.slice(1);
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
const n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = s.match(/rgba?\(([^)]+)\)/);
if (m) {
const p = m[1].split(',').map(parseFloat);
return [p[0] | 0, p[1] | 0, p[2] | 0];
}
return [0, 0, 0]; // unparseable -> black, deterministically
}
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const lerp = (a, b, t) => a + (b - a) * t;
const byte = (x) => clamp(Math.round(x), 0, 255);
function toHex(rgb) {
return '#' + rgb.map((v) => byte(v).toString(16).padStart(2, '0')).join('');
}
/** css rgba() string from a color and alpha — for gradient stops and ctx. */
export function rgba(color, a) {
const [r, g, b] = parseColor(color);
return `rgba(${r},${g},${b},${clamp(a, 0, 1)})`;
}
/** Linear mix of two colors; t=0 -> a, t=1 -> b. DATA. */
export function mix(a, b, t) {
const A = parseColor(a), B = parseColor(b);
const k = clamp(t, 0, 1);
return toHex([A[0] + (B[0] - A[0]) * k, A[1] + (B[1] - A[1]) * k, A[2] + (B[2] - A[2]) * k]);
}
function rgb2hsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0));
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h /= 6;
}
return [h, s, l];
}
function hsl2rgb(h, s, l) {
h = ((h % 1) + 1) % 1;
if (s === 0) { const v = l * 255; return [v, v, v]; }
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t) => {
t = ((t % 1) + 1) % 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
}
/**
* Broken color (Impressionist shorthand): split one color into 2-3 neighbors
* by hue/lightness so adjacent dabs shimmer instead of flattening. DATA.
* Returns an array of 2-3 hex strings. spread ~0..1 scales the split.
*/
export function brokenColor(ctx, rng, baseColor, spread = 0.4) {
return _broken(rng, baseColor, spread);
}
function _broken(rng, baseColor, spread = 0.4) {
const [r, g, b] = parseColor(baseColor);
const [h, s, l] = rgb2hsl(r, g, b);
const n = 2 + (rng() < 0.45 ? 1 : 0); // 2 or 3 swatches
const out = [];
for (let i = 0; i < n; i++) {
const dh = (rng() - 0.5) * 0.055 * spread; // up to ~10 degrees of hue split
const dl = (rng() - 0.5) * 0.10 * spread;
const ds = (rng() - 0.5) * 0.08 * spread;
out.push(toHex(hsl2rgb(h + dh, clamp(s + ds, 0, 1), clamp(l + dl, 0, 1))));
}
return out;
}
/**
* Temperature shift: warmth in -1..1 (negative cools toward blue, positive
* warms toward orange). Subtle by design: |warmth| 1 => ~35% toward the pole.
* DATA.
*/
export function temperatureShift(color, warmth) {
const k = clamp(warmth, -1, 1) * 0.35;
return k >= 0 ? mix(color, '#f0a35c', k) : mix(color, '#6f86c9', -k);
}
/** The complement, hue rotated 180 degrees. DATA. */
export function complementary(color) {
const [r, g, b] = parseColor(color);
const [h, s, l] = rgb2hsl(r, g, b);
return toHex(hsl2rgb(h + 0.5, s, l));
}
// ---------------------------------------------------------------------------
// DABS — the atomic units. All PAINT (accumulate into ctx).
// ---------------------------------------------------------------------------
// Soft-edged ellipse with a loaded-brush profile: near-solid core, quick
// falloff at the rim. hardness 0..1 moves the falloff outward.
function softEllipse(ctx, x, y, size, color, alpha, angle, squash, hardness = 0.55) {
// guards: a dab with no extent, no ink, or a NaN argument paints nothing
// (a painter computing a bad alpha must not lose the whole canvas)
if (!Number.isFinite(size) || !(size > 0)) return;
if (!Number.isFinite(alpha) || alpha <= 0) return;
ctx.save();
ctx.translate(x, y);
if (angle) ctx.rotate(angle);
if (squash !== 1) ctx.scale(1, clamp(squash, 0.05, 8));
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, size);
g.addColorStop(0, rgba(color, clamp(alpha, 0, 1)));
g.addColorStop(clamp(hardness, 0.05, 0.95), rgba(color, alpha * 0.92));
g.addColorStop(1, rgba(color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, size, 0, TAU);
ctx.fill();
ctx.restore();
}
/**
* dab — the atomic soft ellipse. PAINT.
* dab(ctx, x, y, size, color, alpha, angle = 0, squash = 1)
* squash < 1 flattens vertically (after rotation). No rng: this one is
* meant for precomputed placements and needs no stream.
*/
export function dab(ctx, x, y, size, color, alpha, angle = 0, squash = 1) {
softEllipse(ctx, x, y, size, color, alpha, angle, squash, 0.55);
}
/**
* roundDab — a loaded round-brush touch: one soft ellipse plus 1-2 rng-
* offset satellites so no two touches read as perfect circles. PAINT.
* roundDab(ctx, rng, { x, y, size, color, alpha, angle?, squash?, warmth? })
*/
export function roundDab(ctx, rng, p) {
const { x, y, size, color, alpha } = p;
const angle = p.angle || 0, squash = p.squash || 1;
const col = p.warmth ? temperatureShift(color, (rng() - 0.5) * p.warmth) : color;
softEllipse(ctx, x, y, size, col, alpha, angle, squash, 0.55);
const n = rng() < 0.55 ? 1 : 2;
for (let i = 0; i < n; i++) {
const a = rng() * TAU, r = size * (0.35 + rng() * 0.45);
softEllipse(ctx, x + Math.cos(a) * r, y + Math.sin(a) * r * squash,
size * (0.25 + rng() * 0.3), col, alpha * (0.35 + rng() * 0.35), angle, squash, 0.6);
}
}
/**
* filbert — the flattened cat's-tongue: wider than tall, aligned to angle,
* with a faint darker loading edge on one side. PAINT.
* filbert(ctx, rng, { x, y, size, color, alpha, angle?, squash? = 0.45 })
*/
export function filbert(ctx, rng, p) {
const { x, y, size, color, alpha } = p;
const angle = p.angle || 0;
const squash = p.squash == null ? 0.45 : p.squash;
const cols = _broken(rng, color, 0.3);
softEllipse(ctx, x, y, size, cols[0], alpha, angle, squash, 0.72);
if (cols[1]) softEllipse(ctx, x, y, size * 0.72, cols[1], alpha * 0.75, angle, squash, 0.7);
// bristle drag: a couple of thin streaks along the major axis
const streaks = 1 + Math.floor(rng() * 2);
for (let i = 0; i < streaks; i++) {
const off = (rng() - 0.5) * size * 0.9 * squash;
softEllipse(ctx, x + Math.cos(angle) * size * 0.3 - Math.sin(angle) * off,
y + Math.sin(angle) * size * 0.3 + Math.cos(angle) * off,
size * (0.3 + rng() * 0.25), mix(cols[0], '#000000', 0.12),
alpha * 0.28, angle, 0.18, 0.8);
}
}
/**
* fanDab — the fan brush: 3-5 parallel bristle tracks fanned across the
* stroke direction, each track its own broken-color neighbor. PAINT.
* fanDab(ctx, rng, { x, y, size, color, alpha, angle?, tracks?, spread?, squash? = 0.8 })
* spread: how far the tracks splay (0..1 of size).
*/
export function fanDab(ctx, rng, p) {
const { x, y, size, color, alpha } = p;
const angle = p.angle || 0;
const squash = p.squash == null ? 0.8 : p.squash;
const spread = p.spread == null ? 0.55 : p.spread;
const n = p.tracks || 3 + Math.floor(rng() * 3); // 3-5
const cols = _broken(rng, color, 0.45);
for (let i = 0; i < n; i++) {
const u = n === 1 ? 0 : (i / (n - 1)) * 2 - 1; // -1..1 across the fan
const off = u * size * spread;
const col = cols[i % cols.length];
softEllipse(ctx, x - Math.sin(angle) * off, y + Math.cos(angle) * off,
size * (0.55 + rng() * 0.3), col, alpha * (0.6 + rng() * 0.4), angle, squash, 0.7);
}
}
/**
* knife — the palette knife: a sharp impasto slab, hard edge on one side,
* feathered on the other, with a light-catching ridge along the crisp edge.
* PAINT.
* knife(ctx, rng, { x, y, size, color, alpha, angle?, squash? = 0.35 })
* angle points along the hard edge; squash is the slab's thickness ratio.
*/
export function knife(ctx, rng, p) {
const { x, y, size, color, alpha } = p;
if (!Number.isFinite(size) || !(size > 0)) return;
if (!Number.isFinite(alpha) || alpha <= 0) return;
const angle = p.angle || 0;
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;
ctx.save();
ctx.translate(x, y);
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
ctx.globalAlpha = clamp(alpha, 0, 1) * 0.5;
ctx.fillStyle = mix(col, '#fff8ec', 0.45);
ctx.fillRect(-size * 0.92, -th / 2 - th * 0.18, size * 1.84, Math.max(0.7, th * 0.22));
ctx.restore();
}
/**
* drybrush — broken skips: a run of small marks along angle with rng gaps,
* so the paper shows through. density 0..1 raises the hit rate. PAINT.
* drybrush(ctx, rng, { x, y, size, color, alpha, angle?, run? = 3, density? = 0.6, squash? = 0.5 })
* size is the mark radius; run is the run length in mark-diameters.
*/
export function drybrush(ctx, rng, p) {
const { x, y, size, color, alpha } = p;
const angle = p.angle || 0;
const run = p.run == null ? 3 : p.run;
const density = clamp(p.density == null ? 0.6 : p.density, 0.05, 1);
const squash = p.squash == null ? 0.5 : p.squash;
const step = size * 1.6;
const n = Math.max(2, Math.round(run * 2));
const cols = _broken(rng, color, 0.3);
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0.5 : i / (n - 1);
if (rng() > density) continue; // the skip IS the technique
const d = (t - 0.5) * step * n;
const wobble = (rng() - 0.5) * size * 0.8;
softEllipse(ctx,
x + Math.cos(angle) * d - Math.sin(angle) * wobble,
y + Math.sin(angle) * d + Math.cos(angle) * wobble,
size * (0.5 + rng() * 0.6), cols[i % cols.length],
alpha * (0.4 + rng() * 0.5), angle, squash, 0.75);
}
}
/**
* stipple — a cluster of micro-dots around (x, y): pebbles, distant flock,
* blossom noise. count scales with size by default. PAINT.
* stipple(ctx, rng, { x, y, size, color, alpha, count?, colors? , scatter? = 1 })
*/
export function stipple(ctx, rng, p) {
const { x, y, size, color, alpha } = p;
const scatter = p.scatter == null ? 1 : p.scatter;
const count = p.count || Math.max(6, Math.round(10 + size * 0.8));
const cols = p.colors || _broken(rng, color, 0.5);
for (let i = 0; i < count; i++) {
const a = rng() * TAU;
const r = (rng() + rng() - 1) * size * scatter; // triangular: denser center
softEllipse(ctx, x + Math.cos(a) * r, y + Math.sin(a) * r * 0.85,
size * (0.06 + rng() * 0.14), cols[i % cols.length],
alpha * (0.5 + rng() * 0.5), 0, 1, 0.85);
}
}
/**
* rigger — the long thin liner: one graceful hair of a stroke, optionally
* bowed. bow is lateral curvature in pixels. PAINT.
* rigger(ctx, rng, { x, y, length, color, alpha, angle? = -PI/2, width? = 1.2, bow? = 0 })
*/
export function rigger(ctx, rng, p) {
const { x, y, length, color, alpha } = p;
if (!Number.isFinite(length) || !(length > 0)) return;
if (!Number.isFinite(alpha) || alpha <= 0) return;
const angle = p.angle == null ? -Math.PI / 2 : p.angle;
const width = p.width == null ? 1.2 : p.width;
const bow = (p.bow || 0) + (rng() - 0.5) * length * 0.06; // a hair of life
const x1 = x + Math.cos(angle) * length, y1 = y + Math.sin(angle) * length;
const mx = (x + x1) / 2 - Math.sin(angle) * bow, my = (y + y1) / 2 + Math.cos(angle) * bow;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(0.4, width);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(mx, my, x1, y1);
ctx.stroke();
ctx.restore();
}
// ---------------------------------------------------------------------------
// PATH STROKES — paint along a route with pressure and per-segment color. PAINT.
// ---------------------------------------------------------------------------
// Catmull-Rom point at parameter u between pts[i] and pts[i+1].
function crPoint(pts, i, u) {
const n = pts.length;
const p0 = pts[Math.max(0, i - 1)], p1 = pts[i], p2 = pts[Math.min(n - 1, i + 1)], p3 = pts[Math.min(n - 1, i + 2)];
const u2 = u * u, u3 = u2 * u;
return [
0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * u + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * u2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * u3),
0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * u + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * u2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * u3),
];
}
function taperMult(taper, t) {
if (taper === 'none') return 1;
const s = Math.sin(Math.PI * t);
if (taper === 'both') return 0.18 + 0.82 * Math.pow(Math.max(s, 0), 0.55);
if (taper === 'end') return t > 0.78 ? 0.2 + 0.8 * (1 - (t - 0.78) / 0.22) : 1;
if (taper === 'start') return t < 0.22 ? 0.2 + 0.8 * (t / 0.22) : 1;
return 1;
}
/**
* stroke — paint along a path with pressure curves and, optionally, a
* different color per segment: colorFn(t, x, y) lets one stroke travel a
* gradient (sky-to-ground in a single gesture). PAINT.
*
* stroke(ctx, rng, {
* path: [[x,y], ...] // polyline, smoothed unless smooth:false
* | { x0, y0, x1, y1 }, // straight segment
* brush?: 'round'|'filbert'|'fan'|'flat'|'knife'|'dry', // default 'round'
* width?: px, // base width at full pressure, default 4
* taper?: 'both'|'end'|'start'|'none', // default 'end'
* alpha?: 0..1, // base alpha, default 0.8
* alphaFn?: (t) => 0..1, // overrides taper-driven alpha shaping
* color?: hex, // constant color
* colorFn?: (t, x, y) => hex, // per-segment color (wins over color)
* spacing?: fraction of width between stamps, default 0.4
* smooth?: boolean, // default true when >= 3 points
* })
*/
export function stroke(ctx, rng, opts) {
const path = opts.path;
if (!path) return;
let pts;
if (Array.isArray(path)) pts = path.map((q) => [q[0], q[1]]);
else pts = [[path.x0, path.y0], [path.x1, path.y1]];
if (pts.length < 2) return;
// resample the path
const segLen = (() => {
let L = 0;
for (let i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
return L;
})();
const width = opts.width == null ? 4 : opts.width;
const spacing = (opts.spacing == null ? 0.4 : opts.spacing) * Math.max(1.2, width);
const steps = Math.max(4, Math.min(400, Math.round(segLen / spacing)));
const smooth = opts.smooth !== false && pts.length > 2;
const baseAlpha = opts.alpha == null ? 0.8 : opts.alpha;
const brush = opts.brush || 'round';
let prev = null;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
let px, py;
if (smooth) {
const fu = t * (pts.length - 1);
const i = Math.min(pts.length - 2, Math.floor(fu));
[px, py] = crPoint(pts, i, fu - i);
} else {
const fu = t * (pts.length - 1);
const i = Math.min(pts.length - 2, Math.floor(fu));
const u = fu - i;
px = pts[i][0] + (pts[i + 1][0] - pts[i][0]) * u;
py = pts[i][1] + (pts[i + 1][1] - pts[i][1]) * u;
}
// tangent from the previous sample (stable, deterministic)
let ang;
if (prev) ang = Math.atan2(py - prev[1], px - prev[0]);
else ang = Math.atan2(pts[1][1] - pts[0][1], pts[1][0] - pts[0][0]);
prev = [px, py];
const wm = taperMult(opts.taper || 'end', t) * (opts.widthFn ? opts.widthFn(t) : 1);
const w = width * wm;
const a = opts.alphaFn ? baseAlpha * clamp(opts.alphaFn(t), 0, 1) : baseAlpha * (0.75 + 0.25 * Math.sin(Math.PI * t));
const col = opts.colorFn ? opts.colorFn(t, px, py) : (opts.color || '#333');
if (!(w > 0.15) || a <= 0.005) continue;
if (brush === 'round') softEllipse(ctx, px, py, w / 2, col, a, ang, 1, 0.6);
else if (brush === 'filbert') filbert(ctx, rng, { x: px, y: py, size: w / 2, color: col, alpha: a, angle: ang, squash: 0.5 });
else if (brush === 'flat') {
// flat: a crisp-sided slab aligned to the path
ctx.save();
ctx.translate(px, py);
ctx.rotate(ang);
ctx.globalAlpha = clamp(a, 0, 1);
ctx.fillStyle = col;
ctx.fillRect(-w * 0.55, -w * 0.28, w * 1.1, w * 0.56);
ctx.restore();
} else if (brush === 'fan') fanDab(ctx, rng, { x: px, y: py, size: w, color: col, alpha: a, angle: ang, spread: 0.7, squash: 0.5 });
else if (brush === 'knife') knife(ctx, rng, { x: px, y: py, size: w, color: col, alpha: a, angle: ang, squash: 0.3 });
else if (brush === 'dry') drybrush(ctx, rng, { x: px, y: py, size: w / 2, color: col, alpha: a, angle: ang, run: 1.6, density: 0.75 });
}
}
// ---------------------------------------------------------------------------
// THE HAND — how the stroke is laid, not what it says. PAINT via handDab.
//
// handFor(technique) -> hand table. One row per painter's hand; scenes consult
// it (directly for fine steering, or through handDab which interprets every
// field for them). Fields:
// dabSize [min, max] multiplier on the scene's nominal touch size;
// which end you land on is drawn from sizeDistribution.
// sizeDistribution 'fat' sizes skew large (impasto loads the brush),
// 'wide' consistently broad (a glaze brush, splayed),
// 'fine' sizes skew small (a pointille loads a needle),
// 'skip' plain uniform (the dry hand lets gaps do it),
// 'bleed' plain uniform (water decides),
// 'uniform' flat uniform (the default hand).
// alphaBase extra multiplier on ink load, on top of alphaCurve.
// alphaCurve how a scene's nominal alpha becomes the hand's ink:
// 'opaque' lifted hard toward full coverage,
// 'soft' divided thin (one whisper per layer),
// 'pure' lifted, for unmixed colour dots,
// 'matte' kept with a small floor (dry paint sits, not glows),
// 'bleed' eased down (water dilutes),
// 'plain' untouched (default hand).
// edgeSoftness 0 hard rim .. 1 lost edge; 1-edgeSoftness feeds the
// gradient hardness of every dab the hand lays.
// directionJitter multiplier on angular noise; divided by coherence.
// layerCount passes per touch (vernis stacks 3 translucent ones).
// preferredBrush the nominal brush: 'fan' | 'knife' | 'stipple' |
// 'drybrush' | 'round' (naming only; `mode` drives the
// realization so mouille can ride a round brush).
// strokeLength [min, max] multiplier on the scene's nominal length.
// coherence how strictly the stroke follows the form's direction;
// > 1 squares the shoulder, < 1 lets the wrist wander.
// ---------------------------------------------------------------------------
const HANDS = {
defaut: {
label: 'libre', mode: 'plain',
dabSize: [1, 1], sizeDistribution: 'uniform', alphaBase: 1, alphaCurve: 'plain',
edgeSoftness: 0.55, directionJitter: 1, layerCount: 1,
preferredBrush: 'round', strokeLength: [1, 1], coherence: 1,
},
empatement: {
label: 'empâtement', mode: 'impasto',
dabSize: [1.45, 2.1], sizeDistribution: 'fat', alphaBase: 1.15, alphaCurve: 'opaque',
edgeSoftness: 0.22, directionJitter: 0.55, layerCount: 1,
preferredBrush: 'knife', strokeLength: [0.95, 1.35], coherence: 1.45,
},
vernis: {
label: 'vernis', mode: 'glaze',
dabSize: [1.35, 1.85], sizeDistribution: 'wide', alphaBase: 0.9, alphaCurve: 'soft',
edgeSoftness: 0.9, directionJitter: 0.85, layerCount: 3,
preferredBrush: 'round', strokeLength: [1.2, 1.7], coherence: 1.0,
},
pointillisme: {
label: 'pointillisme', mode: 'dots',
dabSize: [0.4, 0.75], sizeDistribution: 'fine', alphaBase: 1, alphaCurve: 'pure',
edgeSoftness: 0.45, directionJitter: 1.7, layerCount: 1,
preferredBrush: 'stipple', strokeLength: [0.5, 0.85], coherence: 0.4,
},
sec: {
label: 'sec', mode: 'dry',
dabSize: [0.75, 1.05], sizeDistribution: 'skip', alphaBase: 0.95, alphaCurve: 'matte',
edgeSoftness: 0.65, directionJitter: 1.35, layerCount: 1,
preferredBrush: 'drybrush', strokeLength: [0.45, 0.85], coherence: 0.9,
},
mouille: {
label: 'mouillé', mode: 'wet',
dabSize: [1.4, 2.0], sizeDistribution: 'bleed', alphaBase: 0.85, alphaCurve: 'bleed',
edgeSoftness: 1.0, directionJitter: 0.9, layerCount: 2,
preferredBrush: 'round', strokeLength: [1.25, 1.85], coherence: 0.75,
},
};
/** The hand table for a technique; anything unknown paints with the old hand. DATA. */
export function handFor(technique) {
return HANDS[technique] || HANDS.defaut;
}
// distribution inside a [min,max] band, shaped by the hand's loading habit
function distVal(rng, kind) {
const u = rng();
switch (kind) {
case 'fat': return Math.pow(u, 0.55); // bias large
case 'fine': return Math.pow(u, 1.7); // bias small
case 'wide': return 0.75 + u * 0.25; // consistently broad
default: return u; // uniform, skip, bleed
}
}
// a scene's nominal alpha -> the hand's ink load
function alphaOf(hand, a) {
if (!Number.isFinite(a)) return 0;
const b = hand.alphaBase;
switch (hand.alphaCurve) {
case 'opaque': return Math.min(0.94, (a * 2.2 + 0.34) * b);
case 'soft': return a * 0.45 * b;
case 'pure': return Math.min(0.95, (a * 1.15 + 0.38) * b);
case 'matte': return Math.min(1, (a * 2.0 + 0.16) * b);
case 'bleed': return a * 0.8 * b;
default: return clamp(a * b, 0, 1);
}
}
// jittered angle: noise scaled by directionJitter, disciplined by coherence,
// clamped so even the loosest wrist never turns a stroke against its form
function handAngle(rng, hand, base) {
const amp = Math.min(1.0, 0.55 * hand.directionJitter / Math.max(0.2, hand.coherence));
return base + (rng() - 0.5) * amp;
}
// coord-derived deterministic stream: handDab needs no rng threaded through
// precomputed closures — two touches at the same place hold the same wrist.
function localRng(p) {
let t = (((Math.round(p.x * 16) * 668265261) ^ (Math.round(p.y * 16) * 374761393)) ^ 0x9e3779b9) >>> 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;
};
}
/**
* bleed — paint paintFn inside a water blur: wet-on-wet edges, colours
* merging. Graceful fallback: a ctx without filter support just paints dry.
* bleed(ctx, blurPx, (g) => { ...paint... }) PAINT.
*/
export function bleed(ctx, blurPx, paintFn) {
const can = typeof ctx.filter === 'string';
if (can) ctx.filter = `blur(${Math.max(0.5, blurPx).toFixed(2)}px)`;
paintFn(ctx);
if (can) ctx.filter = 'none';
}
/**
* ridge — the impasto highlight: a thin lighter line along a mark's upper
* edge, where pushed paint catches the light. Draw at the mark's centre with
* its length, angle and thickness. PAINT.
*/
export function ridge(ctx, x, y, len, ang, w, color, alpha) {
if (!Number.isFinite(alpha) || alpha <= 0) return;
ctx.save();
ctx.translate(x, y);
ctx.rotate(ang || 0);
_ridge(ctx, len, w, color, alpha);
ctx.restore();
}
// ridge in the current transform (realizeImpasto calls it inside its own rotate/scale)
function _ridge(ctx, len, w, color, alpha) {
const hi = mix(parseColor(color), [255, 246, 232], 0.55); // mix returns hex
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.strokeStyle = hi;
ctx.lineWidth = Math.max(0.5, w * 0.13);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-len * 0.42, -w * 0.30);
ctx.lineTo(len * 0.42, -w * 0.34);
ctx.stroke();
}
// hard round dot, no gradient: the pointilliste's touch must have an edge
function hardDot(ctx, x, y, r, color, alpha) {
if (!(r > 0.2) || !Number.isFinite(alpha) || alpha <= 0) return;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.fillStyle = rgba(color, 1);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fill();
ctx.restore();
}
// capsule body path along the local x axis, centred on the origin
function capsulePath(ctx, len, w) {
const r = Math.max(0.3, w / 2);
const h = Math.max(0.1, len / 2 - r);
ctx.beginPath();
ctx.arc(-h, 0, r, Math.PI / 2, -Math.PI / 2);
ctx.lineTo(h, -r);
ctx.arc(h, 0, r, -Math.PI / 2, Math.PI / 2);
ctx.closePath();
}
function realizeImpasto(ctx, rng, hand, p) {
const w = p.w * lerp(hand.dabSize[0], hand.dabSize[1], distVal(rng, hand.sizeDistribution));
if (!(w > 0.3)) { if (p.plain) p.plain(ctx); return; } // too tiny to slab — keep the scene's own touch
const len = Math.max(w * 0.9, p.len * lerp(hand.strokeLength[0], hand.strokeLength[1], distVal(rng, hand.sizeDistribution)));
const ang = handAngle(rng, hand, p.ang);
const a = alphaOf(hand, p.a);
if (!(a > 0)) return;
const col = parseColor(p.c);
const sq = clamp(p.squash == null ? 1 : p.squash, 0.15, 1);
const long = len > w * 1.15;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(ang);
ctx.scale(1, sq);
// slab: opaque body, visible border — the paint sits proud of the canvas
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(col, a);
ctx.beginPath();
if (long) capsulePath(ctx, len, w);
else ctx.arc(0, 0, w / 2, 0, TAU);
ctx.fill();
// 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.lineWidth = Math.max(0.6, w * 0.14);
ctx.beginPath();
if (long) { ctx.moveTo(-len * 0.42, w * 0.30); ctx.lineTo(len * 0.42, w * 0.30); }
else ctx.arc(0, 0, w * 0.40, 0.25, Math.PI - 0.25);
ctx.stroke();
// the ridge: light catching the pushed paint on the upper-left edge
_ridge(ctx, long ? len : w, w, col, a * 0.5);
ctx.restore();
}
// soft-edged ellipse stretched along angle: the glaze brush's long veil
function softStreak(ctx, x, y, halfLen, halfWid, color, alpha, angle, hardness = 0.4) {
if (!(halfLen > 0.2) || !(halfWid > 0.2) || !Number.isFinite(alpha) || alpha <= 0) return;
ctx.save();
ctx.translate(x, y);
if (angle) ctx.rotate(angle);
ctx.scale(1, clamp(halfWid / halfLen, 0.05, 1));
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, halfLen);
g.addColorStop(0, rgba(color, clamp(alpha, 0, 1)));
g.addColorStop(clamp(hardness, 0.05, 0.95), rgba(color, alpha * 0.92));
g.addColorStop(1, rgba(color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, halfLen, 0, TAU);
ctx.fill();
ctx.restore();
}
function realizeGlaze(ctx, rng, hand, p) {
const layers = Math.max(2, hand.layerCount | 0);
const size0 = p.w * lerp(hand.dabSize[0], hand.dabSize[1], distVal(rng, hand.sizeDistribution));
if (!(size0 > 0.3)) { if (p.plain) p.plain(ctx); return; }
const len = p.len * lerp(hand.strokeLength[0], hand.strokeLength[1], distVal(rng, hand.sizeDistribution));
const ang = handAngle(rng, hand, p.ang);
const a = alphaOf(hand, p.a);
if (!(a > 0)) return;
const sq = clamp(p.squash == null ? 1 : p.squash, 0.15, 1);
const hardness = 1 - hand.edgeSoftness * 0.75; // very lost edges
const elongated = len > size0 * 1.3;
for (let k = 0; k < layers; k++) {
const spread = k * size0 * 0.22;
const dx = (rng() - 0.5) * spread, dy = (rng() - 0.5) * spread;
const col = k === layers - 1
? mix(parseColor(p.c), [255, 242, 220], 0.14) // the top veil carries a breath of light
: p.c;
const al = a * Math.pow(0.85, k);
if (elongated) {
softStreak(ctx, p.x + dx, p.y + dy, (len / 2) * (1 + k * 0.12), (size0 / 2) * (1 + k * 0.18), col, al, ang, hardness);
} else {
softEllipse(ctx, p.x + dx, p.y + dy, size0 * (1 + k * 0.18), col, al, ang, sq, hardness);
}
}
}
function realizeDots(ctx, rng, hand, p) {
const size = p.w * lerp(hand.dabSize[0], hand.dabSize[1], distVal(rng, hand.sizeDistribution));
// dot radius tracks the stroke's own half-width: the dot chain must carry
// roughly the area the stroke would have, or the painting's values starve
const dotR = Math.max(0.6, size * 0.75);
const ang = handAngle(rng, hand, p.ang);
const a = alphaOf(hand, p.a);
if (!(a > 0)) return;
const cols = _broken(rng, p.c, 0.85); // pure neighbours: the eye does the mixing
const sq = clamp(p.squash == null ? 1 : p.squash, 0.15, 1);
const ux = Math.cos(ang), uy = Math.sin(ang) * sq;
const len = Math.max(0, p.len * lerp(hand.strokeLength[0], hand.strokeLength[1], distVal(rng, hand.sizeDistribution)));
// spacing just past the radius: dots stay distinct to the eye yet dense
// enough that colour mixes optically instead of starving
const n = clamp(Math.round(len / Math.max(1.0, dotR * 1.6)), 1, 64);
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0 : (i / (n - 1)) * 2 - 1;
hardDot(ctx,
p.x + ux * t * len * 0.5 + (rng() - 0.5) * dotR * 1.8,
p.y + uy * t * len * 0.5 + (rng() - 0.5) * dotR * 1.8,
dotR * (0.6 + rng() * 0.7), cols[i % cols.length], a * (0.55 + rng() * 0.45));
}
}
function realizeDry(ctx, rng, hand, p) {
const w = p.w * lerp(hand.dabSize[0], hand.dabSize[1], distVal(rng, hand.sizeDistribution));
if (!(w > 0.3)) { if (p.plain) p.plain(ctx); return; }
const len = Math.max(w * 0.8, p.len * lerp(hand.strokeLength[0], hand.strokeLength[1], distVal(rng, hand.sizeDistribution)));
const ang = handAngle(rng, hand, p.ang);
const a = alphaOf(hand, p.a);
if (!(a > 0)) return;
const matte = mix(parseColor(p.c), [148, 146, 138], 0.14); // dry pigment, no sheen
const segs = 4 + Math.floor(rng() * 4); // 4-7 broken dashes
const ux = Math.cos(ang), uy = Math.sin(ang);
for (let i = 0; i < segs; i++) {
if (rng() < 0.32) continue; // the skip IS the technique
const t = segs === 1 ? 0 : (i / (segs - 1)) * 2 - 1;
const wob = (rng() - 0.5) * w * 0.9;
softEllipse(ctx,
p.x + ux * t * len * 0.5 - uy * 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);
}
}
function realizeWet(ctx, rng, hand, p) {
const w = p.w * lerp(hand.dabSize[0], hand.dabSize[1], distVal(rng, hand.sizeDistribution));
if (!(w > 0.3)) { if (p.plain) p.plain(ctx); return; }
const len = Math.max(w * 0.8, p.len * lerp(hand.strokeLength[0], hand.strokeLength[1], distVal(rng, hand.sizeDistribution)));
const ang = handAngle(rng, hand, p.ang);
const a = alphaOf(hand, p.a);
if (!(a > 0)) return;
const sq = clamp(p.squash == null ? 1 : p.squash, 0.15, 1);
const col = p.c;
const ux = Math.cos(ang), uy = Math.sin(ang);
bleed(ctx, clamp(w * 0.35, 0.8, 3.5), (g) => {
// the touch proper, plus a halo the colour bleeds into
if (len > w * 1.3) {
softStreak(g, p.x, p.y, (len / 2) * 1.1, (w / 2) * 1.5, col, a, ang, 0.3);
softStreak(g, p.x + ux * len * 0.1, p.y + uy * len * 0.1, (len / 2) * 1.25, (w / 2) * (1.7 + rng() * 0.4), col, a * 0.45, ang, 0.15);
} else {
softEllipse(g, p.x, p.y, w * 0.72, col, a, ang, sq, 0.30);
softEllipse(g, p.x + ux * len * 0.18, p.y + uy * len * 0.18, w * (1.05 + rng() * 0.3), col, a * 0.45, ang, sq * 0.9, 0.18);
}
});
}
const REALIZERS = {
impasto: realizeImpasto,
glaze: realizeGlaze,
dots: realizeDots,
dry: realizeDry,
wet: realizeWet,
};
/**
* handDab — realize ONE touch in a hand's manner. PAINT.
* handDab(ctx, rng|null, hand, {
* x, y, // touch centre
* len, ang, // nominal length and angle (len 0 = a round touch)
* w, // nominal full thickness
* c, // color, hex or [r,g,b]
* a, // nominal alpha — the hand's alphaCurve re-inks it
* squash?, // ellipse squash (default 1)
* plain?, // (ctx) => the scene's own original stroke; executed
* // verbatim for the default hand and sub-pixel touches
* })
* rng may be null: the wrist then derives deterministically from the touch's
own geometry, so precomputed closures need no rng threaded through them.
*/
export function handDab(ctx, rng, hand, p) {
const r = typeof rng === 'function' ? rng : localRng(p);
const mode = hand && hand.mode;
const fn = mode && REALIZERS[mode];
if (!fn || !Number.isFinite(p.x) || !Number.isFinite(p.y)) {
if (p.plain) p.plain(ctx);
return;
}
fn(ctx, r, hand, p);
}
// ---------------------------------------------------------------------------
// COMPOSITION GEOMETRY — data for placing motifs on planes. Mostly DATA.
// ---------------------------------------------------------------------------
/**
* Horizon — one-point ground-plane perspective, tuned for painters.
* new Horizon(y, vanishingX, fov = 1.6, frameH = 1)
* y: horizon line, in the same units you will pass as frameH
* (pass H for pixels; default frameH = 1 keeps everything
* normalized 0..1 so scenes can multiply later)
* vanishingX: where the plane converges
* fov: foreshortening strength; > 1 crowds the horizon, 1 = linear
*
* computeGroundPlane(x, depth, frameH?) -> { x, y, scale }
* depth 0 = at the horizon, 1 = bottom of frame. x is measured at near
* reference (depth 1); the returned x converges toward vanishingX as the
* motif recedes. scale is the size multiplier relative to depth 1 — paint
* poppies, boats, figures at near-size * scale. DATA.
*/
export class Horizon {
constructor(y, vanishingX, fov = 1.6, frameH = 1) {
this.y = y;
this.vanishingX = vanishingX;
this.fov = Math.max(0.2, fov);
this.frameH = frameH;
}
// normalized depth curve: 0 at horizon, 1 at frame bottom
ground(d) {
const k = clamp(d, 0, 1);
return k / (k + this.fov * (1 - k));
}
computeGroundPlane(x, depth, frameH) {
const H = frameH == null ? this.frameH : frameH;
const g = this.ground(depth);
return {
x: this.vanishingX + (x - this.vanishingX) * g,
y: this.y + (H - this.y) * g,
scale: Math.max(0.02, g),
};
}
}
/**
* skyline — generate abstract building masses as DATA for the scene to paint
* with its own brushes. Never draws. DATA.
*
* skyline(ctx, rng, {
* horizon: Horizon instance (uses its vanishingX/fov) or a number (flat y),
* frameW, frameH, // required for pixels
* side?: 'left'|'right'|'both', // default 'left'
* density?: 0..1, // how built-up, default 0.6
* heightVariance?: 0..1, // default 0.5
* style?: 'parisian'|'industrial'|'abstract', // default 'parisian'
* night?: boolean, // include a windowLight grid
* maxDepth?: 0..1, // how deep the block recedes, default 0.35
* })
*
* Returns {
* buildings: [{ x, yBase, w, h, scale, depth, roof: 'mansard'|'flat'|'sawtooth'|'stack'|'step',
* chimneys: [{x, h, w}], stack: {x, w, h} | null }],
* silhouette: [[x, y], ...], // ordered outline, scene strokes over it
* windows: [{ x, y, w, h, color, alpha, depth }] | [],
* }
*/
export function skyline(ctx, rng, opts) {
const o = opts || {}; // ctx accepted for call-site symmetry; never touched
const W = o.frameW, H = o.frameH;
if (!(W > 0) || !(H > 0)) return { buildings: [], silhouette: [], windows: [] };
const hz = o.horizon;
const hzIsH = hz instanceof Horizon;
const hzY = hzIsH ? hz.y : hz;
const depth = clamp(o.maxDepth == null ? 0.35 : o.maxDepth, 0.02, 1);
const dens = clamp(o.density == null ? 0.6 : o.density, 0.05, 1);
const hv = clamp(o.heightVariance == null ? 0.5 : o.heightVariance, 0, 1);
const style = o.style || 'parisian';
const side = o.side || 'left';
const sides = side === 'both' ? ['left', 'right'] : [side];
const place = (x, d) => hzIsH ? hz.computeGroundPlane(x, d, H) : { x, y: hzY + (H - hzY) * d, scale: Math.max(0.02, d) };
const scaleAt = (d) => hzIsH ? hz.computeGroundPlane(0, d, H).scale : Math.max(0.02, d);
const buildings = [];
const silhouette = [];
const windows = [];
for (const sd of sides) {
// three depth rows: far, mid, near — each row a run of buildings
const rows = [0.22, 0.5, 0.85];
for (let ri = 0; ri < rows.length; ri++) {
const d = rows[ri] * depth + 0.04;
const sc = scaleAt(d);
const baseY = place(W / 2, d).y;
const nMin = 3, nMax = 9;
const n = Math.round(nMin + dens * (nMax - nMin));
// walk from the frame edge inward so masses stack against the edge
const edgeX = sd === 'left' ? -W * 0.06 : W * 1.06;
const dir = sd === 'left' ? 1 : -1;
let cursor = edgeX;
for (let i = 0; i < n && ((sd === 'left' && cursor < W * (0.35 + 0.45 * dens)) ||
(sd === 'right' && cursor > W * (0.65 - 0.45 * dens))); i++) {
const wNear = W * (0.05 + rng() * 0.09);
const w = wNear * sc;
const hVar = 1 - hv * rng() * (ri === 0 ? 0.9 : ri === 1 ? 0.6 : 0.45);
const hNear = H * (0.06 + rng() * 0.16 * hVar) * (ri === 2 ? 1.25 : 1);
const h = hNear * sc;
const x = sd === 'left' ? cursor : cursor - w;
const yBase = baseY + (rng() - 0.5) * H * 0.012 * sc;
const b = { x, yBase, w, h, scale: sc, depth: d, roof: 'flat', chimneys: [], stack: null };
if (style === 'parisian') {
b.roof = 'mansard';
const nc = rng() < 0.5 + dens * 0.3 ? 1 + Math.floor(rng() * 3) : 0;
for (let c = 0; c < nc; c++) {
b.chimneys.push({ x: x + w * (0.15 + rng() * 0.7), h: h * (0.08 + rng() * 0.08), w: Math.max(1.5, w * 0.06) });
}
} else if (style === 'industrial') {
b.roof = rng() < 0.45 ? 'sawtooth' : 'stack';
if (b.roof === 'stack' && rng() < 0.8) {
b.stack = { x: x + w * (0.2 + rng() * 0.6), w: Math.max(2, w * 0.14), h: h * (0.5 + rng() * 0.9) };
}
} else { // abstract: stepped masses
b.roof = 'step';
}
buildings.push(b);
silhouette.push([sd === 'left' ? x : x + w, yBase]);
silhouette.push([sd === 'left' ? x : x + w, yBase - h]);
// window light grid (night) — data only, deterministic
if (o.night) {
const cols = Math.max(1, Math.round(w / (W * 0.012 * sc)));
const rowsW = Math.max(1, Math.round(h / (H * 0.018 * sc)));
const wc = ['#f5d98a', '#efd27e', '#f8e6ab', '#e8c46a'];
for (let cx = 0; cx < cols; cx++) {
for (let cy = 0; cy < rowsW; cy++) {
if (rng() > 0.32 + dens * 0.25) continue; // dark windows are data too
const wx = x + w * (0.12 + (0.76 * cx) / Math.max(1, cols - 1 || 1));
const wy = yBase - h * (0.14 + (0.72 * cy) / Math.max(1, rowsW - 1 || 1));
windows.push({
x: wx, y: wy,
w: Math.max(1, w * 0.1), h: Math.max(1, h * 0.055),
color: wc[Math.floor(rng() * wc.length)],
alpha: 0.5 + rng() * 0.45,
depth: d,
});
}
}
}
cursor += (w + W * 0.004 * sc) * dir + (rng() < 0.3 ? W * 0.01 * sc * dir : 0); // occasional gap
}
if (sd === 'right') silhouette.reverse();
}
}
return { buildings, silhouette, windows };
}
/**
* aerialFade — atmospheric perspective factors. DATA.
* aerialFade(depth) with depth 0 = far, 1 = near. Returns
* { alpha, desat, lift, haze }
* alpha: multiply stroke alpha by this (far strokes fade),
* desat: how much to pull the color toward haze (use mix(color, hazeColor, desat)),
* lift: add this much lightness feel (mix toward a pale sky tone),
* haze: a ready-made pale sky color to mix toward (cools with distance).
*/
export function aerialFade(depth) {
const d = clamp(depth, 0, 1);
const t = 1 - d; // haze amount
return {
alpha: 0.35 + 0.65 * d,
desat: 0.55 * t * t,
lift: 0.18 * t,
haze: '#cfd3d8',
};
}
/**
* thirds — rule-of-thirds anchors. DATA. (ctx accepted for call-site
* symmetry with the painting verbs; never touched.)
* Returns { points: [[x,y] x4], lines: { xs: [x1, x2], ys: [y1, y2] },
* centers: [[x,y] x4] } — centers are the quadrant midpoints,
* handy for suns, figures, and the thing you want the eye to find first.
*/
export function thirds(ctx, W, H) {
const x1 = W / 3, x2 = (2 * W) / 3, y1 = H / 3, y2 = (2 * H) / 3;
return {
points: [[x1, y1], [x2, y1], [x1, y2], [x2, y2]],
lines: { xs: [x1, x2], ys: [y1, y2] },
centers: [[(W + x1) / 2, (H + y1) / 2], [(x2 + W) / 2, (H + y1) / 2],
[(W + x1) / 2, (y2 + H) / 2], [(x2 + W) / 2, (y2 + H) / 2]],
};
}
// Atelier Impressionniste — the FRAME, not the paintings.
// One painting emerges at a time on a persistent offscreen canvas; when it
// completes, hold, crossfade, next painting. Loops forever.
import scenes from './scenes/manifest.js';
import { mulberry32 } from './scene-api.js';
const params = new URLSearchParams(location.search);
const HOLD_MS = 7000;
const FADE_MS = 1500;
const IDLE_MS = 3000;
const REPLAY_STEPS = 480;
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 sceneParam = params.get('scene');
// --- the director's chair: conditions, speed, auto-advance -------------------
const LS_KEY = 'atelier-constraints';
let constraints = { timeOfDay: null, season: null, weather: null }; // null = any
let speedMul = 1; // 0.5 | 1 | 2 — divides the effective durationMs
let autoplay = params.get('autoplay') !== '0';
try {
const saved = JSON.parse(localStorage.getItem(LS_KEY) || '{}');
if (saved.timeOfDay) constraints.timeOfDay = saved.timeOfDay;
if (saved.season) constraints.season = saved.season;
if (saved.weather) constraints.weather = saved.weather;
if (saved.speed === 0.5 || saved.speed === 2) speedMul = saved.speed;
if (!params.has('autoplay') && saved.autoplay === false) autoplay = false;
} catch {}
// ?lock=night / ?lock=night,winter,rain — test override of the condition axes,
// winning over localStorage. Tokens: dawn morning noon goldenHour dusk night |
// spring summer autumn winter | clear mist overcast rain snow.
const LOCK_AXES = [
['timeOfDay', { dawn: 'dawn', morning: 'morning', noon: 'noon', goldenhour: 'goldenHour', dusk: 'dusk', night: 'night' }],
['season', { spring: 'spring', summer: 'summer', autumn: 'autumn', winter: 'winter' }],
['weather', { clear: 'clear', mist: 'mist', overcast: 'overcast', rain: 'rain', snow: 'snow' }],
];
if (params.has('lock')) {
for (const tok of params.get('lock').split(',')) {
const t = tok.trim().toLowerCase();
for (const [axis, lut] of LOCK_AXES) {
if (lut[t]) constraints[axis] = lut[t];
}
}
}
// ?tech=<name> — power-user override of the technique axis, joining the
// constraints as constraints.technique for makeConditions to force. Tokens:
// empatement vernis pointillisme sec mouille. (The menu stays condition-only.)
if (params.has('tech')) {
const t = params.get('tech').trim().toLowerCase();
if (['empatement', 'vernis', 'pointillisme', 'sec', 'mouille'].includes(t)) {
constraints.technique = t;
}
}
const canvas = document.getElementById('stage');
const vctx = canvas.getContext('2d');
const titleEl = document.getElementById('title');
const artistEl = document.getElementById('artist');
const fillEl = document.getElementById('fill');
const dotsEl = document.getElementById('dots');
let dpr = 1, rect = null;
let showNumber = 0, idx = 0, scene = null, pendingIdx = 0;
let offA = null, octx = null, offB = null; // offB holds the next painting during crossfade
let phase = 'painting', t0 = 0, lastTo = 0, pausedAt = null;
let archivedKey = null;
function layout() {
dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(innerWidth * dpr);
canvas.height = Math.round(innerHeight * dpr);
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
vctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const m = Math.min(innerWidth, innerHeight) * 0.075;
const availW = innerWidth - 2 * m, availH = innerHeight - 2 * m;
const w = Math.min(availW, (availH * 4) / 3);
rect = { x: (innerWidth - w) / 2, y: (innerHeight - w * 0.75) / 2, w, h: w * 0.75 };
}
function makeOffscreen() {
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(rect.w * dpr));
c.height = Math.max(1, Math.round(rect.h * dpr));
const ctx = c.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { c, ctx };
}
const seedFor = () => (baseSeed + showNumber) >>> 0;
const rngFor = (from) => mulberry32(((seedFor() * 7919) ^ Math.floor(from * 4096)) >>> 0);
// Dedicated conditions stream: distinct from the init and stroke streams.
const condRngFor = () => mulberry32(((seedFor() * 69621) ^ 0x5DEECE66) >>> 0);
let conditions; // the current scene's rolled conditions; undefined for legacy scenes
// Scenes opt in by exporting conditions(rng, constraints) / subtitle(conditions);
// legacy scenes get undefined and behave exactly as before. The second argument
// carries the menu's constraints; scenes that ignore it are untouched.
function callInit(sc, ctx, w, h) {
conditions = sc.conditions ? sc.conditions(condRngFor(), constraints) : undefined;
sc.init(ctx, w, h, mulberry32(seedFor()), conditions);
}
function startScene(i, replayTo = 0) {
idx = ((i % scenes.length) + scenes.length) % scenes.length;
scene = scenes[idx];
const o = makeOffscreen();
offA = o.c; octx = o.ctx; offB = null;
callInit(scene, octx, rect.w, rect.h);
if (replayTo > 0) replay(0, replayTo); else lastTo = 0;
phase = 'painting';
t0 = performance.now();
pausedAt = null;
caption();
}
function replay(from, to) {
for (let k = 0; k < REPLAY_STEPS; k++) {
const a = from + (to - from) * (k / 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);
}
lastTo = to;
}
let subEl = null;
function caption() {
titleEl.textContent = scene.title;
if (!subEl) {
subEl = document.createElement('div');
subEl.id = 'conditions-subtitle';
subEl.style.cssText =
'margin-top:6px;font-size:11px;font-variant:small-caps;letter-spacing:0.2em;' +
'color:var(--dim);opacity:0.85;';
titleEl.parentNode.insertBefore(subEl, titleEl.nextSibling);
}
const text = scene.subtitle && conditions ? scene.subtitle(conditions) : '';
subEl.textContent = text;
subEl.style.display = text ? '' : 'none';
artistEl.textContent = scene.artist;
[...dotsEl.children].forEach((d, k) => d.classList.toggle('active', k === idx));
refreshMenu();
}
// Archiving: once per completion (scene+seed), fire-and-forget. Never in freeze mode.
let flashTimer = 0;
function flash(text) {
let el = document.getElementById('archive-flash');
if (!el) {
el = document.createElement('div');
el.id = 'archive-flash';
el.style.cssText =
'position:absolute;left:50%;bottom:60px;transform:translateX(-50%);' +
'font-size:11px;font-variant:small-caps;letter-spacing:0.24em;' +
'color:var(--dim);opacity:0;transition:opacity 0.5s ease;';
document.getElementById('overlay').appendChild(el);
}
el.textContent = text;
el.style.opacity = '1';
clearTimeout(flashTimer);
flashTimer = setTimeout(() => { el.style.opacity = '0'; }, 1500);
}
function archivePainting() {
if (freeze != null) return;
const key = `${scene.id}:${seedFor()}`;
if (archivedKey === key) return;
archivedKey = key;
const body = JSON.stringify({ scene: scene.id, seed: seedFor(), dataUrl: offA.toDataURL('image/jpeg', 0.92) });
fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
.then((r) => { if (r.ok) flash('archived ✓'); })
.catch(() => {});
}
// --- the menu overlay: segmented conditions, speed, auto-advance, download ---
// Built entirely here (DOM + style) so the frame owns its own furniture; the
// look follows the house: serif small-caps, warm charcoal, hairline rules.
const MENU_SEG = {
timeOfDay: [
['any', 'any'], ['dawn', 'aube'], ['morning', 'matin'], ['noon', 'midi'],
['goldenHour', 'heure dorée'], ['dusk', 'crépuscule'], ['night', 'nuit'],
],
season: [
['any', 'any'], ['spring', 'printemps'], ['summer', 'été'], ['autumn', 'automne'], ['winter', 'hiver'],
],
weather: [
['any', 'any'], ['clear', 'clair'], ['mist', 'brume'], ['overcast', 'couvert'],
['rain', 'pluie'], ['snow', 'neige'],
],
};
const MENU_LABEL = { timeOfDay: 'Heure', season: 'Saison', weather: 'Météo' };
let menuOpen = false;
let menuEl = null;
const segButtons = {}; // axis -> [button, ...]
function persistMenu() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
timeOfDay: constraints.timeOfDay, season: constraints.season, weather: constraints.weather,
speed: speedMul, autoplay,
}));
} catch {}
}
function setConstraint(axis, value) {
constraints[axis] = value === 'any' ? null : value;
persistMenu();
refreshMenu();
archivedKey = null; // the repainting earns its own archive
if (freeze == null) startScene(idx, 0); else renderFreeze(idx);
}
function setSpeed(s) {
if (speedMul === s) return;
if (phase === 'painting' && pausedAt == null) {
t0 = performance.now() - lastTo * (scene.durationMs / s); // keep progress continuous
}
speedMul = s;
persistMenu();
refreshMenu();
}
function downloadCanvas() {
if (!offA) return;
offA.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `atelier_${scene.id}_${seedFor()}.jpg`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}, 'image/jpeg', 0.92);
flash('saved ✓');
}
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen().catch(() => {});
}
function setMenu(open) {
menuOpen = open;
menuEl.classList.toggle('open', open);
refreshMenu();
if (open) wake();
}
function segButton(value, label, onPick) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
b.dataset.value = value;
b.addEventListener('click', () => onPick(value));
return b;
}
function menuHeading(txt) {
const e = document.createElement('h2');
e.textContent = txt;
return e;
}
function buildMenu() {
const style = document.createElement('style');
style.textContent = `
#menu { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
width:min(620px, 92vw); max-height:88vh; overflow:auto; pointer-events:auto;
background:rgba(26,23,18,0.94); border:1px solid rgba(207,198,184,0.16);
padding:26px 32px 22px; display:none; }
#menu.open { display:block; }
#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;
border-bottom:1px solid rgba(207,198,184,0.14); }
#menu h2:not(:first-child) { margin-top:26px; }
.mrow { display:flex; align-items:center; gap:14px; margin:9px 0; }
.mlabel { flex:0 0 84px; font-size:10.5px; font-variant:small-caps;
letter-spacing:0.2em; color:var(--dim); }
.seg { display:flex; flex:1; border:1px solid rgba(207,198,184,0.18); }
.seg button { all:unset; flex:1 1 0; display:flex; align-items:center;
justify-content:center; min-height:27px; padding:3px 4px; cursor:pointer;
font-family:inherit; font-size:10px; font-variant:small-caps; letter-spacing:0.08em;
color:var(--dim); border-right:1px solid rgba(207,198,184,0.12);
transition:color 0.25s ease, background 0.25s ease; text-align:center; }
.seg button:last-child { border-right:none; }
.seg button:hover { color:var(--ink); }
.seg button.on { color:var(--ink); background:rgba(207,198,184,0.13); }
.dl { all:unset; cursor:pointer; padding:7px 20px; border:1px solid rgba(207,198,184,0.28);
font-family:inherit; font-size:10.5px; font-variant:small-caps; letter-spacing:0.2em;
color:var(--ink); transition:background 0.25s ease; }
.dl:hover { background:rgba(207,198,184,0.10); }
.mseed { font-size:10.5px; font-variant:small-caps; letter-spacing:0.18em;
color:var(--dim); margin-left:auto; }
`;
document.head.appendChild(style);
menuEl = document.createElement('div');
menuEl.id = 'menu';
menuEl.appendChild(menuHeading('Conditions'));
for (const axis of ['timeOfDay', 'season', 'weather']) {
const row = document.createElement('div');
row.className = 'mrow';
const lab = document.createElement('span');
lab.className = 'mlabel';
lab.textContent = MENU_LABEL[axis];
const box = document.createElement('div');
box.className = 'seg';
segButtons[axis] = MENU_SEG[axis].map(([v, label]) => {
const b = segButton(v, label, (val) => setConstraint(axis, val));
box.appendChild(b);
return b;
});
row.append(lab, box);
menuEl.appendChild(row);
}
menuEl.appendChild(menuHeading('Show'));
const vrow = document.createElement('div');
vrow.className = 'mrow';
const vlab = document.createElement('span');
vlab.className = 'mlabel';
vlab.textContent = 'Vitesse';
const vbox = document.createElement('div');
vbox.className = 'seg';
segButtons.speed = [['0.5', '½×'], ['1', '1×'], ['2', '2×']].map(([v, label]) => {
const b = segButton(v, label, (val) => setSpeed(parseFloat(val)));
vbox.appendChild(b);
return b;
});
vrow.append(vlab, vbox);
menuEl.appendChild(vrow);
const arow = document.createElement('div');
arow.className = 'mrow';
const alab = document.createElement('span');
alab.className = 'mlabel';
alab.textContent = 'Avance auto';
const abox = document.createElement('div');
abox.className = 'seg';
segButtons.auto = [['on', 'marche'], ['off', 'arrêt']].map(([v, label]) => {
const b = segButton(v, label, (val) => {
autoplay = val === 'on';
persistMenu();
refreshMenu();
});
abox.appendChild(b);
return b;
});
arow.append(alab, abox);
menuEl.appendChild(arow);
menuEl.appendChild(menuHeading('Canvas'));
const crow = document.createElement('div');
crow.className = 'mrow';
const dl = document.createElement('button');
dl.type = 'button';
dl.className = 'dl';
dl.textContent = 'Télécharger';
dl.addEventListener('click', downloadCanvas);
const seedEl = document.createElement('span');
seedEl.className = 'mseed';
seedEl.id = 'menu-seed';
crow.append(dl, seedEl);
menuEl.appendChild(crow);
document.getElementById('overlay').appendChild(menuEl);
}
function refreshMenu() {
if (!menuEl) return;
for (const axis of ['timeOfDay', 'season', 'weather']) {
const cur = constraints[axis] || 'any';
segButtons[axis].forEach((b) => b.classList.toggle('on', b.dataset.value === cur));
}
segButtons.speed.forEach((b) => b.classList.toggle('on', parseFloat(b.dataset.value) === speedMul));
segButtons.auto.forEach((b) => b.classList.toggle('on', (b.dataset.value === 'on') === autoplay));
const s = document.getElementById('menu-seed');
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) {
const n = scenes.length;
const j = ((i % n) + n) % n;
if (freeze != null) { idx = j; renderFreeze(j); return; }
startScene(j, 0);
}
function blit(a, b = null, p = 0) {
vctx.fillStyle = '#1a1712';
vctx.fillRect(0, 0, innerWidth, innerHeight);
vctx.save();
vctx.shadowColor = 'rgba(0,0,0,0.55)';
vctx.shadowBlur = 42;
vctx.shadowOffsetY = 14;
vctx.fillStyle = '#100e0b';
vctx.fillRect(rect.x, rect.y, rect.w, rect.h);
vctx.restore();
vctx.drawImage(a, rect.x, rect.y, rect.w, rect.h);
if (b) {
vctx.save();
vctx.globalAlpha = p;
vctx.drawImage(b, rect.x, rect.y, rect.w, rect.h);
vctx.restore();
}
vignette();
}
function vignette() {
vctx.save();
vctx.beginPath();
vctx.rect(rect.x, rect.y, rect.w, rect.h);
vctx.clip();
const cx = rect.x + rect.w / 2, cy = rect.y + rect.h / 2;
const r1 = Math.hypot(rect.w, rect.h) / 2;
const g = vctx.createRadialGradient(cx, cy, r1 * 0.45, cx, cy, r1);
g.addColorStop(0, 'rgba(26,23,18,0)');
g.addColorStop(1, 'rgba(26,23,18,0.30)');
vctx.fillStyle = g;
vctx.fillRect(rect.x, rect.y, rect.w, rect.h);
vctx.strokeStyle = 'rgba(26,23,18,0.55)';
vctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1);
vctx.restore();
}
function tick(now) {
if (phase === 'painting') {
if (pausedAt == null) {
const to = Math.min(1, (now - t0) / (scene.durationMs / speedMul));
if (to > lastTo) {
scene.draw(octx, rect.w, rect.h, rngFor(lastTo), lastTo, to, conditions);
lastTo = to;
if (lastTo >= 1) archivePainting(); // final stroke done, before the hold
}
}
blit(offA);
fillEl.style.width = (lastTo * 100).toFixed(2) + '%';
if (lastTo >= 1 && pausedAt == null) { phase = 'hold'; t0 = now; }
} else if (phase === 'hold') {
blit(offA);
fillEl.style.width = '100%';
if (now - t0 >= HOLD_MS) {
if (autoplay) {
pendingIdx = (idx + 1) % scenes.length;
if (pendingIdx === 0) showNumber++; // each replay of the show gets a fresh seed
const o = makeOffscreen();
offB = o.c;
callInit(scenes[pendingIdx], o.ctx, rect.w, rect.h);
phase = 'fade';
t0 = now;
} else {
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);
}
function renderFreeze(i = 0) {
document.body.classList.add('frozen');
idx = ((i % scenes.length) + scenes.length) % scenes.length;
scene = scenes[idx];
const o = makeOffscreen();
offA = o.c; octx = o.ctx;
callInit(scene, octx, rect.w, rect.h);
replay(0, freeze);
blit(offA);
fillEl.style.width = (freeze * 100).toFixed(2) + '%';
caption();
}
let idleTimer = 0;
function wake() {
document.body.classList.remove('idle');
clearTimeout(idleTimer);
if (freeze == null) idleTimer = setTimeout(() => document.body.classList.add('idle'), IDLE_MS);
}
addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') jump(idx + 1);
else if (e.key === 'ArrowLeft') jump(idx - 1);
else if (e.key === ' ') {
e.preventDefault();
if (freeze == null && phase === 'painting') {
if (pausedAt == null) pausedAt = performance.now();
else { t0 += performance.now() - pausedAt; pausedAt = null; }
}
} else if (/^[1-9]$/.test(e.key)) jump(parseInt(e.key, 10) - 1);
else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
else if (e.key === 'm' || e.key === 'M') setMenu(!menuOpen);
else if (e.key === 's' || e.key === 'S') downloadCanvas();
else if (e.key === 'Escape' && menuOpen) setMenu(false);
wake();
});
addEventListener('mousemove', wake);
addEventListener('mousedown', wake);
addEventListener('resize', () => {
layout();
if (freeze != null) renderFreeze(idx);
else startScene(idx, lastTo);
});
if (freeze == null) {
new EventSource('/events').onmessage = (e) => {
try { const d = JSON.parse(e.data); if (d && d.changed) location.reload(); } catch {}
};
}
layout();
buildDots();
buildMenu();
if (params.get('menu') === '1') setMenu(true); // headless/screenshots can't press M
if (freeze != null) {
renderFreeze(sceneParam ? scenes.findIndex((s) => s.id === sceneParam) : 0);
} else {
const i = sceneParam ? scenes.findIndex((s) => s.id === sceneParam) : -1;
startScene(i >= 0 ? i : 0, 0);
requestAnimationFrame(tick);
}
wake();
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Atelier Impressionniste</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<canvas id="stage"></canvas>
<div id="overlay">
<div id="caption">
<h1 id="title"></h1>
<p id="artist"></p>
</div>
<div id="wordmark">Atelier Impressionniste</div>
<div id="progress"><div id="fill"></div></div>
<nav id="dots" aria-label="Paintings"></nav>
</div>
<script type="module" src="frame.js"></script>
</body>
</html>
// Shared helpers for scene modules. Import from here, keep scenes deterministic.
// Seeded PRNG: same integer seed -> same stream, every run, every machine.
export function mulberry32(seed) {
let t = seed >>> 0;
return function () {
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;
};
}
export const lerp = (a, b, t) => a + (b - a) * t;
export const clamp01 = (x) => Math.min(1, Math.max(0, x));
export const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)];
// ---------------------------------------------------------------------------
// Conditions — structural variation. Each run of a scene rolls its own time of
// day, season, weather and composition dice from a DEDICATED rng stream (the
// frame feeds it separately from the stroke rng), so the same motif becomes a
// combinatorial family of paintings while the same seed still repaints
// byte-identically. A rules table, not an oracle: tiny, readable, coupled.
const SUN_Y = { dawn: 0.52, morning: 0.30, noon: 0.16, goldenHour: 0.40, dusk: 0.50, night: 0.30 };
const WARMTH = { dawn: 0.85, morning: 0.55, noon: 0.25, goldenHour: 0.90, dusk: 0.75, night: 0.10 };
const WEATHER_WARMTH = { clear: 0, mist: -0.05, overcast: -0.20, rain: -0.25, snow: -0.35 };
const SUN_STRENGTH = { clear: 1, mist: 0.65, overcast: 0.30, rain: 0.25, snow: 0.45 };
// Weather LUT by season: snow only in winter, rain never in winter.
const WEATHER_BY_SEASON = {
spring: ['clear', 'clear', 'mist', 'overcast', 'rain'],
summer: ['clear', 'clear', 'clear', 'mist', 'overcast', 'rain'],
autumn: ['clear', 'mist', 'mist', 'overcast', 'rain', 'rain'],
winter: ['clear', 'clear', 'mist', 'overcast', 'snow', 'snow'],
};
// `constraints` (optional): {timeOfDay, season, weather, technique}, each null
// or a value. A set axis is forced to its value; the dedicated rng stream still
// rolls the same number of dice for the free axes, and every coupling below
// (sun/moon, sunY, warmth, strength) follows the forced values like any other
// roll. The technique die is cast LAST, after every other consumer, so rolling
// or forcing it never perturbs the earlier axes for the same seed.
export function makeConditions(rng, constraints = null) {
const forced = constraints || {};
const timeOfDay = forced.timeOfDay || pick(rng, [
'dawn', 'morning', 'morning', 'noon', 'noon',
'goldenHour', 'goldenHour', 'dusk', 'dusk', 'night',
]);
const season = forced.season || pick(rng, ['spring', 'summer', 'summer', 'autumn', 'autumn', 'winter']);
const weather = forced.weather || pick(rng, WEATHER_BY_SEASON[season]);
const night = timeOfDay === 'night';
const horizonY = 0.35 + rng() * 0.25; // 0.35-0.60
const sunX = 0.20 + rng() * 0.60; // the disc never hugs the frame
const sunY = Math.min(SUN_Y[timeOfDay] + (rng() - 0.5) * 0.08, horizonY - 0.06);
const lightAngle = Math.PI / 2 + (sunX - 0.5) * 1.2 + (rng() - 0.5) * 0.15;
const warmth = clamp01(WARMTH[timeOfDay] + WEATHER_WARMTH[weather] + (rng() - 0.5) * 0.10);
const motifCount = 1 + Math.floor(rng() * 3); // 1-3 major motifs
const motifSide = rng() < 0.5 ? 'left' : 'right';
// the technique die is cast after every other consumer, so rolling or
// forcing it never perturbs the earlier axes for the same seed
const technique = TECHNIQUES.includes(forced.technique)
? forced.technique
: pick(rng, TECHNIQUES);
return {
timeOfDay, season, weather, technique,
sunVisible: !night,
moonVisible: night,
sunX, sunY, horizonY, lightAngle, warmth,
sunStrength: SUN_STRENGTH[weather],
motifCount,
motifSide,
};
}
// --- the painter's hand ------------------------------------------------------
// The fourth condition axis: not the world, the WRIST. Each replay rolls not
// just hour/season/weather but which hand holds the brush, rolled FREE — any
// hand for any hour. The scene's stroke realization routes through
// brushes.handFor(technique); a hand can layer glazes, stab dots, drag a dry
// bristle or slab paint on thick, independent of what the sky is doing.
export const TECHNIQUES = ['empatement', 'vernis', 'pointillisme', 'sec', 'mouille'];
const TECHNIQUE_FR = {
empatement: 'empâtement', vernis: 'vernis', pointillisme: 'pointillisme',
sec: 'sec', mouille: 'mouillé',
};
// --- palettes ---------------------------------------------------------------
const hexRgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
// Mix two '#rrggbb' colours; t=0 -> a, t=1 -> b.
export function mix(hexA, hexB, t) {
const a = hexRgb(hexA), b = hexRgb(hexB);
const q = (x, y) => Math.round(x + (y - x) * clamp01(t));
const c = `#${((1 << 24) | (q(a[0], b[0]) << 16) | (q(a[1], b[1]) << 8) | q(a[2], b[2])).toString(16).slice(1)}`;
return c;
}
const SKY_BY_TIME = {
dawn: ['#6f7c9c', '#c99070', '#ecc194'],
morning: ['#9cb8ca', '#c2d2d6', '#f0e9d2'],
noon: ['#7fa8cf', '#b8cfe0', '#e9edda'],
goldenHour: ['#8d6f92', '#d98d58', '#f2c47e'],
dusk: ['#3f4a68', '#7c6480', '#c08358'],
night: ['#141d33', '#23304b', '#3a4a66'],
};
const SEASON_SKY = {
spring: null,
summer: ['#ffe4ac', 0.08],
autumn: ['#e8c9a0', 0.12],
winter: ['#d8e6ef', 0.28],
};
const WEATHER_SKY = {
clear: null,
mist: ['#e4e8e6', 0.35],
overcast: ['#aeb6ba', 0.50],
rain: ['#7d8b99', 0.45],
snow: ['#e9eef3', 0.50],
};
// Full-canvas gradient stops: sky above the horizon, water/ground shadow below.
export function skyGradient(c) {
const [top, mid, hor] = SKY_BY_TIME[c.timeOfDay] || SKY_BY_TIME.morning;
const t = (h) => {
let out = h;
const [sc, sa] = SEASON_SKY[c.season] || [null, 0];
if (sc) out = mix(out, sc, sa);
const [wc, wa] = WEATHER_SKY[c.weather] || [null, 0];
if (wc) out = mix(out, wc, wa);
return out;
};
const hz = clamp01(c.horizonY);
const deep = mix(t(hor), '#1f2836', 0.55);
return [
{ at: 0, color: t(top) },
{ at: Math.min(0.98, hz * 0.6), color: t(mid) },
{ at: hz, color: t(hor) },
{ at: Math.min(1, hz + 0.02), color: mix(t(hor), deep, 0.35) },
{ at: 1, color: deep },
];
}
// Ground colour roles per season; warmth leans each tone sun-warm or shade-cool.
export function groundPalette(season, warmth = 0.5) {
const base = {
spring: {
light: ['#b2c254', '#a6ba48', '#c3cb6e'],
mid: ['#81a24a', '#739644', '#8bad56'],
shade: ['#5c8258', '#50764e', '#476844'],
},
summer: {
light: ['#c2c058', '#d4c468', '#b0b34e'],
mid: ['#98a44c', '#a8b050', '#8a9c48'],
shade: ['#5f7a44', '#556e40', '#6c8450'],
},
autumn: {
light: ['#d2ac5c', '#c69a4c', '#e0bd74'],
mid: ['#b08a44', '#a3793c', '#c09a54'],
shade: ['#7c5c34', '#6e5030', '#8a6a3c'],
},
winter: {
light: ['#eef2f4', '#e2e9ec', '#f6f8f9'],
mid: ['#cfd9de', '#c4d0d8', '#dae2e6'],
shade: ['#a4b4c2', '#9aaab8', '#b0bec9'],
},
}[season];
const w = clamp01(warmth);
const adj = (h) => (w > 0.55 ? mix(h, '#e8c08a', (w - 0.55) * 0.6)
: w < 0.35 ? mix(h, '#8fa0b8', (0.35 - w) * 0.7) : h);
const map = (a) => a.map(adj);
return { light: map(base.light), mid: map(base.mid), shade: map(base.shade) };
}
// --- weather touches --------------------------------------------------------
const WEATHER_RGB = { rain: [178, 196, 210], snow: [242, 246, 250], mist: [226, 230, 228] };
// Sparse weather touches over [from, to): drifting mist bands, thin rain
// streaks, snow dabs. Density is proportional to the window; call it per
// quantized stroke (tiny window -> one touch) for deterministic replay.
export function weatherOverlay(ctx, W, H, rng, c, from, to) {
// Guard on the palette itself, not a hand-kept list: any weather without an
// airborne touch (clear, overcast — its grey lives in WEATHER_SKY instead)
// bails here, so a new touchless weather can never throw col-of-undefined.
if (!c || !WEATHER_RGB[c.weather]) return;
const n = Math.max(1, Math.round((to - from) * 160));
const col = WEATHER_RGB[c.weather];
for (let k = 0; k < n; k++) {
const x = rng() * W, y = rng() * H;
if (c.weather === 'rain') {
const len = H * (0.008 + rng() * 0.02);
ctx.strokeStyle = `rgba(${col[0]},${col[1]},${col[2]},${(0.04 + rng() * 0.07).toFixed(3)})`;
ctx.lineWidth = 0.8 + rng() * 0.8;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + len * 0.18, y + len);
ctx.stroke();
} else if (c.weather === 'snow') {
ctx.fillStyle = `rgba(${col[0]},${col[1]},${col[2]},${(0.25 + rng() * 0.45).toFixed(3)})`;
ctx.beginPath();
ctx.arc(x, y, 0.8 + rng() * 1.6, 0, 6.2832);
ctx.fill();
} else { // mist: one soft drifting band
ctx.fillStyle = `rgba(${col[0]},${col[1]},${col[2]},${(0.015 + rng() * 0.03).toFixed(3)})`;
ctx.beginPath();
ctx.ellipse(x, y, W * (0.08 + rng() * 0.22), H * (0.015 + rng() * 0.05), 0, 0, 6.2832);
ctx.fill();
}
}
}
// --- French caption fragment -------------------------------------------------
const SEASON_FR = { spring: 'printemps', summer: 'été', autumn: 'automne', winter: 'hiver' };
const WEATHER_FR = { clear: 'ciel clair', mist: 'brume', overcast: 'ciel couvert', rain: 'pluie fine', snow: 'neige' };
export function subtitle(c) {
const night = c.timeOfDay === 'night', dusk = c.timeOfDay === 'dusk';
const w = c.weather === 'clear' && (c.timeOfDay === 'noon' || c.timeOfDay === 'goldenHour')
? 'plein soleil'
: c.weather === 'mist' && (night || dusk) ? 'brume du soir'
: night ? (c.weather === 'clear' ? 'nuit claire' : WEATHER_FR[c.weather] + ' de nuit')
: dusk && c.weather === 'clear' ? 'soir clair'
: WEATHER_FR[c.weather];
const hand = TECHNIQUE_FR[c.technique]; // the hand joins the caption as its own fragment
return hand ? `${SEASON_FR[c.season]}, ${w}, ${hand}` : `${SEASON_FR[c.season]}, ${w}`;
}
// Coquelicots — a homage to Monet's poppy fields at Argenteuil (1873).
// Broken color over a primed canvas: the sky laid in breezily, the slope
// built from far to near along its own diagonal, vermilion pooling where
// the light gathers on the hill, two small figures barely suggested,
// the brightest notes struck last. Every stroke is precomputed in init()
// with a fixed progress stamp; draw() paints exactly the stamps inside
// [from, to), so the painting emerges identically however the timeline
// is sliced.
//
// Each run rolls its own conditions (scene-api.js): spring and summer keep
// the vermilion-on-green field, autumn turns it ochre and scatters the
// poppies thin, winter lays the snowfield bare — hedgerow, figures in warm
// coats, no poppies, its own poetry. Weather tints the sky, the dice move
// the hedge, the figures and the slope — and the painter's hand
// (brushes.handFor) realizes every stroke its own way: impasto slabs with
// ridges, translucent glazes, pure dots, dry skips, wet bleeds.
// Same seed -> same painting.
import {
clamp01, mulberry32, makeConditions, groundPalette,
mix as mixHex, subtitle as subtitleFr,
} from '../scene-api.js';
import { handFor, handDab } from '../brushes.js';
let strokes = null;
export default {
id: 'coquelicots',
title: 'Coquelicots, Argenteuil',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Argenteuil, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1873));
const season = cond.season, weather = cond.weather;
const winter = season === 'winter', autumn = season === 'autumn';
const mirror = cond.motifSide === 'right';
const MX = (xn) => (mirror ? 1 - xn : xn); // composition dice: flip the motif side
const night = cond.timeOfDay === 'night', dusk = cond.timeOfDay === 'dusk';
const dawn = cond.timeOfDay === 'dawn';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.fillStyle = night ? mixHex('#e9e2cf', '#2b3552', 0.5)
: winter ? '#e8edf0' : '#e9e2cf'; // primed canvas
ctx.fillRect(0, 0, W, H);
const list = [];
const add = (p, fn) => list.push({ p, fn });
// -- small deterministic helpers -------------------------------------
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5; // ~bell, range +/-1
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const wpick = (pairs) => {
let t = rng() * pairs.reduce((s, q) => s + q[1], 0);
for (const q of pairs) { t -= q[1]; if (t <= 0) return q[0]; }
return pairs[pairs.length - 1][0];
};
// -- geometry of the hill, rolled from the shared dice -----------------
const hzBase = 0.30 + 0.10 * clamp01((cond.horizonY - 0.35) / 0.25);
const hzTilt = (0.016 + rng() * 0.012) * (mirror ? 1 : -1);
const horizon = (x) => H * hzBase + H * hzTilt * (x / W);
const depth = (x, y) => (y - horizon(x)) / (H - horizon(x));
const slopeA = mirror
? (x) => 0.02 + 0.1 * (x / W) // contours rise to the left
: (x) => -(0.02 + 0.1 * (1 - x / W)); // contours rise to the right
// sunlit pools on the meadow: x, y, sx, sy (fractions of W, H), mirrored
const lights = [[0.65, 0.56, 0.16, 0.1], [0.18, 0.76, 0.14, 0.09], [0.86, 0.86, 0.13, 0.08]]
.map(([x, y, sx, sy]) => [MX(x), y, sx, sy]);
const lit = (x, y) => {
let v = 0;
for (const L of lights) {
const dx = (x / W - L[0]) / L[2], dy = (y / H - L[1]) / L[3];
v += Math.exp(-(dx * dx + dy * dy));
}
return v;
};
const curve = (c, x0, y0, ang, len, bow) => {
const x1 = x0 + Math.cos(ang) * len, y1 = y0 + Math.sin(ang) * len;
const mx = (x0 + x1) / 2 - Math.sin(ang) * bow, my = (y0 + y1) / 2 + Math.cos(ang) * bow;
c.beginPath();
c.moveTo(x0, y0);
c.quadraticCurveTo(mx, my, x1, y1);
c.stroke();
};
const HAND = handFor(cond.technique); // the painter's hand for this replay
// every touch routes through the hand: the default hand replays the plain
// primitives below verbatim, the technique hands re-realize them
const dab = (c, x, y, r, sq, rot, col, a) => {
handDab(c, null, HAND, {
x, y, len: r * 2, ang: rot, w: r * 2, c: col, a, squash: sq,
plain: (g) => {
g.save();
g.translate(x, y);
g.rotate(rot);
g.scale(1, sq);
g.globalAlpha = a;
g.fillStyle = col;
g.beginPath();
g.arc(0, 0, r, 0, 6.2832);
g.fill();
g.restore();
},
});
};
const line = (c, x0, y0, ang, len, wd, col, a, bow = 0) => {
handDab(c, null, HAND, {
x: x0 + (Math.cos(ang) * len) / 2, y: y0 + (Math.sin(ang) * len) / 2,
len, ang, w: wd, c: col, a,
plain: (g) => {
g.save();
g.globalAlpha = a;
g.strokeStyle = col;
g.lineWidth = wd;
curve(g, x0, y0, ang, len, bow);
g.restore();
},
});
};
// -- palettes, rolled from season, warmth and the hour -----------------
const gp = groundPalette(season, cond.warmth);
// late hours sink every colour toward violet shadow
const nightDim = (h) => night ? mixHex(h, '#39466b', 0.45)
: dusk ? mixHex(h, '#7c6470', 0.22)
: dawn ? mixHex(h, '#c98a74', 0.12)
: h;
const lightCols = gp.light.map(nightDim), midCols = gp.mid.map(nightDim), shadeCols = gp.shade.map(nightDim);
const lilacCols = (winter ? ['#8fa0b4', '#a4b2c4', '#7e90a4']
: autumn ? ['#9c7a3e', '#8a6a38', '#a8854a']
: ['#8f81ac', '#7f74a0', '#9c8fb6']).map(nightDim);
// weather leans on every sky colour, then the hour leans again
const skyTint = (h) => {
let out = h;
if (weather === 'mist') out = mixHex(out, '#dfe4e2', 0.4);
else if (weather === 'overcast') out = mixHex(out, '#b6bcc0', 0.5);
else if (weather === 'rain') out = mixHex(out, '#8d9aa8', 0.45);
else if (weather === 'snow') out = mixHex(out, '#e9eef3', 0.45);
if (night) out = mixHex(mixHex(out, '#252f4c', 0.62), '#0c1220', 0.3);
else if (dusk) out = mixHex(out, '#b07a62', 0.3);
else if (dawn) out = mixHex(out, '#d99a78', 0.2);
else if (cond.timeOfDay === 'goldenHour') out = mixHex(out, '#eec69a', 0.3);
return out;
};
// ---- priming washes: a breath of tone before the drawing -------------
for (let i = 0; i < 95; i++) {
const x = -W * 0.05 + rng() * W * 0.9;
const yn = rng();
const y = H * 0.02 + yn * (horizon(x + W * 0.2) - H * 0.05);
const col = skyTint(wpick([['#e3e0cc', 4], ['#d8dcd8', 3], ['#dcd9c4', 2], ['#cdd6dd', 2]]));
const ang = (rng() - 0.5) * 0.04;
const len = W * (0.18 + rng() * 0.3);
const wd = H * (0.012 + rng() * 0.026);
const a = 0.1 + rng() * 0.09;
add(0.005 + rng() * 0.05, (c) => line(c, x, y, ang, len, wd, col, a));
}
for (let i = 0; i < 110; i++) {
const x = rng() * W;
const dn = rng();
const y = horizon(x) + H * 0.01 + dn * (H - horizon(x) - H * 0.02);
const col = pick(midCols.concat(lightCols));
const ang = slopeA(x) * 0.5;
const len = W * (0.16 + rng() * 0.28);
const wd = H * (0.014 + rng() * 0.028);
const a = 0.12 + rng() * 0.12;
add(0.01 + rng() * 0.07, (c) => line(c, x, y, ang, len, wd, col, a));
}
// ---- sky: horizontal, breezy, cream at the horizon -------------------
for (let i = 0; i < 800; i++) {
const x = -W * 0.03 + rng() * W * 1.04;
const yn = Math.pow(rng(), 0.9);
const y = H * 0.015 + yn * (horizon(x) - H * 0.045);
const near = yn > 0.72;
const col = skyTint(near
? wpick([['#f1ead3', 5], ['#ece2c8', 3], ['#cfdbe2', 2], ['#d9d3e2', 1]])
: wpick([['#a9c2cf', 5], ['#b7cbd6', 4], ['#9db8c8', 3], ['#f1ead3', 2], ['#d5cde0', 1]]));
const len = W * (0.035 + rng() * 0.11);
const wd = (1.4 + rng() * 4.6) * (0.65 + yn * 0.9);
const a = 0.16 + rng() * 0.3;
const tilt = (rng() - 0.5) * 0.06;
const bow = (rng() - 0.5) * wd;
const p = 0.05 + 0.25 * yn + rng() * 0.015;
add(p, (c) => line(c, x, y, tilt, len, wd, col, a, bow));
}
// ---- clouds: cream and lilac, drifting in loose bands -----------------
const clouds = [
[0.24, 0.13, 0.1, 0.035, 80],
[0.62, 0.22, 0.12, 0.03, 80],
[0.83, 0.1, 0.08, 0.03, 60],
[0.44, 0.3, 0.1, 0.02, 70],
].map(([cx, cy, sx, sy, n]) => [MX(cx), cy, sx, sy, n]);
for (const cl of clouds) {
const cx = cl[0], cy = cl[1], sx = cl[2], sy = cl[3], n = cl[4];
for (let i = 0; i < n; i++) {
const x = (cx + gauss() * sx) * W;
const y = (cy + gauss() * sy) * H;
const col = skyTint(wpick([
['#f6f0dc', 5], ['#efe6cf', 3], ['#d9cfe3', 3], ['#cfc3dc', 2], ['#f9f4e4', 1],
]));
const len = W * (0.04 + rng() * 0.1);
const wd = 4 + rng() * 9;
const a = 0.25 + rng() * 0.35;
const tilt = (rng() - 0.5) * 0.04;
const bow = (rng() - 0.5) * wd;
add(0.24 + rng() * 0.15, (c) => line(c, x, y, tilt, len, wd, col, a, bow));
}
}
// ---- the slope: colours following the diagonal, far to near ----------
for (let i = 0; i < 2600; i++) {
const x = rng() * W;
const dn = Math.pow(rng(), 1.05);
const y = Math.min(horizon(x) + H * 0.004 + dn * (H - horizon(x)), H - 2);
const d = depth(x, y);
const s = 0.35 + 1.25 * Math.pow(d, 0.8);
let col, a;
if (rng() < 0.05) { col = pick(lilacCols); a = 0.26 + rng() * 0.26; }
else {
const l = lit(x, y) + (rng() - 0.5) * 0.5 + (d < 0.2 ? 0.35 : 0); // haze far away
col = l > 0.5 ? pick(lightCols) : l > 0.12 ? pick(midCols) : pick(shadeCols);
a = 0.28 + rng() * 0.34;
}
const ang = slopeA(x) + (rng() - 0.5) * 0.3 + (rng() < 0.08 ? (rng() - 0.5) * 0.8 : 0);
const len = W * (0.014 + rng() * 0.045) * s * (0.7 + rng() * 0.6);
const wd = (1.1 + rng() * 2.8) * s * 3.1;
const bow = (rng() - 0.5) * len * 0.15;
const p = 0.3 + 0.3 * clamp01(d + (rng() - 0.5) * 0.25);
add(p, (c) => line(c, x, y, ang, len, wd, col, a, bow));
}
// grass blades, vertical accents in the front meadow
for (let i = 0; i < 140; i++) {
const x = rng() * W;
const dn = rng();
const y = horizon(x) + H * 0.3 + dn * (H - horizon(x) - H * 0.3);
const d = depth(x, y);
const s = 0.5 + 1.1 * d;
const col = rng() < 0.55 ? pick(lightCols) : pick(midCols);
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.9;
const len = H * (0.006 + rng() * 0.011) * s;
const wd = (1.6 + rng() * 2.2) * s;
const a = 0.3 + rng() * 0.3;
const bow = (rng() - 0.5) * len * 0.3;
add(0.42 + 0.2 * d + rng() * 0.05, (c) => line(c, x, y, ang, len, wd, col, a, bow));
}
// ---- a thin line of trees breaking the horizon, on the dice's side ----
// one continuous hedge of dark horizontal smears, taller at its own end
for (let i = 0; i < 190; i++) {
const xn = MX(0.004 + rng() * 0.286);
const x = xn * W;
const env = mirror ? (xn - 0.71) / 0.286 : 1 - xn / 0.29; // 1 at its end, 0 inland
const hMax = H * (0.005 + 0.011 * env);
const y = horizon(x) - H * 0.001 - rng() * hMax;
const col = wpick([
['#35482f', 4], ['#2c3f2a', 3], ['#40542f', 3], ['#5a7038', 2], ['#243522', 1],
]);
const r = H * (0.0035 + rng() * 0.0045) * (0.7 + 0.5 * env);
const sq = 0.35 + rng() * 0.3; // squashed: horizontal smears, not balls
const rot = (rng() - 0.5) * 0.4;
const a = 0.35 + rng() * 0.35;
add(0.44 + rng() * 0.08, (c) => dab(c, x, y, r, sq, rot, col, a));
}
// a few taller trunks at the hedge's end
for (let i = 0; i < 5; i++) {
const x = W * (mirror ? 0.996 - rng() * 0.035 : 0.004 + rng() * 0.035);
const base = horizon(x) + H * 0.006;
const th = H * (0.035 + rng() * 0.02);
const tilt = (rng() - 0.5) * 0.14;
const a = 0.5 + rng() * 0.3;
add(0.5 + rng() * 0.03, (c) => line(c, x, base, -Math.PI / 2 + tilt, th, 0.8 + rng() * 0.9, '#3a3b2c', a));
}
// ---- the coquelicots: vermilion pooling where the slope gathers -------
// autumn scatters them thin; winter lays the field bare, no poppies
const patches = [
[0.3, 0.62, 0.3, 0.07, 0.05],
[0.55, 0.52, 0.22, 0.06, 0.038],
[0.73, 0.72, 0.22, 0.075, 0.05],
[0.13, 0.82, 0.11, 0.06, 0.045],
[0.87, 0.46, 0.09, 0.05, 0.03],
[0.44, 0.4, 0.06, 0.055, 0.024],
].map(([x, y, sx, sy, d]) => [MX(x), y, sx, sy, d]);
const poppyTarget = winter ? 0 : autumn ? 520 : season === 'summer' ? 1600 : 1400;
const clusterFrac = autumn ? 0.45 : 0.74; // autumn pops up scattered, not pooled
const totalW = patches.reduce((s, q) => s + q[2], 0);
const verm = [['#cd3b28', 4], ['#d94a2f', 3], ['#c22f24', 3], ['#b02820', 1.5]].map(([c2, w]) => [nightDim(c2), w]);
const vermHi = ['#e85f3c', '#f0754f', '#e2583a'].map(nightDim);
const vermLo = ['#a8251f', '#9c2018'].map(nightDim);
const poppyPts = [];
let made = 0, attempts = 0;
while (made < poppyTarget && attempts < 6000) {
attempts++;
let x, y;
if (rng() < clusterFrac) {
let t = rng() * totalW, pt = patches[0];
for (const q of patches) { t -= q[2]; if (t <= 0) { pt = q; break; } }
x = (pt[0] + gauss() * pt[3]) * W;
y = (pt[1] + gauss() * pt[4]) * H;
} else {
x = rng() * W;
y = horizon(x) + H * 0.006 + rng() * (H - horizon(x) - H * 0.02);
}
x = Math.min(Math.max(x, W * 0.008), W * 0.992);
y = Math.min(Math.max(y, horizon(x) + H * 0.005), H * 0.988);
const d = depth(x, y);
if (d > 0.8 && rng() < 0.5) continue; // sparser at the very front
const rad = H * (0.0038 + 0.0115 * Math.pow(d, 1.1)) * (0.6 + rng() * 0.8);
const cq = rng();
const col = cq < 0.12 ? vermHi[Math.floor(rng() * 3)]
: cq < 0.22 ? vermLo[Math.floor(rng() * 2)]
: wpick(verm);
const a = 0.5 + rng() * 0.45;
const rot = slopeA(x) + (rng() - 0.5) * 0.9;
const sq = 0.6 + rng() * 0.35;
const hasCenter = rad > H * 0.004 && rng() < 0.17;
const cCol = rng() < 0.5 ? '#5e1f22' : '#6e2420';
const cX = x + (rng() - 0.5) * rad * 0.5, cY = y + (rng() - 0.5) * rad * 0.3;
const hasTwin = rng() < 0.15;
const tX = x + (rng() - 0.5) * rad, tY = y + (rng() - 0.5) * rad * 0.5;
const p = Math.min(0.86, 0.5 + 0.36 * clamp01(d + (rng() - 0.5) * 0.18));
poppyPts.push([x, y, rad, p]);
made++;
add(p, (c) => {
dab(c, x, y, rad, sq, rot, col, a);
if (hasTwin) dab(c, tX, tY, rad * 0.7, sq, rot + 0.4, col, a * 0.8);
if (hasCenter) dab(c, cX, cY, rad * 0.34, 0.8, rot, cCol, Math.min(1, a + 0.15));
});
}
// ---- weather in the air: stamped like every other stroke --------------
if (weather === 'snow') {
for (let i = 0; i < 260; i++) {
const x = rng() * W, y = rng() * H;
const r = 0.8 + rng() * 1.7, a = 0.2 + rng() * 0.5;
add(0.30 + rng() * 0.68, (c) => dab(c, x, y, r, 1, 0, '#f2f6fa', a));
}
} else if (weather === 'rain') {
for (let i = 0; i < 170; i++) {
const x = rng() * W, y = rng() * H;
const len = H * (0.01 + rng() * 0.022), a = 0.05 + rng() * 0.08;
const tilt = Math.PI / 2 + 0.12;
add(0.30 + rng() * 0.68, (c) => line(c, x, y, tilt, len, 0.9, '#aebecb', a));
}
} else if (weather === 'mist') {
for (let i = 0; i < 90; i++) {
const x = rng() * W;
const y = H * hzBase + (rng() - 0.6) * H * 0.18;
const len = W * (0.12 + rng() * 0.26), wd = H * (0.02 + rng() * 0.05);
const a = 0.05 + rng() * 0.08;
const tilt = (rng() - 0.5) * 0.06;
add(0.20 + rng() * 0.6, (c) => line(c, x, y, tilt, len, wd, '#e4e8e6', a));
}
}
// ---- figures: the dice say how many and where; winter wraps them warm --
const figs = [];
if (cond.motifCount >= 1) figs.push([MX(0.205), 0.615, 1.0, 0.865, true]);
if (cond.motifCount >= 2) figs.push([MX(0.315), 0.505, 0.58, 0.905, false]);
const figure = (fx, fy, sc, p0, withParasol, coat) => {
const h = H * 0.05 * sc;
const bw = Math.max(1.4, H * 0.0085 * sc);
const darks = ['#3a3f55', '#333648', '#444a60'];
// the dress: a tall dark stroke, slightly tapered, with an A-line skirt
add(p0, (c) => line(c, fx, fy - h * 0.55, -Math.PI / 2 + (rng() - 0.5) * 0.06, h * 0.58, bw * 1.15, darks[0], 0.9));
add(p0 + 0.002, (c) => line(c, fx + (rng() - 0.5) * bw * 0.4, fy - h * 0.3, -Math.PI / 2 + (rng() - 0.5) * 0.08, h * 0.42, bw * 1.4, darks[1], 0.85));
for (let i = 0; i < 2; i++) { // the skirt widening at the base
const ang = (i === 0 ? 0.44 : 0.56) * Math.PI + (rng() - 0.5) * 0.08;
add(p0 + 0.004, (c) => line(c, fx, fy - h * 0.18, ang, h * 0.34, bw * 1.1, darks[i % 2 ? 2 : 1], 0.8));
}
const hx = fx + (rng() - 0.5) * bw * 0.3, hy = fy - h * 0.68;
const hr = h * 0.3;
add(p0 + 0.008, (c) => dab(c, hx, hy, hr, 0.5, (rng() - 0.5) * 0.3, '#f0e8d0', 0.95));
add(p0 + 0.01, (c) => dab(c, fx, fy - h * 0.76, hr * 0.5, 0.55, 0, '#e0d5b4', 0.9));
if (coat) { // a warm rust coat against the snow, all the figure needs
const cr = bw * 1.7, cyy = fy - h * 0.42, crot = (rng() - 0.5) * 0.3;
add(p0 + 0.006, (c) => dab(c, fx, cyy, cr, 0.75, crot, '#b0492e', 0.85));
}
if (withParasol) { // a slim hint of parasol over the shoulder
add(p0 + 0.009, (c) => line(c, fx + bw * 0.4, fy - h * 0.5, -Math.PI / 2 - 0.5, h * 0.46, bw * 0.45, '#6b6a78', 0.7));
add(p0 + 0.011, (c) => dab(c, fx + bw * 0.4 + Math.cos(-Math.PI / 2 - 0.5) * h * 0.46, fy - h * 0.5 + Math.sin(-Math.PI / 2 - 0.5) * h * 0.46, h * 0.14, 0.45, 0.4, '#e8dfc8', 0.8));
}
add(p0 + 0.012, (c) => dab(c, fx + h * 0.09, fy + h * 0.04, bw * 2, 0.35, slopeA(fx), '#6f6490', 0.5));
};
for (const [fxn, fyn, sc, p0, parasol] of figs) {
figure(W * fxn, H * fyn, sc, p0, parasol, winter);
}
// ---- brightest accents, struck last -------------------------------------
for (let i = 0; i < 95; i++) { // pure vermilion re-struck on the pools
const q = poppyPts[Math.floor(rng() * poppyPts.length)];
if (!q) break;
const x = q[0] + (rng() - 0.5) * q[2], y = q[1] + (rng() - 0.5) * q[2] * 0.6;
const r2 = q[2] * (0.5 + rng() * 0.5);
const col = rng() < 0.5 ? '#ef5a35' : '#f46a3f';
const sq = 0.6 + rng() * 0.3, rot = (rng() - 0.5) * 0.6, a = 0.7 + rng() * 0.25;
add(0.9 + rng() * 0.09, (c) => dab(c, x, y, r2, sq, rot, col, a));
}
const glowCols = (winter ? ['#eef3f5', '#f6f9fa']
: autumn ? ['#e0c078', '#d0b060']
: ['#ccd67e', '#d8dd8c']).map(nightDim);
for (let i = 0; i < 55; i++) { // lightest grass in the sunlit pools
const L = lights[Math.floor(rng() * 3)];
const x = Math.min(Math.max((L[0] + gauss() * L[2]) * W, W * 0.01), W * 0.99);
const y = Math.min(Math.max((L[1] + gauss() * L[3]) * H, horizon(x) + H * 0.02), H - 3);
const col = rng() < 0.5 ? glowCols[0] : glowCols[1];
const ang = slopeA(x) + (rng() - 0.5) * 0.3;
const len = W * (0.015 + rng() * 0.03);
const wd = 1.5 + rng() * 3;
const a = 0.4 + rng() * 0.3;
add(0.92 + rng() * 0.07, (c) => line(c, x, y, ang, len, wd, col, a));
}
for (let i = 0; i < 22; i++) { // cream glints along the horizon light
const x = rng() * W;
const y = horizon(x) + H * (rng() - 0.5) * 0.02;
const r = H * (0.002 + rng() * 0.003);
const a = 0.4 + rng() * 0.3;
add(0.93 + rng() * 0.06, (c) => dab(c, x, y, r, 0.4, 0, '#f5efda', a));
}
for (let i = 0; i < 16; i++) { // shadow accents at the front
const x = rng() * W;
const y = H * (0.8 + rng() * 0.17);
const r = H * 0.006 * (0.5 + rng());
const col = pick(lilacCols);
add(0.9 + rng() * 0.08, (c) => dab(c, x, y, r, 0.4, 0.3, col, 0.4));
}
for (let i = 0; i < 12; i++) { // vermilion (or coat-coloured) notes around the figures
if (!figs.length) break;
const q = figs[Math.floor(rng() * figs.length)];
const fx = W * q[0], fy = H * q[1];
const x = fx + gauss() * W * 0.035;
const y = fy + gauss() * H * 0.04;
const r = H * 0.005 * (0.6 + rng() * 0.8);
const col = winter ? (rng() < 0.6 ? '#b0492e' : '#f0e9d2')
: rng() < 0.6 ? '#e25433' : '#f0e9d2';
add(0.94 + rng() * 0.05, (c) => dab(c, x, y, r, 0.7, (rng() - 0.5) * 0.5, col, 0.6 + rng() * 0.3));
}
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);
}
},
};
// Show order — one painter at a time, in this order, forever.
import soleil from './soleil.js';
import coquelicots from './coquelicots.js';
import nuit from './nuit.js';
import vallee from './vallee.js';
export default [soleil, coquelicots, nuit, vallee];
// Nuit étoilée sur le Rhône — a homage to Van Gogh's starry nights.
// A deep night of Prussian blue, indigo and violet laid in with directional
// strokes; two great swirls winding through the sky with a pale blue-green
// current running between them; stars kindling one by one, each with a
// corona of short radiating dashes; the light struck last, the brightest
// accent, with its full halo; and the dark water answering in broken
// vertical shimmers of gold and pale blue beneath the stars. A low bank,
// moored boats, one lantern.
//
// The painting now rolls its own conditions (scene-api.js) and adapts while
// keeping its soul. Night is home turf: the dice move the moon to a side,
// size its phase-ish halo, move the waterline, flip the bank and boats, and
// sometimes swap the shoreline for a jetty. At dawn, dusk and golden hour
// the same architecture becomes the sky's TRANSITION: deep indigo bottom,
// a first rose/gold band at the horizon, stars fading near the light, the
// water carrying both. Weather thins or thickens the starfield (mist halves
// it, overcast lets the swirls dominate) and rides in as house weatherOverlay
// touches; winter cools the blues, sharpens the stars and lays icy silver
// shimmer on the water; summer warms the golds. Morning and noon are
// re-rolled away — this scene's sky vocabulary is night-first (see
// conditions()).
//
// Determinism unchanged: every stroke is precomputed in init() with a fixed
// progress stamp; draw() paints exactly the stamps inside [from, to), so the
// painting emerges identically however the timeline is sliced. Closures never
// touch the live rng: any randomness they need arrives as a dedicated
// mulberry32 stream created in init().
import {
mulberry32, makeConditions, skyGradient, weatherOverlay,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import { brokenColor, drybrush, stroke, dab as softDab } from '../brushes.js';
let strokes = null;
export default {
id: 'nuit',
title: 'Nuit étoilée sur le Rhône',
artist: 'Atelier Impressionniste',
durationMs: 55000,
// Scene-specific weights override: nuit never paints morning or noon.
// Instead of a table of weights, the rolled conditions are re-drawn (same
// dedicated stream, so still fully deterministic) until the sky lands on
// the scene's home turf (night) or on the day's edges it can paint
// honestly (dawn / dusk / goldenHour). Eight tries max; the astronomically
// rare survivor paints as a goldenHour transition. Morning/noon survive
// roughly 0.4^8 of the time — essentially never.
conditions: (rng) => {
let c = makeConditions(rng);
for (let i = 0; i < 8; i++) {
const t = c.timeOfDay;
if (t === 'night' || t === 'dawn' || t === 'dusk' || t === 'goldenHour') return c;
c = makeConditions(rng);
}
return c;
},
subtitle: (c) => 'nuit sur l’eau, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const c = conditions || makeConditions(mulberry32(1888));
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.fillStyle = '#0a1020'; // the primed night
ctx.fillRect(0, 0, W, H);
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; // ~bell, range +/-1
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const mixTo = (arr, col, k) => arr.map((h) => mixHex(h, col, k));
// ---- conditions-derived state ------------------------------------------
const tod = c.timeOfDay === 'morning' || c.timeOfDay === 'noon'
? 'goldenHour' : c.timeOfDay; // the rare re-roll survivor paints as goldenHour
const night = tod === 'night';
const trans = !night; // dawn / dusk / goldenHour: the sky's transition
const wx = c.weather;
const winter = c.season === 'winter';
const summer = c.season === 'summer';
const hzF = Math.max(0.50, Math.min(0.72, c.horizonY + 0.10)); // the waterline
const hz = H * hzF;
const waterH = H - hz;
// the light: the moon high on its die-rolled side, or the half-set sun
const moonSide = c.motifSide;
let lightX, lightY, lightR, haloK;
if (night) {
lightX = W * (moonSide === 'left' ? 0.14 + rng() * 0.18 : 0.68 + rng() * 0.18);
lightY = H * (0.10 + rng() * 0.09);
lightR = H * (0.045 + rng() * 0.02);
haloK = 3.0 + rng() * 1.6; // phase-ish: how far the halo breathes out
} else {
lightX = W * c.sunX; // the sun dice never hug the frame
lightY = hz - H * (0.05 + rng() * 0.06); // half-set on the horizon
lightR = H * (0.05 + rng() * 0.025);
haloK = 3.4 + rng() * 1.6;
}
// the horizon colour straight off the rolled sky — the transition's rose
const horizonCol = skyGradient(c)[2].color;
// the transition stage: first light laid as a wash beneath the strokes
if (trans) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#0a1020');
g.addColorStop(hzF * 0.5, mixHex('#0e1a36', horizonCol, 0.10));
g.addColorStop(Math.min(0.98, hzF), mixHex(horizonCol, '#241a30', 0.28));
g.addColorStop(Math.min(1, hzF + 0.02), '#131c34');
g.addColorStop(1, '#0a1224');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const rg = ctx.createRadialGradient(lightX, lightY, 0, lightX, lightY, W * 0.18);
rg.addColorStop(0, 'rgba(244,200,140,0.15)');
rg.addColorStop(1, 'rgba(244,200,140,0)');
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
}
const curve = (c, x0, y0, ang, len, bow) => {
const x1 = x0 + Math.cos(ang) * len, y1 = y0 + Math.sin(ang) * len;
const mx = (x0 + x1) / 2 - Math.sin(ang) * bow, my = (y0 + y1) / 2 + Math.cos(ang) * bow;
c.beginPath();
c.moveTo(x0, y0);
c.quadraticCurveTo(mx, my, x1, y1);
c.stroke();
};
const dab = (c, x, y, r, sq, rot, col, a) => {
c.save();
c.translate(x, y);
c.rotate(rot);
c.scale(1, sq);
c.globalAlpha = a;
c.fillStyle = col;
c.beginPath();
c.arc(0, 0, r, 0, 6.2832);
c.fill();
c.restore();
};
const line = (c, x0, y0, ang, len, wd, col, a) => {
c.save();
c.globalAlpha = a;
c.strokeStyle = col;
c.lineWidth = wd;
curve(c, x0, y0, ang, len, 0);
c.restore();
};
// a stroke from (x0,y0) to (x1,y1) with a small deterministic bow
const seg = (p, x0, y0, x1, y1, col, a, wd) => {
const dx = x1 - x0, dy = y1 - y0;
const L = Math.hypot(dx, dy) || 1;
const bow = (rng() - 0.5) * L * 0.18;
add(p, (c) => {
c.save();
c.globalAlpha = a;
c.strokeStyle = col;
c.lineWidth = wd;
c.beginPath();
c.moveTo(x0, y0);
c.quadraticCurveTo((x0 + x1) / 2 - dy / L * bow, (y0 + y1) / 2 + dx / L * bow, x1, y1);
c.stroke();
c.restore();
});
};
// ---- deep sky ground: layered Prussian, indigo, violet -----------------
let skyLo = ['#0d1834', '#101d3e', '#0e1a36', '#132048'];
let skyMid = ['#18244c', '#202c58', '#2a3160', '#332f5e', '#252a52'];
let skyHi = ['#33406e', '#3c4676', '#3f4a72', '#2e3a64', '#453c6e'];
let watD = ['#0a1224', '#0d1830', '#0e1a2e', '#101f38', '#122440'];
let watL = ['#1d3352', '#233a5c', '#182a48'];
let watG = ['#0a1a16', '#0d211c', '#0b1e1a'];
let golds = ['#d8bd7c', '#e8dcae', '#cfae62'];
let pales = ['#7f9cc0', '#8fa8c8'];
// the transition band: how strongly each sky band takes the horizon rose —
// kept tight to the horizon so the deep indigo above never turns beige
const tBand = tod === 'goldenHour' ? [0.06, 0.16, 0.60]
: tod === 'dawn' ? [0.10, 0.18, 0.50]
: [0.04, 0.10, 0.44]; // dusk: the deepest of the transitions
if (trans) {
skyLo = mixTo(skyLo, horizonCol, tBand[0]);
skyMid = mixTo(skyMid, horizonCol, tBand[1]);
skyHi = mixTo(skyHi, horizonCol, tBand[2]);
watL = mixTo(watL, horizonCol, 0.28); // the water carries the sky's band
golds = mixTo(golds, horizonCol, 0.22);
}
if (wx === 'overcast') { // the ceiling comes down over the night
const k = night ? 0.22 : 0.15;
skyLo = mixTo(skyLo, '#66707c', k);
skyMid = mixTo(skyMid, '#66707c', k);
skyHi = mixTo(skyHi, '#66707c', k);
watL = mixTo(watL, '#66707c', k * 0.6);
}
if (wx === 'mist') skyHi = mixTo(skyHi, '#b8c0ba', 0.22);
if (winter) { // colder blues all through, silvered accents
const cool = (a, k) => mixTo(a, '#8fa8c8', k);
skyLo = cool(skyLo, 0.16); skyMid = cool(skyMid, 0.16); skyHi = cool(skyHi, 0.14);
watD = cool(watD, 0.14); watL = cool(watL, 0.18);
golds = mixTo(golds, '#e6eef8', 0.38);
pales = mixTo(pales, '#c8d8ec', 0.35);
}
if (summer) golds = mixTo(golds, '#f0b45c', 0.30); // warmer gold accents
for (let i = 0; i < 900; i++) {
const y = Math.pow(rng(), 0.85) * (hz - H * 0.012);
const x = -W * 0.03 + rng() * W * 1.05;
const v = y / hz;
const col = v < 0.32 ? pick(skyLo) : v < 0.68 ? pick(skyMid) : pick(skyHi);
const ang = 0.04 + (rng() - 0.5) * 0.16;
const len = W * (0.045 + rng() * 0.1);
const wd = 2.2 + rng() * 5.5;
const a = 0.1 + rng() * 0.15;
const bow = (rng() - 0.5) * wd * 0.8;
add(0.005 + rng() * 0.21, (c) => {
c.save();
c.globalAlpha = a;
c.strokeStyle = col;
c.lineWidth = wd;
curve(c, x, y, ang, len, bow);
c.restore();
});
}
// ---- water ground: dark horizontals, navy and black-green -------------
for (let i = 0; i < 520; i++) {
const d = Math.pow(rng(), 0.9);
const y = hz + H * 0.004 + d * (waterH - H * 0.025);
const x = -W * 0.02 + rng() * W * 1.03;
const col = rng() < 0.1 ? pick(watG) : d < 0.3 ? pick(watL) : pick(watD);
const s = 0.7 + 1.5 * d;
const ang = (rng() - 0.5) * 0.1;
const len = W * (0.04 + rng() * 0.13) * s;
const wd = (1.6 + rng() * 3.6) * s;
const a = 0.14 + rng() * 0.18;
const bow = (rng() - 0.5) * wd;
add(0.1 + d * 0.16 + rng() * 0.04, (c) => {
c.save();
c.globalAlpha = a;
c.strokeStyle = col;
c.lineWidth = wd;
curve(c, x, y, ang, len, bow);
c.restore();
});
}
// the near water, close and heavy: broad dark passes low in the frame
for (let i = 0; i < 150; i++) {
const d = rng();
const y = hz + waterH * (0.5 + d * 0.42);
const x = -W * 0.02 + rng() * W * 1.03;
const col = rng() < 0.3 ? '#080e1c' : pick(watD);
const ang = (rng() - 0.5) * 0.08;
const len = W * (0.06 + rng() * 0.16);
const wd = 3.5 + rng() * 6;
const a = 0.12 + rng() * 0.16;
add(0.16 + d * 0.12 + rng() * 0.03, (c) => {
c.save();
c.globalAlpha = a;
c.strokeStyle = col;
c.lineWidth = wd;
curve(c, x, y, ang, len, (rng() - 0.5) * wd);
c.restore();
});
}
// faint cold shimmer in the low water, drybrushed so the paper breathes
for (let i = 0; i < 34; i++) {
const d = rng();
const y = hz + waterH * (0.45 + d * 0.5);
const x = rng() * W;
const ang = (rng() - 0.5) * 0.08;
const a = 0.07 + rng() * 0.1;
const col = rng() < 0.6 ? '#3a5878' : '#2c4664';
const dr = mulberry32(0x9e37 + i * 7919); // dedicated stream per closure
add(0.24 + d * 0.1, (c) => drybrush(c, dr, {
x, y, size: 1.6 + d * 1.4, color: col, alpha: a, angle: ang,
run: 2.6, density: 0.5, squash: 0.3,
}));
}
if (winter) { // icy silver shimmer: the frozen breath on the water
for (let i = 0; i < 70; i++) {
const d = rng();
const y = hz + waterH * (0.04 + d * 0.72);
const x = rng() * W;
const col = pick(['#c9d8e8', '#b8cce0', '#dce8f2']);
const a = 0.10 + rng() * 0.16;
const sz = 1.4 + rng() * 2.2;
const ang = (rng() - 0.5) * 0.12;
const dr = mulberry32(0x1ce0 + i * 104729);
add(0.26 + d * 0.22, (c) => drybrush(c, dr, {
x, y, size: sz, color: col, alpha: a, angle: ang,
run: 2.2, density: 0.55, squash: 0.28,
}));
}
}
// haze settling on the horizon: mist thickens it, transitions kindle it,
// and under an overcast ceiling a faint glow keeps the waterline alive
if (wx === 'mist' || trans || wx === 'overcast') {
const hazeCol = trans ? mixHex(horizonCol, '#e8e4da', 0.3)
: wx === 'overcast' ? mixHex(horizonCol, '#9aa4b0', 0.45)
: '#b8c0ba';
const nHaze = wx === 'mist' ? 16 : wx === 'overcast' ? 10 : 9;
const aBase = wx === 'mist' ? 0.06 : wx === 'overcast' ? 0.05 : 0.04;
const hSpread = wx === 'overcast' ? 0.028 : 0.05;
for (let i = 0; i < nHaze; i++) {
const x = rng() * W;
const y = hz - H * (0.005 + rng() * hSpread);
const rw = W * (0.10 + rng() * 0.22);
const rh = H * (0.008 + rng() * 0.02);
const a = aBase + rng() * 0.05;
add(0.02 + rng() * 0.06, (c) => dab(c, x, y, rw, rh / rw, 0, hazeCol, a));
}
}
// ---- the great swirls: spiral flow fields, ribbons of pale blue-green --
const swirl = (cx, cy, rMax, r0, dir, wind, sqy, n, nt, pBase, pSpan, tone) => {
const k = Math.log(rMax / r0);
const pos = (u, off) => {
const ang = dir * wind * u;
const r = r0 * Math.exp(k * u);
const nx = -Math.sin(ang) * dir, ny = Math.cos(ang) * dir * sqy;
const L = Math.hypot(nx, ny) || 1;
return [cx + Math.cos(ang) * r + nx / L * off, cy + Math.sin(ang) * r * sqy + ny / L * off];
};
const ribbon = rMax * 0.06;
for (let i = 0; i < n; i++) { // the flowing ribbon: broad body + pale core,
// both hugging the same spiral so the strokes overlap into a current
const u = i / n + rng() * 0.003;
const off = gauss() * ribbon * 0.75;
const q0 = pos(u, off), q1 = pos(u + 5 / n, off * 0.92);
seg(pBase + u * pSpan + rng() * 0.01, q0[0], q0[1], q1[0], q1[1], pick(tone.edge), 0.12 + rng() * 0.12, 4.5 + rng() * 5);
if (rng() < 0.7) {
const off2 = gauss() * ribbon * 0.22;
const c0 = pos(u, off2), c1 = pos(u + 4 / n, off2);
seg(pBase + u * pSpan + rng() * 0.01, c0[0], c0[1], c1[0], c1[1], pick(tone.core), 0.18 + rng() * 0.2, 2.2 + rng() * 2.8);
}
}
for (let i = 0; i < nt; i++) { // loose turbulence circling the swirl
const ang = rng() * 6.2832;
const r = rMax * (0.35 + rng() * 0.85);
const x = cx + Math.cos(ang) * r, y = cy + Math.sin(ang) * r * sqy;
const ta = ang + dir * 1.5708 + (rng() - 0.5) * 0.5;
const l = W * (0.012 + rng() * 0.03);
const col = rng() < 0.4 ? pick(tone.edge) : pick(tone.dark);
seg(pBase + rng() * pSpan, x, y, x + Math.cos(ta) * l, y + Math.sin(ta) * l * sqy, col, 0.05 + rng() * 0.1, 1.5 + rng() * 2.5);
}
};
// swirl tones take a breath of the horizon rose in the transitions
const sMix = trans ? [0.30, 0.22, 0.12] : [0, 0, 0];
let tonePale = {
core: mixTo(['#a9c2cf', '#93b1c4', '#c3d4d8', '#8fb4b0', '#9db8c8'], horizonCol, sMix[0]),
edge: mixTo(['#5f82a4', '#4f7195', '#6b8dab', '#587fa0'], horizonCol, sMix[1]),
dark: mixTo(['#3a5578', '#32496b', '#2c4060'], horizonCol, sMix[2]),
};
let toneWarm = {
core: mixTo(['#b3c2c6', '#9db2c2', '#c8cec0', '#a5bcbc', '#bcc6b4'], horizonCol, sMix[0]),
edge: mixTo(['#5f82a4', '#6b8dab', '#587fa0', '#64889c'], horizonCol, sMix[1]),
dark: mixTo(['#3a5578', '#32496b', '#2c4060'], horizonCol, sMix[2]),
};
if (wx === 'overcast') { // under the ceiling the swirls go slate
tonePale = {
core: mixTo(tonePale.core, '#8a929c', 0.18),
edge: mixTo(tonePale.edge, '#8a929c', 0.18),
dark: mixTo(tonePale.dark, '#8a929c', 0.10),
};
toneWarm = {
core: mixTo(toneWarm.core, '#8a929c', 0.18),
edge: mixTo(toneWarm.edge, '#8a929c', 0.18),
dark: mixTo(toneWarm.dark, '#8a929c', 0.10),
};
}
// overcast: the swirls dominate the starved sky
const swirlBoost = wx === 'overcast' ? 1.45 : 1;
swirl(W * 0.33, H * 0.3, W * 0.3, W * 0.03, 1, 5.4, 0.6, Math.round(320 * swirlBoost), Math.round(60 * swirlBoost), 0.07, 0.33, tonePale);
swirl(W * 0.66, H * 0.2, W * 0.22, W * 0.025, -1, 4.4, 0.55, Math.round(260 * swirlBoost), Math.round(55 * swirlBoost), 0.12, 0.32, toneWarm);
swirl(W * 0.88, H * 0.42, W * 0.09, W * 0.015, 1, 3.4, 0.6, Math.round(130 * swirlBoost), Math.round(26 * swirlBoost), 0.2, 0.22, tonePale);
// a broad current running between the swirls, a slow sine across the sky
for (let i = 0; i < 120; i++) {
const u = rng();
const x = W * (0.03 + u * 0.94);
const y = hz * 0.5 + Math.sin(u * 5.2 + 1.2) * H * 0.085 + gauss() * H * 0.02;
const slope = Math.cos(u * 5.2 + 1.2) * H * 0.085 * 5.2 / W;
const ang = Math.atan(slope) + (rng() - 0.5) * 0.2;
const len = W * (0.02 + rng() * 0.05);
const wd = 1.8 + rng() * 3.6;
const a = 0.08 + rng() * 0.14;
const col = rng() < 0.6 ? pick(tonePale.core) : pick(tonePale.edge);
seg(0.28 + u * 0.14 + rng() * 0.008, x, y, x + Math.cos(ang) * len, y + Math.sin(ang) * len, col, a, wd);
}
// the transition made physical: sinuous ribbons of rose and gold traveling
// the horizon, painted with the house stroke and a travelling colour
if (trans) {
const bandRng = mulberry32(0xb4cd); // dedicated stream; 'round' consumes none
const roseA = mixHex(horizonCol, '#f6d9a8', 0.45);
const roseB = mixHex(horizonCol, '#f2b878', 0.25);
for (let b = 0; b < 3; b++) {
const lift = H * (0.012 + b * 0.022);
const ph = b * 2.1;
const pts = [];
for (let i = 0; i <= 8; i++) {
const u = i / 8;
pts.push([W * (-0.03 + u * 1.06),
hz - lift - Math.abs(Math.sin(u * 4.6 + ph)) * H * 0.028 - gauss() * H * 0.008]);
}
const al = 0.16 - b * 0.035;
const wdt = H * (0.014 - b * 0.003);
add(0.225 + b * 0.004, (c) => stroke(c, bandRng, {
path: pts, brush: 'round', width: wdt, taper: 'both',
alpha: al, smooth: true,
colorFn: (t) => mixHex(roseA, roseB, 0.5 + 0.5 * Math.sin(t * 9.0 + ph)),
}));
}
}
// ---- the stars: kindling one by one, haloed, reflected ----------------
const stars = [];
const bigs = [];
// the starfield thins with the weather and with the sky's own light
let starScale = 1;
if (trans) starScale *= 0.55;
if (wx === 'mist') starScale *= 0.5;
if (wx === 'overcast') starScale *= 0.3;
if (wx === 'rain') starScale *= 0.55;
if (wx === 'snow') starScale *= 0.45;
const tryStar = (x, y, r) => {
if (rng() > starScale) return;
x = Math.min(Math.max(x, W * 0.02), W * 0.98);
y = Math.min(Math.max(y, H * 0.02), hz - H * 0.03);
if (Math.hypot(x - lightX, y - lightY) < lightR * 3) return;
stars.push({ x, y, r });
};
// the Dipper, faintly, high in the middle
const dip = [[0.3, 0.13], [0.37, 0.105], [0.45, 0.115], [0.535, 0.15], [0.575, 0.23], [0.545, 0.31], [0.465, 0.34]];
for (let i = 0; i < dip.length; i++) {
tryStar((dip[i][0] + gauss() * 0.008) * W, (dip[i][1] + gauss() * 0.008) * H,
H * (i < 4 ? 0.0045 + rng() * 0.002 : 0.006 + rng() * 0.003));
}
// clusters with rhythm, then scattered singles
const clus = [
[0.13, 0.32, 0.045, 0.05, 6], [0.7, 0.36, 0.055, 0.045, 6],
[0.9, 0.32, 0.035, 0.05, 4], [0.2, 0.075, 0.05, 0.028, 5],
[0.62, 0.065, 0.055, 0.026, 5], [0.42, 0.27, 0.085, 0.06, 6],
];
for (const q of clus) {
for (let i = 0; i < q[4]; i++) {
tryStar((q[0] + gauss() * q[2]) * W, (q[1] + gauss() * q[3]) * H, H * (0.003 + rng() * 0.005));
}
}
for (let i = 0; i < 16; i++) {
tryStar(rng() * W, rng() * (hz - H * 0.06), H * (0.003 + rng() * 0.004));
}
// a few large ones, the beacons of the piece
for (let i = 0; i < 5 && stars.length; i++) {
const s = stars[Math.floor(rng() * stars.length)];
s.r = H * (0.011 + rng() * 0.004);
bigs.push(s);
}
// star voices: night burns warm gold; winter burns cold and hard; the
// transitions warm toward the horizon's rose
let coreCol = '#f2e6c2', coreHot = '#fbf5e0', glowCol = '#c9bd92', glowWarm = '#e0d2a2';
let coronaCols = ['#e8d9a8', '#cfd8d0'];
if (trans) {
coreCol = '#f6e8c8'; coreHot = '#fdf6dd';
// richer chroma than the night glow: at low alpha over navy it must stay
// warm, not sink into grey murk
glowCol = '#e8bd86'; glowWarm = '#f2cd94';
coronaCols = ['#f0dcb4', '#d8dcd0'];
}
if (winter) {
coreCol = '#eef3fa'; coreHot = '#f8fbff'; glowCol = '#c2cfdd'; glowWarm = '#d4dee8';
coronaCols = ['#e4ecf6', '#ccd8e4'];
}
for (const s of stars) {
const big = s.r > H * 0.0075;
// stars fade near the light and toward the horizon in the transitions
const dL = Math.hypot(s.x - lightX, s.y - lightY);
const near = night ? 1 : clamp01((dL / (lightR * 5) - 0.5) / 1.4);
const hgt = trans ? clamp01(1.12 - (s.y / hz) * 1.0) : 1;
const dim = near * (0.30 + 0.70 * hgt);
if (dim < 0.06) continue;
const p0 = 0.3 + rng() * 0.4;
// broken-colour halo: the glow shimmers in 2-3 neighbours, never flat;
// the house soft dab gives the glow a real light falloff, no hard rim
const halo = brokenColor(ctx, rng, glowWarm, 0.35);
const gr = s.r * (winter ? 2.1 : 2.6); // winter: sharper, tighter glow
add(p0 - 0.004, (c) => softDab(c, s.x, s.y, gr, halo[0], 0.11 * dim));
add(p0 - 0.003, (c) => softDab(c, s.x, s.y, gr * 0.65, halo[1] || glowCol, 0.18 * dim));
// the core
add(p0, (c) => dab(c, s.x, s.y, s.r * 0.75, 1, 0, coreCol, Math.min(1, 0.8 * dim * 1.25)));
add(p0 + 0.004, (c) => dab(c, s.x, s.y, s.r * 0.42, 1, 0, coreHot, Math.min(1, (winter ? 1 : 0.95) * dim * 1.35)));
// the corona: short directional dashes all around
const nd = big ? 12 : 7;
for (let i = 0; i < nd; i++) {
const ang = (i / nd) * 6.2832 + rng() * 0.5;
const l = s.r * (big ? 2.2 + rng() * 1.6 : 1.5 + rng() * 1.2);
const wdd = big ? 1.1 + rng() * 1.2 : 0.8 + rng() * 0.8;
const a = (0.22 + rng() * 0.3) * dim;
const col = rng() < 0.7 ? coronaCols[0] : coronaCols[1];
const x0 = s.x + Math.cos(ang) * s.r * 0.9, y0 = s.y + Math.sin(ang) * s.r * 0.9;
add(p0 + 0.008 + rng() * 0.006, (c) => line(c, x0, y0, ang, l, wdd, col, a));
}
if (big) { // four long rays for the beacons
for (let i = 0; i < 4; i++) {
const ang = i * 1.5708 + 0.4 + rng() * 0.3;
const l = s.r * (3 + rng() * 1.5);
const x0 = s.x + Math.cos(ang) * s.r, y0 = s.y + Math.sin(ang) * s.r;
add(p0 + 0.012, (c) => line(c, x0, y0, ang, l, 0.9, coreCol, 0.3 * dim));
}
}
// the reflection, arriving as the star completes: broken vertical
// dashes trembling downward, gold and pale blue, loosening with depth
const reflK = (trans ? 0.65 : 1) * (wx === 'mist' ? 0.75 : 1);
const rp = Math.min(0.955, p0 + 0.2 + rng() * 0.06);
const nd2 = big ? 7 + Math.floor(rng() * 4) : 3 + Math.floor(rng() * 3);
for (let i = 0; i < nd2; i++) {
const fade = 1 - i / nd2;
const col = rng() < 0.6 ? pick(golds) : pick(pales);
const a = (0.12 + 0.3 * fade) * reflK;
const len = H * (0.006 + rng() * 0.012) * (big ? 1.4 : 1);
const wd = (big ? 1.4 : 1) + rng() * 1.4;
const xx = s.x + gauss() * s.r * 0.5 + gauss() * H * 0.004 * (1 + i * 0.35);
const yy = hz + H * (0.004 + rng() * 0.008) + i * waterH * (0.035 + rng() * 0.02);
const tilt = Math.PI / 2 + (rng() - 0.5) * 0.24;
add(rp + i * 0.002, (c) => line(c, xx, yy, tilt, len, wd, col, a));
}
}
// ---- the bank or the jetty, the boats, one lantern ---------------------
const bankCols = ['#070a12', '#0a0d16', '#060810', '#0c1019'];
const jetty = rng() < 0.45; // composition die: shoreline or a jetty
for (let i = 0; i < 24; i++) {
const xr = jetty
? (moonSide === 'right' ? [0.48, 1.05] : [-0.05, 0.52])
: [-0.02, 1.03]; // a jetty narrows the near bank to its own side
const x = W * xr[0] + rng() * W * (xr[1] - xr[0]);
const y = H * 0.945 + rng() * H * 0.028;
const ang = (rng() - 0.5) * 0.05;
const len = W * (0.1 + rng() * 0.2);
const wd = 5 + rng() * 9;
const a = 0.5 + rng() * 0.3;
add(0.48 + rng() * 0.09, (c) => line(c, x, y, ang, len, wd, pick(bankCols), a));
}
let jettyTipX = 0, jettyTipY = 0;
if (jetty) {
const side = moonSide === 'left' ? 1 : -1; // the jetty recedes inward
const fx = W * (moonSide === 'left' ? 0.04 : 0.96), fy = H * (0.975 + rng() * 0.015);
const tx = W * (0.5 + side * (0.02 + rng() * 0.10)), ty = hz + waterH * (0.22 + rng() * 0.16);
jettyTipX = tx; jettyTipY = ty;
const jx = (u) => fx + (tx - fx) * u, jy = (u) => fy + (ty - fy) * u;
seg(0.50, fx, fy, tx, ty, '#06080f', 0.9, 3.2); // the walkway rails
seg(0.505, fx, fy + H * 0.008, tx, ty + H * 0.004, '#0a0d16', 0.7, 2.2);
for (let i = 1; i <= 7; i++) { // posts, shrinking with depth
const u = i / 7, sc = 1 - u * 0.8;
const px = jx(u), py = jy(u);
const ph = H * 0.045 * sc;
add(0.51 + u * 0.05, (c) => line(c, px, py - ph, -Math.PI / 2 + 0.02, ph, 2.4 * sc + 0.6, '#05070c', 0.9));
add(0.515 + u * 0.05, (c) => line(c, px, py + H * 0.004, Math.PI / 2, H * 0.02 * sc, 1.6 * sc + 0.4, '#0a0d16', 0.45));
}
for (let i = 0; i < 6; i++) { // cross planks
const u = 0.12 + i * 0.16;
const half = W * 0.028 * (1 - u * 0.8);
seg(0.52 + u * 0.05, jx(u) - half, jy(u), jx(u) + half, jy(u) + (rng() - 0.5) * 3, '#04060b', 0.75, 2 * (1 - u * 0.6));
}
}
const boat = (bx, by, sc, p0) => {
const L = W * 0.075 * sc;
const tilt = 0.06 * (rng() < 0.5 ? 1 : -1);
add(p0, (c) => { // the hull, one dark curve
c.save();
c.globalAlpha = 0.92;
c.strokeStyle = '#05070c';
c.lineWidth = 5 * sc;
c.beginPath();
c.moveTo(bx - L / 2, by);
c.quadraticCurveTo(bx, by + H * 0.014 * sc, bx + L / 2, by - H * 0.005 * sc);
c.stroke();
c.restore();
});
add(p0 + 0.003, (c) => line(c, bx - L * 0.42, by + H * 0.002 * sc, 0.06, L * 0.84, 1.2 * sc, '#2c4664', 0.4)); // the water catching the hull's edge
add(p0 + 0.004, (c) => line(c, bx - L / 2, by - H * 0.004 * sc, 0.02, L, 2.5 * sc, '#0a0d14', 0.85)); // gunwale
add(p0 + 0.008, (c) => line(c, bx + L * 0.12, by - H * 0.002 * sc, -Math.PI / 2 + tilt, H * 0.055 * sc, 1.3 * sc, '#10141f', 0.95)); // mast
add(p0 + 0.012, (c) => line(c, bx + L * 0.12, by - H * 0.045 * sc, 0.5 + tilt, H * 0.02 * sc, 1 * sc, '#0e1220', 0.85)); // boom
};
// the boats moor away from the light, on the dice-rolled side
const boatSide = moonSide === 'left' ? 'right' : 'left';
const bx1 = boatSide === 'left' ? 0.16 : 0.84;
const bx2 = boatSide === 'left' ? 0.31 : 0.69;
boat(W * bx1, H * 0.952, 1, 0.595);
if (c.motifCount >= 2) boat(W * bx2, H * 0.962, 0.75, 0.61);
// the lantern: a small warm voice on the water — on the jetty's far post
// when there is one, else low on the bank by the boats
const lx = jetty ? jettyTipX : W * (boatSide === 'left' ? 0.105 : 0.895);
const ly = jetty ? jettyTipY - H * 0.045 : H * 0.9;
add(0.62, (c) => softDab(c, lx, ly, H * 0.014, '#c9a75e', 0.22));
add(0.622, (c) => dab(c, lx, ly, H * 0.004, 1, 0, '#f2d488', 0.85));
for (let i = 0; i < 4; i++) {
const yy = ly + H * (0.008 + i * 0.008);
const len = H * (0.005 + rng() * 0.006);
const xx = lx + gauss() * H * 0.003 * (1 + i * 0.4);
add(0.625 + i * 0.003, (c) => line(c, xx, yy, Math.PI / 2 + (rng() - 0.5) * 0.3, len, 1.2 + rng() * 1.2, i % 2 ? '#d8bd7c' : '#b89a55', 0.35 - i * 0.06));
}
// ---- the light, last, brightest accent of the piece --------------------
// the halo as one smooth radial breath — no rim, just light fading out
const haloCol = night
? ['232,218,168', '216,202,150', '190,178,132']
: ['246,206,150', '240,186,128', '214,158,108'];
add(0.845, (c) => {
const g = c.createRadialGradient(lightX, lightY, lightR * 0.9, lightX, lightY, lightR * haloK);
g.addColorStop(0, `rgba(${haloCol[0]},0.30)`);
g.addColorStop(0.35, `rgba(${haloCol[1]},0.16)`);
g.addColorStop(0.7, `rgba(${haloCol[2]},0.07)`);
g.addColorStop(1, `rgba(${haloCol[2]},0)`);
c.fillStyle = g;
c.beginPath();
c.arc(lightX, lightY, lightR * haloK, 0, 6.2832);
c.fill();
});
for (let i = 0; i < 22; i++) { // the halo: dashes circling the disc
const ang = (i / 22) * 6.2832 + rng() * 0.3;
const rr = lightR * (1.25 + rng() * 0.9);
const x0 = lightX + Math.cos(ang) * rr, y0 = lightY + Math.sin(ang) * rr;
const l = lightR * (0.25 + rng() * 0.5);
const wd = 1.4 + rng() * 1.6;
const a = 0.15 + rng() * 0.2;
add(0.86 + rng() * 0.05, (c) => line(c, x0, y0, ang, l, wd, '#e6d8a8', a));
}
for (let i = 0; i < 10; i++) { // pale strokes curling around the light
const ang = rng() * 6.2832;
const rr = lightR * (1.5 + rng() * 1);
const x = lightX + Math.cos(ang) * rr, y = lightY + Math.sin(ang) * rr * 0.85;
const ta = ang + 1.5708 + (rng() - 0.5) * 0.4;
const l = W * (0.015 + rng() * 0.03);
seg(0.87 + rng() * 0.05, x, y, x + Math.cos(ta) * l, y + Math.sin(ta) * l, pick(toneWarm.core), 0.12 + rng() * 0.14, 2 + rng() * 3);
}
const discCols = night
? ['#f2e6bd', '#f8eecb', '#fbf3d8', '#fefae8']
: ['#f6d9a0', '#f9dfa8', '#fceac0', '#fef4d8'];
add(0.9, (c) => dab(c, lightX, lightY, lightR, 1, 0, discCols[0], 0.75));
add(0.905, (c) => dab(c, lightX, lightY, lightR * 0.8, 1, 0, discCols[1], 0.85));
add(0.91, (c) => dab(c, lightX, lightY, lightR * 0.55, 1, 0, discCols[2], 0.95));
add(0.915, (c) => dab(c, lightX, lightY, lightR * 0.3, 1, 0, discCols[3], 1));
// the light's long shimmer on the water, a column of broken gold
for (let i = 0; i < 24; i++) {
const d = rng();
const xx = lightX + gauss() * W * 0.012 * (1 + d);
const yy = hz + H * 0.006 + d * waterH * 0.55;
const len = H * (0.008 + rng() * 0.016) * (1 + d);
const wd = 1.2 + rng() * 2.2;
const a = 0.4 - d * 0.22 + rng() * 0.12;
const col = rng() < 0.75 ? pick(golds) : '#e9e2c0';
const tilt = Math.PI / 2 + (rng() - 0.5) * 0.22;
add(0.92 + d * 0.04, (c) => line(c, xx, yy, tilt, len, wd, col, a));
}
// ---- weather riding through: rain streaks, snow dabs, drifting mist ----
// house weatherOverlay, stamped into quantized progress windows, each with
// its own dedicated stream so the closure replays identically. Overcast is
// painted by the palette and haze above: scene-api's WEATHER_RGB table has
// no overcast entry, so the overlay is left to the scenes that need streaks.
if (wx === 'rain' || wx === 'snow' || wx === 'mist') {
for (let i = 0; i < 36; i++) {
const p = 0.30 + i * 0.019;
const wr = mulberry32(0x77ea + i * 3571);
add(p, (c2) => weatherOverlay(c2, W, H, wr, c, p, p + 0.022));
}
}
// ---- final accents: the brightest notes, struck very last --------------
for (let i = 0; i < bigs.length; i++) {
const s = bigs[i];
add(0.96 + rng() * 0.03, (c) => dab(c, s.x, s.y, s.r * 0.3, 1, 0, '#fffdf0', 0.95));
}
for (let i = 0; i < 8; i++) { // re-struck shimmer in the light's column
const d = rng();
const xx = lightX + gauss() * W * 0.014;
const yy = hz + H * 0.01 + d * waterH * 0.5;
add(0.96 + rng() * 0.03, (c) => line(c, xx, yy, Math.PI / 2 + (rng() - 0.5) * 0.2, H * (0.006 + rng() * 0.01), 1 + rng() * 1.5, '#f5ead0', 0.4));
}
for (let i = 0; i < 6; i++) { // pale glints where the current bends
const u = rng();
const x = W * (0.08 + u * 0.84);
const y = hz * 0.5 + Math.sin(u * 5.2 + 1.2) * H * 0.085 + gauss() * H * 0.018;
add(0.955 + rng() * 0.04, (c) => dab(c, x, y, H * 0.003 * (0.6 + rng()), 0.6, 0, '#c3d4d8', 0.3));
}
strokes = list.sort((a, b) => a.p - b.p);
},
draw(ctx, W, H, _rng, from, to) {
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);
}
},
};
// Impression, soleil levant — hommage (Monet, Le Havre, 1872).
// Painted in broken colour: mist first, water building, sun and its trembling
// reflection arriving mid-work, boats and accents last. Orange vs blue-grey
// is the engine of the piece; no hard contour anywhere — edges are earned by
// layered low-alpha dabs, the way the painter earns them.
//
// Each run rolls its own conditions (scene-api.js): time of day, season,
// weather, sun/moon, horizon, composition dice — and the painter's HAND
// (brushes.handFor): the same motif realized empâto, glazed, dotted, dry or
// wet-on-wet, rolled free of the sky. Night turns it into a
// moonlit harbour, winter cools and thickens the mist, rain streaks the
// water, the dice move the disc, shift the horizon and thin the fleet.
// The structure — phases, reflection column, boats — survives; its
// realization varies. Same seed -> same conditions -> identical painting.
import {
mulberry32, makeConditions, skyGradient, weatherOverlay,
mix as mixHex, subtitle as subtitleFr,
} from '../scene-api.js';
import { handFor, handDab } from '../brushes.js';
const N = 3800; // stroke budget, quantized to progress for determinism
// --- conditions-derived state (fixed once per painting, in init) ------------
let HZ = 0.46; // horizon line
let SUN = { x: 0.42, y: 0.40, r: 0.05 }; // disc centre (fractions), r of H
let GAUGES = [0.06, 0.20, 0.66, 0.86]; // harbour vertical columns
let MISTY = 0; // 0 clear .. ~0.5 thick mist
let SUN_STR = 1; // dimmed by overcast/rain
let COND = null; // the rolled conditions themselves
let HAND = handFor(null); // the painter's hand (technique axis)
const pick = (r, pal) => pal[(r() * pal.length) | 0];
let SKY = [
[148, 157, 163], [161, 165, 164], [136, 146, 150],
[172, 170, 161], [127, 141, 142], [152, 158, 152],
];
let SKY_WARM = [183, 159, 132];
let SEA = [
[92, 107, 120], [74, 102, 104], [58, 66, 74],
[110, 121, 124], [86, 97, 109], [64, 84, 90], [52, 62, 72],
];
let SUN_C = [229, 117, 42];
let SUN_HOT = [244, 133, 50];
let HAZE = [176, 150, 120];
let CONTAM = [150, 110, 80]; // faint spill of the disc on the sea
let EMBER = [250, 150, 60]; // accent notes in the reflection
let VERT = [120, 132, 140]; // far mist-eaten verticals
const mix = (a, b, t) => [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
const jit = (r, c, amt = 24) => c.map((v) =>
Math.max(0, Math.min(255, Math.round(v + (r() - 0.5) * amt))));
function dab(ctx, x, y, len, ang, w, c, a) {
// every touch passes through the painter's hand: the default hand replays
// the plain capsule below verbatim, the technique hands re-realize it
handDab(ctx, null, HAND, {
x, y, len, ang, w, c, a,
plain: (g) => {
g.strokeStyle = `rgba(${c[0]},${c[1]},${c[2]},${a})`;
g.lineWidth = w;
g.lineCap = 'round';
const dx = (Math.cos(ang) * len) / 2, dy = (Math.sin(ang) * len) / 2;
g.beginPath();
g.moveTo(x - dx, y - dy);
g.lineTo(x + dx, y + dy);
g.stroke();
},
});
}
// --- phases ----------------------------------------------------------------
function skyStroke(ctx, W, H, r, u) {
if (u < 0.62) { // broad misty horizontal passes, warm breath near the sun
const k = u / 0.62;
const y = H * (0.03 + (HZ - 0.10) * Math.pow(k, 0.9)) + (r() - 0.5) * H * 0.05;
const x = (r() * 1.1 - 0.05) * W;
let c = pick(r, SKY);
const d = Math.hypot(x - SUN.x * W, y - SUN.y * H) / W;
if (d < 0.20 && r() < 0.7) c = mix(c, SKY_WARM, (1 - d / 0.20) * 0.8);
dab(ctx, x, y, W * (0.05 + r() * 0.13), (r() - 0.5) * 0.08, 8 + r() * 20,
c, 0.025 + r() * 0.05);
} else if (u < 0.88) { // farthest mist-eaten verticals, in loose clusters
if (r() < 0.55 + MISTY * 0.4) return; // mist eats most of them
const gx = GAUGES[(r() * 4) | 0];
const x = (gx + (r() + r() - 1) * 0.11) * W;
if (Math.abs(x - SUN.x * W) < W * 0.06) return; // the sun keeps its clearing
const top = H * (HZ - 0.04 - r() * 0.11), bot = H * (HZ - 0.005 + r() * 0.01);
dab(ctx, x, (top + bot) / 2, bot - top, -Math.PI / 2 + (r() - 0.5) * 0.06,
1.2 + r() * 2.2, VERT, 0.015 + r() * 0.03);
} else { // pale mist band settling on the horizon
dab(ctx, r() * W, H * (HZ - 0.02 - r() * 0.05), W * (0.06 + r() * 0.14),
(r() - 0.5) * 0.05, 10 + r() * 16, HAZE, 0.03 + r() * 0.04 + MISTY * 0.05);
}
}
function waterStroke(ctx, W, H, r, u) {
const y = H * (HZ + (1 - HZ) * Math.pow(u, 1.35) + (r() - 0.5) * 0.03);
const x = (r() * 1.08 - 0.04) * W;
const len = W * (0.018 + 0.075 * Math.pow(u, 1.1)) * (0.55 + r() * 0.9);
const ang = (r() - 0.5) * 0.14;
const w = (1.6 + 5.5 * u) * (0.7 + r() * 0.7);
const c = pick(r, SEA);
const a = 0.06 + r() * 0.10 + u * 0.02;
if (r() < 0.10) { // dry-brush skip: broken stroke
dab(ctx, x, y, len * 0.4, ang, w * 0.8, c, a * 0.8);
dab(ctx, x + len * 0.62, y + (r() - 0.5) * 4, len * 0.3, ang, w * 0.7, c, a * 0.7);
} else {
dab(ctx, x, y, len, ang, w, c, a);
}
if (WX() === 'rain' && r() < 0.12) { // rain stippling the surface
dab(ctx, x, y - H * 0.004, H * (0.006 + r() * 0.012),
-Math.PI / 2 + (r() - 0.5) * 0.08, 0.8 + r(), [200, 210, 218], 0.04 + r() * 0.05);
}
if (Math.abs(x - SUN.x * W) < W * 0.05 && r() < 0.22) { // faint contamination
dab(ctx, x, y, len * 0.5, ang, w * 0.8, CONTAM, 0.04 + r() * 0.05);
}
}
// shorthand: stroke functions read the weather without threading conditions
const WX = () => (COND ? COND.weather : 'clear');
function harbourStroke(ctx, W, H, r) { // near verticals: masts, stacks, cranes
if (r() < 0.30 + MISTY * 0.35 + (3 - COND.motifCount) * 0.08) return;
const gx = GAUGES[(r() * 4) | 0];
const x = (gx + (r() + r() - 1) * 0.13) * W;
if (Math.abs(x - SUN.x * W) < W * 0.07) return; // keep the disc's clearing
const depth = r(); // nearer shapes are darker and taller
const ink = mix([112, 122, 128], [82, 92, 100], depth);
const a = (0.04 + depth * 0.06) * (0.6 + r() * 0.6);
const kind = r();
const gy = H * (HZ - 0.004 + r() * 0.012);
if (kind < 0.42) { // masts
const h = H * (0.04 + r() * 0.14);
dab(ctx, x, gy - h / 2, h, -Math.PI / 2 + (r() - 0.5) * 0.05,
1.4 + r() * 2.4, ink, a);
} else if (kind < 0.62) { // smokestacks + drifting smoke
const h = H * (0.05 + r() * 0.07);
dab(ctx, x, gy - h / 2, h, -Math.PI / 2, 3 + r() * 4, ink, a);
dab(ctx, x + W * (0.008 + r() * 0.02), gy - h - H * 0.008, W * (0.02 + r() * 0.05),
(r() - 0.5) * 0.1, 4 + r() * 6, HAZE, 0.03 + r() * 0.04);
} else if (kind < 0.82) { // crane: post + jib
const h = H * (0.06 + r() * 0.11);
dab(ctx, x, gy - h / 2, h, -Math.PI / 2, 1.6 + r() * 2, ink, a);
const jl = W * (0.015 + r() * 0.04);
dab(ctx, x + jl * 0.3, gy - h * (0.75 + r() * 0.15), jl,
(r() < 0.5 ? 0 : Math.PI) + (r() - 0.5) * 0.3, 1.4, ink, a * 0.9);
} else { // quay line
dab(ctx, x, gy + (r() - 0.5) * H * 0.01, W * (0.03 + r() * 0.06),
(r() - 0.5) * 0.03, 2 + r() * 3, ink, a);
}
}
function sunStroke(ctx, W, H, r, u) {
const sx = SUN.x * W, sy = SUN.y * H, R = H * SUN.r;
if (u < 0.22) { // hazy halo
const rad = R * (1.05 + r() * 1.0), ang = r() * 7;
dab(ctx, sx + Math.cos(ang) * rad, sy + Math.sin(ang) * rad,
5 + r() * 9, ang, 6 + r() * 10, HAZE, (0.015 + r() * 0.03) * (0.4 + 0.6 * SUN_STR));
} else { // the disc, dab by dab, slightly soft at the rim
const rad = R * Math.sqrt(r()) * 0.98, ang = r() * 7;
const edge = rad / R;
const c = edge > 0.85 ? mix(SUN_C, [0, 0, 0], 0.03) : (r() < (edge < 0.4 ? 0.45 : 0.25) ? SUN_HOT : SUN_C);
dab(ctx, sx + Math.cos(ang) * rad, sy + Math.sin(ang) * rad,
3 + r() * 6, r() * 7, 2.5 + r() * 4, jit(r, c),
(0.12 + r() * 0.16) * (0.45 + 0.55 * SUN_STR));
}
}
function reflectionStroke(ctx, W, H, r, u) {
const y = H * (HZ + 0.015 + (0.90 - HZ) * Math.pow(u, 1.12) + (r() - 0.5) * 0.012);
const wig = W * (0.013 * Math.sin((y / H) * 9.2 + 2.0) + 0.007 * Math.sin((y / H) * 24.0));
const x = SUN.x * W + wig + (r() + r() + r() - 1.5) * W * 0.011;
const fade = Math.max(0, 1 - (y / H - HZ) / (0.93 - HZ));
if (r() < 0.12) { // the water keeps breaking through
dab(ctx, x, y, W * (0.02 + r() * 0.05), (r() - 0.5) * 0.1,
2.5 + r() * 3, pick(r, SEA), 0.07 + r() * 0.07);
return;
}
const c = jit(r, r() < 0.25 ? SUN_HOT : SUN_C);
dab(ctx, x, y, W * (0.006 + r() * 0.017), (r() - 0.5) * 0.2,
2.2 + r() * 3.6, c, (0.10 + r() * 0.16) * (0.4 + 0.6 * fade) * (0.5 + 0.5 * SUN_STR));
}
// --- boats -----------------------------------------------------------------
let BOATS = [
{ cx: 0.52, cy: 0.705, L: 0.145, tone: [40, 48, 58], mist: 0.15, mx: 0.02 },
{ cx: 0.335, cy: 0.885, L: 0.115, tone: [32, 39, 48], mist: 0.10, mx: -0.04 },
{ cx: 0.272, cy: 0.615, L: 0.062, tone: [70, 80, 90], mist: 0.75, mx: 0.0 },
];
function hullPath(ctx, r, cx, cy, L, hh) {
const j = (amt) => (r() - 0.5) * amt;
ctx.beginPath();
ctx.moveTo(cx - L / 2 + j(L * 0.05), cy - hh * (0.35 + j(0.3)));
ctx.quadraticCurveTo(cx - L * 0.18 + j(L * 0.05), cy + hh * (1.0 + j(0.35)),
cx + L * 0.05 + j(L * 0.06), cy + hh * (1.02 + j(0.3)));
ctx.quadraticCurveTo(cx + L * 0.34 + j(L * 0.05), cy + hh * (0.72 + j(0.3)),
cx + L / 2 + j(L * 0.05), cy - hh * (0.42 + j(0.35)));
ctx.quadraticCurveTo(cx + j(L * 0.08), cy - hh * (0.10 + j(0.5)),
cx - L / 2 + j(L * 0.05), cy - hh * (0.35 + j(0.3)));
ctx.closePath();
}
function boatStroke(ctx, W, H, r, v, b) {
const cx = b.cx * W, cy = b.cy * H, L = b.L * W, hh = L * 0.14;
const dim = 1 - b.mist * 0.55;
const sea = pick(r, SEA);
if (v < 0.55) { // hull mass: blurred translucent layers, waterline dissolving
ctx.save();
ctx.filter = `blur(${Math.max(2, L * 0.035).toFixed(1)}px)`;
ctx.translate((r() - 0.5) * L * 0.05, (r() - 0.5) * hh * 0.30);
hullPath(ctx, r, cx, cy, L, hh);
ctx.fillStyle = `rgba(${b.tone[0]},${b.tone[1]},${b.tone[2]},${(0.045 + r() * 0.025) * dim})`;
ctx.fill();
ctx.restore();
if (r() < 0.35) { // dabs scattered along the waterline: hull melts into sea
const s = r() * 2 - 1;
dab(ctx, cx + s * L * 0.48, cy + hh * (0.85 + r() * 0.35),
L * 0.05 * (0.5 + r()), (r() - 0.5) * 0.3, 1.8 + r() * 2.6,
r() < 0.5 ? b.tone : sea, 0.07 + r() * 0.07);
}
} else if (v < 0.70) { // mast and rigging, barely suggested
if (r() < 0.55) {
const mx = cx + (b.mx + (r() - 0.5) * 0.03) * L, mh = L * (0.32 + r() * 0.26);
ctx.save();
ctx.filter = 'blur(1px)';
dab(ctx, mx, cy - hh * 0.3 - mh / 2, mh, -Math.PI / 2 + (r() - 0.5) * 0.05,
1.0 + r() * 1.2, b.tone, (0.18 + r() * 0.14) * dim);
ctx.restore();
if (r() < 0.35) dab(ctx, mx, cy - hh * 0.3 - mh * 0.6, mh * 0.8,
-Math.PI / 2 + (r() < 0.5 ? 0.5 : -0.5), 0.8, b.tone, 0.08 * dim);
}
} else if (v < 0.85) { // figure + pale sheer highlight
if (r() < 0.45) {
ctx.save();
ctx.filter = 'blur(1.5px)';
ctx.fillStyle = `rgba(${b.tone[0]},${b.tone[1]},${b.tone[2]},${0.26 * dim})`;
ctx.beginPath();
ctx.ellipse(cx + L * (0.04 + r() * 0.08), cy - hh * (0.30 + r() * 0.2),
L * 0.026, L * 0.045, (r() - 0.5) * 0.3, 0, 7);
ctx.fill();
ctx.restore();
} else {
ctx.save();
ctx.filter = 'blur(2px)';
ctx.strokeStyle = `rgba(172,178,180,${(0.02 + r() * 0.03) * dim})`;
ctx.lineWidth = 1.5 + r() * 2;
const seg = r() * 0.6 + 0.2; // broken arc, never a full contour
const a0 = -0.5 + seg, a1 = 0.5 + seg;
ctx.beginPath();
ctx.moveTo(cx + L * a0, cy - hh * (0.35 + (r() - 0.5) * 0.5));
ctx.quadraticCurveTo(cx + L * (seg - 0.1) + (r() - 0.5) * L * 0.08,
cy - hh * (0.08 + (r() - 0.5) * 0.5),
cx + L * a1, cy - hh * (0.42 + (r() - 0.5) * 0.5));
ctx.stroke();
ctx.restore();
}
} else { // dark smudge the water holds under the hull
dab(ctx, cx + (r() - 0.5) * L * 0.9, cy + hh * (0.9 + r() * 0.5),
L * 0.05 * (0.5 + r()), -Math.PI / 2 + (r() - 0.5) * 0.4,
1.5 + r() * 2, b.tone, (0.05 + r() * 0.05) * dim);
}
}
function accentStroke(ctx, W, H, r) {
const k = r();
if (k < 0.35) { // vivid life high in the reflection
const y = H * (HZ + 0.02 + 0.16 * r());
const wig = W * (0.013 * Math.sin((y / H) * 9.2 + 2.0) + 0.007 * Math.sin((y / H) * 24.0));
dab(ctx, SUN.x * W + wig + (r() - 0.5) * W * 0.01, y,
W * (0.005 + r() * 0.012), (r() - 0.5) * 0.15, 2.5 + r() * 3,
r() < 0.5 ? SUN_HOT : EMBER, 0.22 + r() * 0.2);
} else if (k < 0.6) { // pale sky caught on the water
dab(ctx, r() * W, H * (HZ + 0.06 + r() * (0.92 - HZ - 0.10)), W * (0.03 + r() * 0.07),
(r() - 0.5) * 0.06, 1.5 + r() * 2.5, HAZE, 0.05 + r() * 0.06);
} else if (k < 0.8) { // deep charcoal accents in the lower water
dab(ctx, r() * W, H * (0.78 + r() * 0.18), W * (0.02 + r() * 0.04),
(r() - 0.5) * 0.08, 2 + r() * 3, [44, 52, 60], 0.08 + r() * 0.08);
} else { // one last ember at the disc's heart
const rad = H * SUN.r * Math.sqrt(r()) * 0.5, ang = r() * 7;
dab(ctx, SUN.x * W + Math.cos(ang) * rad, SUN.y * H + Math.sin(ang) * rad,
3 + r() * 4, r() * 7, 3 + r() * 3, jit(r, SUN_HOT), 0.18 + r() * 0.15);
}
}
// --- the scene -------------------------------------------------------------
export default {
id: 'soleil',
title: 'Impression, soleil levant — hommage',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Le Havre, ' + subtitleFr(c),
init(ctx, W, H, _rng, conditions) {
const c = conditions || makeConditions(mulberry32(1872));
COND = c;
const night = c.timeOfDay === 'night';
const lowSun = c.timeOfDay === 'dawn' || c.timeOfDay === 'dusk' || c.timeOfDay === 'goldenHour';
MISTY = { clear: 0, mist: 0.35, overcast: 0.25, rain: 0.15, snow: 0.3 }[c.weather]
+ (c.season === 'winter' ? 0.2 : 0);
SUN_STR = c.sunStrength;
HAND = handFor(c.technique);
HZ = c.horizonY;
SUN = { x: c.sunX, y: c.sunY, r: night ? 0.034 : lowSun ? 0.058 : 0.042 };
GAUGES = c.motifSide === 'right' ? [0.14, 0.34, 0.80, 0.94] : [0.06, 0.20, 0.66, 0.86];
// dab palettes sampled straight off the rolled gradient
const rgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const stops = skyGradient(c);
SKY = [
rgb(stops[0].color), rgb(stops[1].color), rgb(stops[2].color),
rgb(mixHex(stops[1].color, stops[2].color, 0.5)),
rgb(mixHex(stops[0].color, stops[1].color, 0.6)),
rgb(mixHex(stops[2].color, '#f0e8d4', 0.22)),
];
SKY_WARM = night ? rgb(mixHex(stops[2].color, '#cdd8ea', 0.5)) : [183, 159, 132];
const hzC = stops[3].color, deep = stops[4].color;
SEA = [
rgb(hzC), rgb(mixHex(hzC, deep, 0.3)), rgb(mixHex(hzC, deep, 0.55)), rgb(deep),
rgb(mixHex(deep, '#101822', 0.22)), rgb(mixHex(hzC, deep, 0.15)),
];
if (c.season === 'winter') SEA = SEA.map((v) => mix(v, [190, 200, 212], 0.18));
SUN_C = night ? [214, 224, 238] : lowSun ? [232, 118, 46] : [236, 158, 78];
SUN_HOT = night ? [238, 244, 250] : [246, 140, 56];
HAZE = night ? rgb(mixHex(stops[2].color, '#dfe6f0', 0.4)) : [176, 150, 120];
CONTAM = night ? [170, 185, 205] : [150, 110, 80];
EMBER = night ? [225, 235, 250] : [250, 150, 60];
VERT = night ? [72, 82, 98] : [120, 132, 140];
// boats: the dice say how many survive, and which side of the column
const side = c.motifSide === 'right' ? (x) => 1 - x : (x) => x;
const flip = c.motifSide === 'right' ? -1 : 1;
BOATS = [
{ cx: side(0.52), cy: 0.705, L: 0.145, tone: [40, 48, 58], mist: 0.15, mx: flip * 0.02 },
{ cx: side(0.335), cy: 0.885, L: 0.115, tone: [32, 39, 48], mist: 0.10, mx: flip * -0.04 },
{ cx: side(0.272), cy: 0.615, L: 0.062, tone: [70, 80, 90], mist: 0.75, mx: 0.0 },
].slice(0, c.motifCount);
// ground wash straight from the rolled sky
const g = ctx.createLinearGradient(0, 0, 0, H);
for (const s of stops) g.addColorStop(s.at, s.color);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
if (c.sunVisible || c.moonVisible) {
const hc = night ? '190,205,230' : '196,158,116';
const rg = ctx.createRadialGradient(SUN.x * W, SUN.y * H, 0, SUN.x * W, SUN.y * H, W * 0.16);
rg.addColorStop(0, `rgba(${hc},${0.20 * SUN_STR})`);
rg.addColorStop(1, `rgba(${hc},0)`);
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
}
},
draw(ctx, W, H, _rng, from, to, conditions) {
const c = conditions || COND;
const lo = Math.floor(from * N), hi = Math.ceil(to * N);
for (let i = lo; i < hi; i++) {
if (i < 0 || i >= N) continue;
const r = mulberry32(90210 + i * 7919);
const t = i / N;
if (t < 0.20) skyStroke(ctx, W, H, r, t / 0.20);
else if (t < 0.44) waterStroke(ctx, W, H, r, (t - 0.20) / 0.24);
else if (t < 0.52) harbourStroke(ctx, W, H, r);
else if (t < 0.62) sunStroke(ctx, W, H, r, (t - 0.52) / 0.10);
else if (t < 0.82) reflectionStroke(ctx, W, H, r, (t - 0.62) / 0.20);
else {
const v = (t - 0.82) / 0.18;
const per = 0.78 / BOATS.length; // boats share the first 78%, accents the rest
const bi = Math.min(BOATS.length - 1, Math.floor(v / per));
if (v >= per * BOATS.length) accentStroke(ctx, W, H, r);
else boatStroke(ctx, W, H, r, (v - bi * per) / per, BOATS[bi]);
}
if (c.weather !== 'clear' && i % 15 === 7) { // sparse quantized weather touches
weatherOverlay(ctx, W, H, r, c, t, (i + 1) / N);
}
}
},
};
// La Vallée de l'Estaque — a homage to Cézanne's and Pissarro's valleys.
// The valley is built as PLANES, not lines: a distant mountain mass of
// faceted knife slabs (the constructive stroke), middle fields as a patchwork
// of ochre and green planes laid at contrasting angles on the one-point
// ground plane, a river winding from the near bank up to the vanishing
// region, poplars standing like metronomes on their depth line. Cézanne
// built; Pissarro breathed: mist bands at dawn, warm rim light at golden
// hour, snowfields in winter, rust in autumn, rain streaks. Planes persist;
// light, colour and weather change.
//
// Contract: identical to scenes/coquelicots.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.
// Determinism: brush micro-jitter and weather touches run on per-stroke
// mulberry32 streams seeded from init's rng, so nothing depends on the
// draw()-time rng the frame hands in.
import {
clamp01, mulberry32, makeConditions, groundPalette, weatherOverlay,
mix as mixHex, subtitle as subtitleFr,
} from '../scene-api.js';
import {
Horizon, aerialFade, brokenColor, temperatureShift,
dab, roundDab, fanDab, filbert, knife, drybrush, stipple, rigger, stroke,
} from '../brushes.js';
let strokes = null;
export default {
id: 'vallee',
title: "La Vallée de l'Estaque",
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'vallon, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1879));
const season = cond.season, weather = cond.weather;
const winter = season === 'winter', autumn = season === 'autumn';
const night = cond.timeOfDay === 'night', dusk = cond.timeOfDay === 'dusk';
const dawn = cond.timeOfDay === 'dawn';
const golden = cond.timeOfDay === 'goldenHour';
const mirror = cond.motifSide === 'right';
const MX = (xn) => (mirror ? 1 - xn : xn);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.fillStyle = night ? mixHex('#e9e2cf', '#232c47', 0.55)
: winter ? '#e6ebee' : '#e9e2cf'; // primed canvas
ctx.fillRect(0, 0, W, H);
const list = [];
const add = (p, fn) => list.push({ p, fn });
// -- small deterministic helpers -------------------------------------
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const wpick = (pairs) => {
let t = rng() * pairs.reduce((s, q) => s + q[1], 0);
for (const q of pairs) { t -= q[1]; if (t <= 0) return q[0]; }
return pairs[pairs.length - 1][0];
};
// one dedicated jitter stream per stroke: consumed lazily, exactly once.
// Weather touches ride on ~18% of strokes (one touch each) so rain, snow
// and mist stay airy instead of flooding the canvas.
const stamped = (p, fn) => {
const jr = mulberry32((0xA71E1 + Math.floor(rng() * 0xffffff) * 613) >>> 0);
const wr = (cond.weather !== 'clear' && rng() < 0.18)
? 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; scene keeps painting */ }
}
});
};
// -- palettes: season, hour, weather lean on every colour --------------
const gp = groundPalette(season, cond.warmth);
const nightDim = (h) => night ? mixHex(h, '#39466b', 0.48)
: dusk ? mixHex(h, '#7c6470', 0.22)
: dawn ? mixHex(h, '#c98a74', 0.10)
: h;
const overcastFlat = (h) => (weather === 'overcast' || weather === 'rain')
? mixHex(h, weather === 'rain' ? '#8a96a4' : '#aeb6ba', 0.22) : h;
const tint = (h) => overcastFlat(nightDim(h));
const lightCols = gp.light.map(tint), midCols = gp.mid.map(tint), shadeCols = gp.shade.map(tint);
// the complementary engine: earth ochres against fresh greens
const ochres = (autumn ? ['#c98f3e', '#b57a34', '#d8a852']
: winter ? ['#c9cdd2', '#bcc2c8', '#d5dade']
: ['#c9a04c', '#b8903e', '#d4b264']).map(tint);
const rusts = ['#a4582c', '#8f4a26', '#b96a34'].map(tint);
const skyTint = (h) => {
let out = h;
if (weather === 'mist') out = mixHex(out, '#dfe4e2', 0.4);
else if (weather === 'overcast') out = mixHex(out, '#b6bcc0', 0.5);
else if (weather === 'rain') out = mixHex(out, '#8d9aa8', 0.45);
else if (weather === 'snow') out = mixHex(out, '#e9eef3', 0.45);
if (night) out = mixHex(mixHex(out, '#252f4c', 0.62), '#0c1220', 0.3);
else if (dusk) out = mixHex(out, '#b07a62', 0.3);
else if (dawn) out = mixHex(out, '#d99a78', 0.2);
else if (golden) out = mixHex(out, '#eec69a', 0.3);
return out;
};
// -- geometry: the one-point ground plane, the dice, the river ---------
const vx = MX(0.40 + rng() * 0.20); // vanishing x, 0.40-0.60
const hzY = H * cond.horizonY;
const horizon = new Horizon(hzY, W * vx, 1.6, H);
const gpAt = (xn, d) => horizon.computeGroundPlane(xn * W, d, H);
// river: meanders from its near mouth toward the vanishing region
const mouth = MX(0.30 + rng() * 0.14); // near-reference x at depth 1
const meanderAmp = 0.10 + rng() * 0.10;
const meanderPhase = rng() * Math.PI * 2;
const RN = 44;
const river = [];
for (let k = 0; k <= RN; k++) {
const d = 1 - Math.pow(k / RN, 1.25) * 0.87; // 1 -> 0.13
const pull = 1 - k / RN;
const xn = mouth + (vx - mouth) * (1 - pull) + Math.sin(meanderPhase + k * 0.55) * meanderAmp * pull;
const g = horizon.computeGroundPlane(xn * W, d, H);
river.push({ d, x: g.x, y: g.y, s: g.scale, halfW: Math.max(1.5, W * 0.13 * g.scale) });
}
const riverAt = (d) => {
const dd = clamp01((1 - d) / 0.87) * RN;
const i = Math.max(0, Math.min(RN - 1, Math.floor(dd)));
const u = dd - i, a = river[i], b = river[i + 1];
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u, s: a.s + (b.s - a.s) * u, halfW: a.halfW + (b.halfW - a.halfW) * u };
};
// sunlit pools wandering over the middle fields
const pools = [[0.30, 0.45, 0.20, 0.10], [0.72, 0.55, 0.18, 0.09], [0.5, 0.75, 0.22, 0.10]]
.map(([x, y, sx, sy]) => [MX(x), y, sx, sy]);
const lit = (x, y) => {
let v = 0;
for (const L of pools) {
const dx = (x / W - L[0]) / L[1 + 1] / 1, dy = (y / H - L[1]) / L[3];
v += Math.exp(-(dx * dx + dy * dy));
}
return v;
};
// ---- sky: broad Cézanne hatching, no smooth gradients -----------------
for (let i = 0; i < 640; i++) {
const x = -W * 0.03 + rng() * W * 1.04;
const yn = Math.pow(rng(), 0.85);
const y = H * 0.012 + yn * Math.max(4, hzY - H * 0.05);
const band = yn; // 0 high, 1 near horizon
const near = band > 0.72;
const col = skyTint(near
? wpick([['#f1ead3', 5], ['#ecdFbe', 3], ['#d5cde0', 2], ['#e3d8c2', 2]])
: wpick([['#a9c2cf', 5], ['#b7cbd6', 4], ['#9db8c8', 3], ['#8fa8c2', 2], ['#d5cde0', 1]]));
// the hatch steepens with altitude — Cézanne's parallel construction
const hatch = -0.10 + (1 - band) * 0.34 + (rng() - 0.5) * 0.08;
const len = W * (0.05 + rng() * 0.13);
const wd = H * (0.006 + rng() * 0.016) * (0.7 + band * 0.7);
const a = 0.14 + rng() * 0.26;
stamped(0.01 + 0.07 * band + rng() * 0.012, (c, jr) => {
stroke(c, jr, {
path: { x0: x, y0: y, x1: x + Math.cos(hatch) * len, y1: y + Math.sin(hatch) * len },
brush: 'flat', width: wd, alpha: a, color: col, taper: 'none', spacing: 0.3,
});
});
}
// ---- sun or moon: a pale disc built from dabs, never a gradient -------
const sunPx = cond.sunX * W, sunPy = Math.max(H * 0.04, cond.sunY * H);
if (cond.sunVisible) {
const warmCore = dawn ? '#f2c494' : golden ? '#f4c882' : dusk ? '#e8a878' : '#f6e9c4';
for (let i = 0; i < 34; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.7) * H * 0.020;
const x = sunPx + Math.cos(a) * r, y = sunPy + Math.sin(a) * r;
const col = i < 18 ? warmCore : mixHex(warmCore, '#ffffff', 0.3);
stamped(0.085 + rng() * 0.02, (c) => dab(c, x, y, H * (0.006 + rng() * 0.008), col, 0.30 + rng() * 0.30));
}
for (let i = 0; i < 16; i++) { // halo notes, loose
const a = rng() * 6.2832, r = H * (0.03 + rng() * 0.05);
stamped(0.09 + rng() * 0.02, (c) => dab(c, sunPx + Math.cos(a) * r, sunPy + Math.sin(a) * r,
H * (0.012 + rng() * 0.02), mixHex(warmCore, skyTint('#cfd3d8'), 0.5), 0.06 + rng() * 0.08));
}
} else {
for (let i = 0; i < 22; i++) { // moon: small, cold, quiet
const a = rng() * 6.2832, r = Math.pow(rng(), 0.7) * H * 0.014;
stamped(0.085 + rng() * 0.02, (c) => dab(c, sunPx + Math.cos(a) * r, sunPy + Math.sin(a) * r,
H * (0.004 + rng() * 0.006), i < 12 ? '#e8e6da' : '#f4f2e6', 0.25 + rng() * 0.3));
}
}
// ---- cloud masses: flat watery shapes, layered slabs ------------------
const nClouds = 3 + Math.floor(rng() * 3);
for (let ci = 0; ci < nClouds; ci++) {
const cx = MX(0.12 + rng() * 0.76), cy = 0.06 + rng() * 0.52;
const sx = 0.07 + rng() * 0.10, sy = 0.016 + rng() * 0.022;
const n = 26 + Math.floor(rng() * 22);
const baseCols = night
? ['#3a4a66', '#2e3c56', '#46587a']
: pick([
['#f6f0dc', '#efe6cf', '#d9cfe3', '#cfc3dc'],
['#cfd3dc', '#b9c2cc', '#e3ddd0', '#a8b2bf'],
['#f0d9b8', '#e8c9a0', '#d9b48f', '#f6ead4'],
]);
for (let i = 0; i < n; i++) {
const x = (cx + gauss() * sx) * W;
const y = (cy + gauss() * sy) * H;
const col = skyTint(baseCols[Math.floor(rng() * baseCols.length)]);
const ang = (rng() - 0.5) * 0.10;
const len = W * (0.03 + rng() * 0.075);
stamped(0.10 + rng() * 0.10, (c, jr) => {
if (rngJ(jr) < 0.55) {
knife(c, jr, { x, y, size: len * 0.5, color: col, alpha: 0.10 + rngJ(jr) * 0.16, angle: ang, squash: 0.30 });
} else {
fanDab(c, jr, { x, y, size: len * 0.35, color: col, alpha: 0.10 + rngJ(jr) * 0.14, angle: ang, squash: 0.35, spread: 0.8 });
}
});
}
}
// ---- the distant mountain mass: faceted knife slabs -------------------
const peak1x = MX(0.30 + rng() * 0.16), peak2x = MX(0.68 + rng() * 0.14);
const peak1h = H * (0.10 + rng() * 0.09), peak2h = H * (0.05 + rng() * 0.055);
const baseY = hzY - H * 0.004;
const shoulder = 0.30 + rng() * 0.18; // how far the flank reaches
const ridgePts = [
[-0.06, 0], [peak1x - shoulder, peak1h * 0.28], [peak1x, peak1h],
[peak1x + (peak2x - peak1x) * 0.5, peak1h * (0.30 + rng() * 0.15)],
[peak2x, peak2h], [peak2x + 0.16, peak2h * 0.3], [1.06, 0],
].map(([xn, h]) => [xn * W, baseY - h]);
const ridgeY = (x) => {
for (let i = 0; i < ridgePts.length - 1; i++) {
const [x0, y0] = ridgePts[i], [x1, y1] = ridgePts[i + 1];
if (x <= x1 || i === ridgePts.length - 2) {
if (x <= x1) return y0 + (y1 - y0) * clamp01((x - x0) / Math.max(1e-3, x1 - x0));
}
}
return baseY;
};
const hazer = skyTint('#cfd3d8');
const fade = aerialFade(0.10);
const mFade = Math.max(0.55, fade.alpha); // the mass must stay present
// local ridge slope -> strata follow the crest, clamped near-horizontal
const e = W * 0.01;
const ridgeSlope = (x) => {
const yA = ridgeY(Math.max(-0.05 * W, x - e)), yB = ridgeY(Math.min(1.05 * W, x + e));
return (yB - yA) / Math.max(1, (Math.min(1.05 * W, x + e) - Math.max(-0.05 * W, x - e)));
};
for (let i = 0; i < 300; i++) {
const xn = -0.02 + rng() * 1.04;
const x = xn * W;
const top = ridgeY(x);
if (top >= baseY - 2) continue;
const upF = Math.pow(rng(), 1.25); // 0 at base, 1 at ridge
const y = baseY - upF * (baseY - top);
const ang = Math.max(-0.55, Math.min(0.55, Math.atan(ridgeSlope(x)))) + (rng() - 0.5) * 0.30; // strata along the crest
const px = Math.abs(x - peak1x * W) < Math.abs(x - peak2x * W) ? peak1x * W : peak2x * W;
const sunSide = (px - x) * (sunPx - x) > 0 ? 1 : -1; // same side as the sun?
let col = wpick([['#7a6f9e', 5], ['#6a5f92', 4], ['#8b7fae', 3], ['#5d5488', 3]]);
col = mixHex(col, sunSide > 0 ? '#d8a070' : '#4a4470',
(sunSide > 0 ? 0.20 : 0.30) * (0.4 + cond.warmth * 0.8) * (0.35 + 0.65 * cond.sunStrength));
col = mixHex(col, hazer, 0.55 * fade.desat + upF * 0.22 + (weather === 'mist' ? 0.18 : 0));
if (winter) col = mixHex(col, '#eef2f5', Math.pow(upF, 1.4) * 0.75);
if (autumn && upF < 0.3) col = mixHex(col, '#8a6a52', 0.25);
const size = W * (0.045 + rng() * 0.055) * (0.75 + upF * 0.45);
const a = (0.30 + rng() * 0.22) * mFade * (cond.sunStrength * 0.5 + 0.5);
stamped(0.21 + 0.10 * (1 - upF) + rng() * 0.012, (c, jr) => {
knife(c, jr, { x, y, size, color: col, alpha: a, angle: ang, squash: 0.30 });
});
}
// facet accents over the mass: the constructive stroke, light against dark
for (let i = 0; i < 170; i++) {
const xn = -0.02 + rng() * 1.04;
const x = xn * W;
const top = ridgeY(x);
if (top >= baseY - 2) continue;
const upF = Math.pow(rng(), 1.1);
const y = baseY - upF * (baseY - top);
const px = Math.abs(x - peak1x * W) < Math.abs(x - peak2x * W) ? peak1x * W : peak2x * W;
const sunSide = (px - x) * (sunPx - x) > 0 ? 1 : -1;
let col = sunSide > 0 ? wpick([['#a896c0', 4], ['#c0a8b4', 3], ['#9884b4', 3]])
: wpick([['#544a80', 4], ['#4a4270', 3], ['#5e5490', 3]]);
col = mixHex(col, hazer, 0.4 * fade.desat + upF * 0.20);
if (winter) col = mixHex(col, '#f2f6f9', Math.pow(upF, 1.3) * 0.8);
const size = W * (0.02 + rng() * 0.035);
const a = (0.28 + rng() * 0.26) * mFade;
stamped(0.26 + 0.06 * (1 - upF) + rng() * 0.012, (c, jr) => {
knife(c, jr, { x, y, size, color: col, alpha: a, angle: Math.max(-0.55, Math.min(0.55, Math.atan(ridgeSlope(x)))) + (rngJ(jr) - 0.5) * 0.5, squash: 0.26 });
});
}
// far ridge line behind, barely there
for (let i = 0; i < 60; i++) {
const xn = -0.02 + rng() * 1.04;
const x = xn * W;
const y = hzY - H * (0.005 + rng() * 0.028) - Math.abs(gauss()) * H * 0.008;
stamped(0.205 + rng() * 0.01, (c, jr) => {
knife(c, jr, { x, y, size: W * (0.02 + rng() * 0.04), color: mixHex(hazer, '#8b7fae', 0.4),
alpha: 0.10 + rngJ(jr) * 0.10, angle: (rngJ(jr) - 0.5) * 0.2, squash: 0.25 });
});
}
// ridge accents: the constructive stroke along the crest
for (let i = 0; i < 30; i++) {
const seg = 1 + Math.floor(rng() * (ridgePts.length - 2));
const [x0, y0] = ridgePts[seg], [x1, y1] = ridgePts[seg + 1];
const u = rng();
const x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u + (rng() - 0.5) * 3;
const rim = golden || dawn ? mixHex('#f0c080', hazer, 0.3) : mixHex('#cfd3e2', hazer, 0.5);
stamped(0.315 + rng() * 0.02, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + (x1 - x0) * 0.04, y1: y + (y1 - y0) * 0.04 },
brush: 'round', width: 1.6 + rngJ(jr) * 2.4, color: rim, alpha: 0.32 + rngJ(jr) * 0.22, taper: 'both' });
});
}
// ---- middle fields: the patchwork of planes at contrasting angles -----
// rows of quads in (depth, nearX) space; each patch carries ONE dominant
// stroke angle and its own field type; neighbours contrast; Cézanne's
// constructive plane, Pissarro's furrows.
const fieldType = () => wpick([
['ochre', 4], ['green', 5], ['dark', 3], ['pale', 3], ['rust', autumn ? 4 : 1], ['violet', 1],
]);
const fieldCols = (type, g) => {
const hazeMix = (h) => mixHex(tint(h), hazer, (1 - g.scale) * 0.30);
switch (type) {
case 'ochre': return [hazeMix(pick(ochres)), hazeMix(mixHex(ochres[0], '#e0c078', 0.4)), hazeMix(mixHex(ochres[1], midCols[0], 0.3))];
case 'green': return [hazeMix(pick(lightCols)), hazeMix(pick(midCols)), hazeMix(mixHex(midCols[0], '#e8e0b0', 0.3))];
case 'dark': return [hazeMix(pick(shadeCols)), hazeMix(mixHex(shadeCols[0], '#3a4a30', 0.4)), hazeMix(pick(midCols))];
case 'pale': return [hazeMix(mixHex(lightCols[0], '#efe6cc', 0.6)), hazeMix(mixHex(ochres[2], '#f0e8d0', 0.5)), hazeMix(pick(lightCols))];
case 'rust': return autumn
? [hazeMix(pick(rusts)), hazeMix(mixHex(rusts[0], ochres[0], 0.4)), hazeMix(mixHex(rusts[2], '#7c4a28', 0.3))]
: [hazeMix(mixHex(rusts[0], ochres[0], 0.55)), hazeMix(mixHex(rusts[1], ochres[1], 0.6)), hazeMix(ochres[0])];
default: return [hazeMix(mixHex('#8f81ac', shadeCols[0], 0.45)), hazeMix(mixHex('#7f74a0', shadeCols[1], 0.45)), hazeMix(mixHex('#8f81ac', shadeCols[0], 0.6))]; // muted violet shadow side
}
};
const rows = 6;
for (let r = 0; r < rows; r++) {
const d1 = 0.02 + r * 0.155, d2 = d1 + 0.155;
let cursor = -0.08 - rng() * 0.06;
for (let sIdx = 0; sIdx < 9; sIdx++) {
const w1 = 0.14 + rng() * 0.18;
const x1 = cursor, x2 = cursor + w1;
const slant = (rng() - 0.5) * 0.14; // slanted sides: planes at contrasting angles
const x1b = x1 + slant, x2b = x2 + slant * (0.6 + rng() * 0.8);
cursor = x2 - 0.02 * rng();
if (x1 > 1.10) break;
const gMid = gpAt(vx + ((x1 + x2) / 2 - vx) / Math.max(0.02, horizon.ground((d1 + d2) / 2)), (d1 + d2) / 2);
const type = fieldType();
const cols = fieldCols(type, gMid);
// the furrow direction: contour-following with contrasting diagonal turns
const patchAngle = wpick([[0.0, 5], [Math.PI, 4], [r % 2 ? 0.30 : -0.30, 3], [r % 2 ? -0.55 : 0.55, 2]])
+ (rng() - 0.5) * 0.10;
const nStrokes = Math.round((30 + (d2 - d1) * 160 + w1 * 110) * (r === 0 ? 1.5 : 1));
for (let i = 0; i < nStrokes; i++) {
const u = rng(), v = rng();
const dqv = d1 + v * (d2 - d1);
// sample the patch in SCREEN space, then convert to the plane's
// near-reference x so every row covers the full frame width
const sx = (x1 + (x2 - x1) * u) * (1 - v) + (x1b + (x2b - x1b) * u) * v;
const gscr = horizon.ground(dqv);
const g = gpAt(vx + (sx - vx) / Math.max(0.02, gscr), dqv);
const col = temperatureShift(cols[Math.floor(rng() * cols.length)], (rng() - 0.5) * 0.3);
const len = W * (0.014 + rng() * 0.030) * (0.4 + g.scale * 1.5);
const wd = Math.max(1.4, H * (0.004 + rng() * 0.009) * (0.4 + g.scale * 1.4));
const ang = patchAngle + (rng() - 0.5) * 0.30 + (rng() < 0.07 ? (rng() - 0.5) * 1.0 : 0);
const a = (0.30 + rng() * 0.32) * (0.62 + 0.38 * Math.min(1, dqv * 2.2));
const p = 0.34 + 0.24 * dqv + rng() * 0.03;
const tech = rng();
stamped(p, (c, jr) => {
if (tech < 0.62) {
stroke(c, jr, { path: { x0: g.x, y0: g.y, x1: g.x + Math.cos(ang) * len, y1: g.y + Math.sin(ang) * len },
brush: 'round', width: wd, color: col, alpha: a, taper: 'none', spacing: 0.6 });
} else if (tech < 0.85) {
stroke(c, jr, { path: { x0: g.x, y0: g.y, x1: g.x + Math.cos(ang) * len, y1: g.y + Math.sin(ang) * len },
brush: 'flat', width: wd * 0.9, color: col, alpha: a * 0.85, taper: 'none', spacing: 0.7 });
} else {
fanDab(c, jr, { x: g.x, y: g.y, size: wd * 1.6, color: col, alpha: a * 0.8, angle: ang, squash: 0.4, spread: 0.9 });
}
});
}
}
}
// hedgerows and copse clumps stitching the planes together
for (let i = 0; i < 90; i++) {
const dqv = 0.14 + rng() * 0.55;
const xn = -0.05 + rng() * 1.1;
const g = gpAt(xn, dqv);
const rvh = riverAt(dqv);
if (Math.abs(g.x - rvh.x) < rvh.halfW * 1.3) continue; // never on the water
const dark = wpick([['#35482f', 4], ['#2c3f2a', 3], ['#40542f', 3], ['#243522', 1]]);
const r = Math.max(1.6, H * 0.008 * (0.4 + g.scale));
stamped(0.40 + 0.22 * dqv + rng() * 0.03, (c, jr) => {
const cols = brokenColor(null, jr, tint(dark), 0.4);
const nDab = 3 + Math.floor(rngJ(jr) * 4);
for (let k = 0; k < nDab; k++) {
dab(c, g.x + (rngJ(jr) - 0.5) * r * 3.2, g.y - r * (0.3 + rngJ(jr) * 0.9),
r * (0.5 + rngJ(jr) * 0.5), cols[k % cols.length], 0.35 + rngJ(jr) * 0.3, 0, 0.55);
}
});
}
// ---- the river: broken horizontal strokes, sky-coloured ---------------
for (let i = 0; i < 620; i++) {
const dqv = 1 - Math.pow(rng(), 1.45) * 0.87; // bias toward the near width
const rv = riverAt(dqv);
if (rv.halfW < 1.4) continue;
const side = (rng() * 2 - 1);
const xa = rv.x + side * rv.halfW * (0.15 + rng() * 0.85);
const span = rv.halfW * (0.35 + rng() * 0.85) * (side > 0 ? -1 : 1);
const x1 = xa + span;
const y = rv.y + (rng() - 0.5) * rv.halfW * 0.35;
const near = dqv > 0.55;
let col;
const refl = rng();
if (refl < 0.55) col = skyTint(wpick([['#a9c2cf', 5], ['#b7cbd6', 4], ['#c2d2d6', 3], ['#e9e2cf', 2]]));
else if (refl < 0.75) col = tint(wpick([['#5d7392', 4], ['#4a5a70', 3], ['#6f86a8', 3]])); // depth under the sky
else col = tint('#3d4a60');
if (weather === 'rain') col = mixHex(col, '#8d9aa8', 0.3);
if (winter) col = mixHex(col, rng() < 0.55 ? '#dfe6ec' : '#c8d4dc', 0.30 + rng() * 0.25); // ice sheen
const wd = Math.max(1.1, H * (0.004 + rng() * 0.010) * (0.4 + rv.s * 1.6));
const a = 0.4 + rng() * 0.35;
const tilt = (rng() - 0.5) * 0.05;
stamped(0.56 + 0.16 * (1 - dqv) + rng() * 0.02, (c, jr) => {
stroke(c, jr, {
path: { x0: xa, y0: y, x1, y1: y + Math.sin(tilt) * Math.abs(span) },
brush: near ? 'round' : 'flat', width: wd, alpha: a, color: col,
taper: rngJ(jr) < 0.5 ? 'both' : 'none', spacing: 0.55,
});
});
}
// glints where the light strikes the water — struck last, near the mouth
for (let i = 0; i < 55; i++) {
const dqv = 0.45 + rng() * 0.55;
const rv = riverAt(dqv);
const x = rv.x + (rng() * 2 - 1) * rv.halfW * 0.8;
const y = rv.y + (rng() - 0.5) * rv.halfW * 0.3;
const col = night ? '#cfd6e2' : wpick([['#f5efda', 5], ['#f0e9d2', 3], ['#e8dfc8', 2]]);
stamped(0.93 + rng() * 0.05, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + (rngJ(jr) - 0.5) * rv.halfW, y1: y },
brush: 'round', width: 1.2 + rngJ(jr) * 2, color: col, alpha: 0.5 + rngJ(jr) * 0.35, taper: 'both' });
});
}
// ---- near bank: dark, warm, dense dabs --------------------------------
for (let i = 0; i < 620; i++) {
const dqv = 0.74 + Math.pow(rng(), 0.9) * 0.26;
const xn = -0.04 + rng() * 1.08;
const rv = riverAt(dqv);
const g = gpAt(xn, dqv);
if (Math.abs(g.x - rv.x) < rv.halfW * 1.08) continue; // never on the water
const l = lit(g.x, g.y) * 0.7 + (rng() - 0.5) * 0.4;
let col = l > 0.45 ? pick(lightCols) : l > 0.1 ? pick(midCols) : pick(shadeCols);
if (rng() < 0.3) col = pick(autumn ? rusts : ochres);
col = mixHex(tint(col), '#5c4a32', 0.18); // warm the bank earth
const size = H * (0.008 + rng() * 0.016) * (0.7 + g.scale);
const ang = (rng() - 0.5) * 0.5 + (rng() < 0.12 ? -Math.PI / 2 + (rng() - 0.5) * 0.6 : 0);
const a = 0.35 + rng() * 0.35;
stamped(0.70 + 0.10 * (dqv - 0.74) / 0.26 + rng() * 0.02, (c, jr) => {
if (rngJ(jr) < 0.6) roundDab(c, jr, { x: g.x, y: g.y, size, color: col, alpha: a, angle: ang, squash: 0.6, warmth: 0.4 });
else drybrush(c, jr, { x: g.x, y: g.y, size: size * 0.9, color: col, alpha: a, angle: ang, run: 2, density: 0.6, squash: 0.5 });
});
}
// dark bushes crowding the near bank corners
for (let i = 0; i < 26; i++) {
const dqv = 0.82 + rng() * 0.18;
const side = rng() < 0.5 ? -0.06 : 1.06;
const xn = side + (rng() - 0.5) * 0.16;
const g = gpAt(xn, dqv);
const rv = riverAt(dqv);
if (Math.abs(g.x - rv.x) < rv.halfW * 1.6) continue;
const r = Math.max(3, H * 0.02 * (0.5 + g.scale));
stamped(0.76 + rng() * 0.05, (c, jr) => {
stipple(c, jr, { x: g.x, y: g.y - r, size: r * 1.6, color: tint('#26331f'),
alpha: 0.55 + rngJ(jr) * 0.3, count: 22 + Math.floor(rngJ(jr) * 16), colors: [tint('#2c3f2a'), tint('#35482f'), tint('#1e2a18')] });
});
}
// ---- the bridge: a small broken-stone accent crossing mid-distance ----
if (rng() < 0.75) {
const bd = 0.46 + rng() * 0.10;
const rv = riverAt(bd);
const deckY = rv.y - rv.halfW * (0.55 + rng() * 0.35);
const span = rv.halfW * (1.5 + rng() * 0.4);
const bx0 = rv.x - span, bx1 = rv.x + span;
const stone = tint('#6b5f52'), stoneDark = tint('#4a4038');
const deckW = Math.max(1.5, rv.halfW * 0.18);
for (let i = 0; i < 9; i++) { // deck voussoirs, slightly ragged
const u = i / 8;
if (rng() < 0.12) continue; // a missing stone is data too
stamped(0.79 + rng() * 0.012, (c, jr) => {
const brokenCols = brokenColor(null, jr, i % 2 ? stone : stoneDark, 0.4);
stroke(c, jr, { path: { x0: bx0 + (bx1 - bx0) * u, y0: deckY + (rngJ(jr) - 0.5) * deckW * 0.3,
x1: bx0 + (bx1 - bx0) * (u + 0.14), y1: deckY + (rngJ(jr) - 0.5) * deckW * 0.3 },
brush: 'flat', width: deckW, color: brokenCols[0], alpha: 0.55 + rngJ(jr) * 0.2, taper: 'none', spacing: 0.4 });
});
}
const nArch = 1 + (rng() < 0.4 ? 1 : 0);
for (let ai = 0; ai < nArch; ai++) {
const ax0 = bx0 + (bx1 - bx0) * (ai / nArch + 0.10), ax1 = bx0 + (bx1 - bx0) * ((ai + 1) / nArch - 0.10);
stamped(0.80 + rng() * 0.012, (c, jr) => {
rigger(c, jr, { x: ax0, y: deckY + deckW * 0.5, length: (ax1 - ax0), color: stoneDark, alpha: 0.55,
angle: 0, width: Math.max(1.0, rv.halfW * 0.10), bow: -rv.halfW * (0.45 + rngJ(jr) * 0.2) });
});
}
for (let pi = 0; pi <= nArch; pi++) { // piers dipping toward the water
const px = bx0 + (bx1 - bx0) * (pi / nArch);
stamped(0.805 + rng() * 0.01, (c, jr) => {
rigger(c, jr, { x: px, y: deckY, length: rv.halfW * (0.55 + rngJ(jr) * 0.25), color: stoneDark,
alpha: 0.55, angle: Math.PI / 2 + (rngJ(jr) - 0.5) * 0.1, width: Math.max(1.0, rv.halfW * 0.10) });
});
}
// the bridge's reflection, broken
for (let i = 0; i < 8; i++) {
const x = rv.x + (rng() * 2 - 1) * span * 0.7;
const y = deckY + rv.halfW * (0.9 + rng() * 0.6);
stamped(0.82 + rng() * 0.02, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + (rngJ(jr) - 0.5) * span * 0.4, y1: y + rv.halfW * 0.10 },
brush: 'round', width: 1.0 + rngJ(jr) * 1.4, color: stoneDark, alpha: 0.18 + rngJ(jr) * 0.15, taper: 'both' });
});
}
}
// ---- poplars: 3-7 verticals standing like metronomes ------------------
const nPop = 3 + Math.floor(rng() * 5);
const popDepths = [];
for (let i = 0; i < nPop; i++) {
popDepths.push({ d: 0.42 + rng() * 0.38, far: false });
}
popDepths.push({ d: 0.20 + rng() * 0.08, far: true }); // one distant sentinel
popDepths.sort((a, b) => a.d - b.d);
const bare = winter || (autumn && rng() < 0.4);
const crownBase = autumn ? wpick([['#b57a34', 4], ['#c98f3e', 3], ['#8f6a2c', 2], ['#a4582c', 2]])
: wpick([['#4a6a34', 4], ['#3d5c2c', 3], ['#5a7a3c', 3], ['#2f4a26', 2]]);
const poplarSunX = sunPx;
for (const q of popDepths) {
const sideSign = (q.far ? (rng() < 0.5 ? -1 : 1) : (rng() < 0.72 ? (mirror ? -1 : 1) : (mirror ? 1 : -1)));
const rv = riverAt(q.d);
// place in SCREEN space (clear of the water), then convert to the
// plane's near-reference x — otherwise the perspective pull drags the
// trunks into the river
const sxFrac = (rv.x + sideSign * (rv.halfW * 1.25 + W * (0.018 + rng() * 0.055))) / W;
const gscr = horizon.ground(q.d);
const g = gpAt(vx + (sxFrac - vx) / Math.max(0.02, gscr), q.d);
const thisCrown = mixHex(crownBase, pick(shadeCols), rng() * 0.35); // each tree its own green
const hgt = H * (0.16 + rng() * 0.07) * (0.35 + g.scale * 1.05); // rational on the plane
const baseTilt = (rng() - 0.5) * 0.06;
const trunkCol = tint('#3a3226');
const p0 = 0.78 + (q.d - 0.2) * 0.12;
// trunk: slim riggers overlapped
for (let i = 0; i < 3; i++) {
const bow = (rng() - 0.5) * hgt * 0.03;
stamped(p0 + i * 0.002, (c, jr) => {
rigger(c, jr, { x: g.x + (rngJ(jr) - 0.5) * hgt * 0.02, y: g.y, length: hgt * (0.96 + rngJ(jr) * 0.06),
color: i === 0 ? trunkCol : mixHex(trunkCol, '#8a7a5c', 0.3), alpha: 0.75 - i * 0.15,
angle: -Math.PI / 2 + baseTilt, width: Math.max(0.9, W * 0.0035 * (0.4 + g.scale)) * (1 - i * 0.28), bow });
});
}
if (!bare) {
const levels = 8 + Math.floor(rng() * 5);
for (let k = 0; k < levels; k++) {
const yf = (k + 0.5) / levels; // 0 base -> 1 top of the crown
const prof = Math.pow(Math.sin(Math.PI * (0.12 + 0.88 * yf)), 0.65);
const cy = g.y - hgt * (0.10 + 0.88 * yf);
const cx = g.x + Math.sin(yf * 5 + meanderPhase) * hgt * 0.015 + baseTilt * hgt * yf;
const size = Math.max(1.6, hgt * 0.105 * prof * (0.75 + rng() * 0.5));
const col = mixHex(tint(thisCrown), lightCols[0], lit(cx, cy) * 0.35);
stamped(p0 + 0.006 + 0.02 * yf, (c, jr) => {
fanDab(c, jr, { x: cx, y: cy, size, color: col, alpha: 0.55 + rngJ(jr) * 0.3,
angle: -Math.PI / 2 + (rngJ(jr) - 0.5) * 0.5, squash: 1.15, spread: 0.55, tracks: 4 + Math.floor(rngJ(jr) * 3) });
});
}
} else {
// bare (or wintered): a few branch hairs
const nb = winter ? 5 + Math.floor(rng() * 4) : 4 + Math.floor(rng() * 3);
for (let b = 0; b < nb; b++) {
const yf = 0.25 + rng() * 0.7;
const side2 = rng() < 0.5 ? -1 : 1;
stamped(p0 + 0.006 + rng() * 0.01, (c, jr) => {
rigger(c, jr, { x: g.x, y: g.y - hgt * yf, length: hgt * (0.10 + rngJ(jr) * 0.16),
color: trunkCol, alpha: 0.6, angle: -Math.PI / 2 + side2 * (0.5 + rngJ(jr) * 0.5),
width: Math.max(0.6, W * 0.0012 * (0.4 + g.scale)), bow: side2 * hgt * 0.02 });
});
}
}
// golden-hour rim on the sun side
if ((golden || dawn) && cond.sunStrength > 0.5) {
for (let i = 0; i < 4; i++) {
const yf = rng();
const side2 = poplarSunX > g.x ? 1 : -1;
stamped(0.90 + rng() * 0.02, (c, jr) => {
dab(c, g.x + side2 * hgt * 0.03, g.y - hgt * (0.15 + 0.8 * yf), Math.max(0.8, hgt * 0.018),
rngJ(jr) < 0.5 ? '#f0b070' : '#f4c88a', 0.22 + rngJ(jr) * 0.18, -Math.PI / 2, 1.6);
});
}
}
q.g = g; q.hgt = hgt; // for the shadow pass
}
// ---- long shadows across the planes ------------------------------------
const elong = clamp01((cond.sunY - 0.10) * 2.0); // low sun -> long shadows
if (cond.sunVisible && cond.sunStrength > 0.45 && elong > 0.15) {
const shadowCol = mixHex(tint('#5a5480'), hazer, 0.25);
for (const q of popDepths) {
if (!q.g) continue;
const dir = sunPx > q.g.x ? -1 : 1; // shadows fall away from the sun
const len = q.hgt * elong * (0.9 + rng() * 0.6);
stamped(0.905 + rng() * 0.015, (c, jr) => {
knife(c, jr, { x: q.g.x + dir * len * 0.5, y: q.g.y + q.hgt * 0.01, size: len * 0.55,
color: shadowCol, alpha: 0.16 + rngJ(jr) * 0.10, angle: dir > 0 ? 0.06 : Math.PI - 0.06, squash: 0.10 });
});
}
for (let i = 0; i < 26; i++) { // broad shadow bands raking the near fields
const dqv = 0.45 + rng() * 0.55;
const xn = -0.05 + rng() * 1.1;
const g = gpAt(xn, dqv);
const dir = sunPx > g.x ? -1 : 1;
const len = W * (0.06 + rng() * 0.14) * (0.5 + g.scale);
stamped(0.86 + rng() * 0.03, (c, jr) => {
knife(c, jr, { x: g.x, y: g.y, size: len * 0.5, color: shadowCol,
alpha: 0.08 + rngJ(jr) * 0.09, angle: dir > 0 ? 0.04 : Math.PI - 0.04, squash: 0.08 });
});
}
}
// ---- dawn mist and weather haze: bands across the valley ---------------
if (weather === 'mist' || dawn) {
const nBands = (dawn ? 70 : 40) + Math.floor(rng() * 30);
for (let i = 0; i < nBands; i++) {
const hugHorizon = rng() < 0.6;
const dqv = hugHorizon ? 0.12 + rng() * 0.2 : 0.35 + rng() * 0.45;
const g = gpAt(0.5, dqv);
const y = hugHorizon ? hzY + (rng() - 0.35) * H * 0.10 : g.y + (rng() - 0.5) * H * 0.08;
const x = rng() * W;
const len = W * (0.12 + rng() * 0.30);
const wd = H * (0.014 + rng() * 0.04);
const a = (dawn ? 0.06 : 0.045) + rng() * 0.07;
stamped(0.32 + rng() * 0.34, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + len, y1: y + (rngJ(jr) - 0.5) * wd * 0.4 },
brush: 'round', width: wd, color: '#e4e8e6', alpha: a, taper: 'both', spacing: 0.8 });
});
}
}
// ---- final notes: cream glints and dark accents, struck last ----------
for (let i = 0; i < 60; i++) { // lightest grass in the sunlit pools
const L = pools[Math.floor(rng() * pools.length)];
const x = Math.min(Math.max((L[0] + gauss() * L[1 + 1] * 0.9) * W, W * 0.01), W * 0.99);
const y = Math.min(Math.max((L[1] + gauss() * L[3]) * H, hzY + H * 0.02), H - 3);
const col = night ? '#7a86a4' : wpick([['#e0c078', 4], ['#d4b264', 3], ['#ccd67e', 3], ['#e8dfc8', 2]]);
stamped(0.945 + rng() * 0.04, (c, jr) => {
fanDab(c, jr, { x, y, size: H * (0.006 + rng() * 0.010), color: tint(col),
alpha: 0.4 + rngJ(jr) * 0.3, angle: (rngJ(jr) - 0.5) * 0.4, squash: 0.45, spread: 0.8 });
});
}
for (let i = 0; i < 30; i++) { // dark terracotta accents anchoring the planes
const dqv = 0.35 + rng() * 0.6;
const g = gpAt(-0.02 + rng() * 1.04, dqv);
stamped(0.95 + rng() * 0.04, (c, jr) => {
knife(c, jr, { x: g.x, y: g.y, size: W * (0.012 + rng() * 0.03) * (0.5 + g.scale),
color: tint(pick(['#8a4a30', '#6b4030', '#a05a38'])), alpha: 0.30 + rngJ(jr) * 0.25,
angle: (rngJ(jr) - 0.5) * 0.5, squash: 0.18 });
});
}
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);
}
},
};
// tiny local rng helper for inside stamped closures (jr is a mulberry32)
function rngJ(jr) { return jr(); }
:root {
--ink: #cfc6b8;
--dim: #93897b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: #1a1712;
overflow: hidden;
font-family: Georgia, 'Iowan Old Style', serif;
}
#stage { position: fixed; inset: 0; display: block; }
#overlay {
position: fixed;
inset: 0;
pointer-events: none;
color: var(--ink);
transition: opacity 0.9s ease;
}
body.idle #overlay { opacity: 0; }
body.frozen #overlay { opacity: 1; }
#caption { position: absolute; top: 30px; left: 40px; }
#title {
font-size: 15px;
font-weight: 400;
font-variant: small-caps;
letter-spacing: 0.18em;
}
#artist {
margin-top: 7px;
font-size: 11.5px;
font-variant: small-caps;
letter-spacing: 0.16em;
color: var(--dim);
}
#wordmark {
position: absolute;
top: 32px;
right: 40px;
font-size: 11px;
font-variant: small-caps;
letter-spacing: 0.32em;
color: var(--dim);
}
#progress {
position: absolute;
left: 12%;
right: 12%;
bottom: 26px;
height: 1px;
background: rgba(207, 198, 184, 0.16);
}
#fill {
height: 2px;
width: 0%;
margin-top: -0.5px;
background: var(--ink);
transition: width 0.2s linear;
}
#dots {
position: absolute;
bottom: 46px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 13px;
pointer-events: auto;
}
#dots button {
all: unset;
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(207, 198, 184, 0.26);
cursor: pointer;
transition: background 0.3s ease, transform 0.3s ease;
}
#dots button:hover { background: rgba(207, 198, 184, 0.55); }
#dots button.active {
background: var(--ink);
transform: scale(1.3);
}
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