Commit bb4d0806 by PLN (Algolia)

impression: the full wall — thirteen canvases

parent 4fe2289a
...@@ -593,6 +593,14 @@ function localRng(p) { ...@@ -593,6 +593,14 @@ function localRng(p) {
}; };
} }
// NOTE on the water: bleed() is deliberately kept for the occasional hero
// touch (a handful of calls per painting — a capped budget is affordable).
// It must NOT ride the per-stroke hot path: a canvas-wide ctx.filter costs
// ~25ms PER STROKE at full canvas area in software rendering, and dense
// mouille freezes hung chrome for minutes (house bug, three painter reports).
// The wet hand's stroke loop lays its water with realizeWet's dry-laid wet
// marks instead — the blur baked into the mark geometry, no filter ops.
/** /**
* bleed — paint paintFn inside a water blur: wet-on-wet edges, colours * bleed — paint paintFn inside a water blur: wet-on-wet edges, colours
* merging. Graceful fallback: a ctx without filter support just paints dry. * merging. Graceful fallback: a ctx without filter support just paints dry.
...@@ -787,16 +795,41 @@ function realizeWet(ctx, rng, hand, p) { ...@@ -787,16 +795,41 @@ function realizeWet(ctx, rng, hand, p) {
const sq = clamp(p.squash == null ? 1 : p.squash, 0.15, 1); const sq = clamp(p.squash == null ? 1 : p.squash, 0.15, 1);
const col = p.c; const col = p.c;
const ux = Math.cos(ang), uy = Math.sin(ang); const ux = Math.cos(ang), uy = Math.sin(ang);
bleed(ctx, clamp(w * 0.35, 0.8, 3.5), (g) => { // THE WET, LAID DRY. The old path set a canvas-wide ctx.filter per stroke —
// the touch proper, plus a halo the colour bleeds into // ~25ms PER STROKE at full canvas area (house bug: dense mouille freezes
if (len > w * 1.3) { // hung chrome for minutes; touch-sized offscreen stamps still cost ~10ms
softStreak(g, p.x, p.y, (len / 2) * 1.1, (w / 2) * 1.5, col, a, ang, 0.3); // each on a lived-in canvas — every scratch round-trip resolves the big
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); // surface). So the water is laid with no filter ops and no canvas
} else { // round-trips: Gaussian blur is linear over transparency, so blurring the
softEllipse(g, p.x, p.y, w * 0.72, col, a, ang, sq, 0.30); // touch's two marks equals compositing the two blurred marks; and blurring
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); // a soft gradient mark widens its falloff by the blur and dips its peak as
} // the ink spreads (badly for small marks, barely for large ones). `wet()`
}); // bakes exactly that into each mark: radius + ~1.2 sigma, the rim softened
// by the blur's own transition width, alpha faded by the spread. The rng
// beat is unchanged — one halo draw, same position in the stream.
const sig = clamp(w * 0.35, 0.8, 3.5) / 2; // css blur(px) -> gaussian sigma
// blurred radius and hardness for a mark of radius r (defining axis),
// falloff today = (1-hardness)*r widened by the blur's ~2 sigma transition
const wetH = (r, hardness) => {
const r2 = r + 1.2 * sig;
return clamp(1 - ((1 - hardness) * r + 2 * sig) / r2, 0.05, 0.95);
};
// peak dip as the ink spreads: ~nothing once the flat top dwarfs sigma
const wetA = (r, hardness) =>
a * clamp(1 - 0.75 * Math.exp(-Math.pow(hardness * r / (2 * sig), 2)), 0.05, 1);
if (len > w * 1.3) {
const haloW = 1.7 + rng() * 0.4;
softStreak(ctx, p.x, p.y, (len / 2) * 1.1 + 1.2 * sig, (w / 2) * 1.5 + 1.2 * sig,
col, wetA((w / 2) * 1.5, 0.3), ang, wetH((len / 2) * 1.1, 0.3));
softStreak(ctx, p.x + ux * len * 0.1, p.y + uy * len * 0.1,
(len / 2) * 1.25 + 1.2 * sig, (w / 2) * haloW + 1.2 * sig,
col, wetA((w / 2) * haloW, 0.15) * 0.45, ang, wetH((len / 2) * 1.25, 0.15));
} else {
const halo = 1.05 + rng() * 0.3;
softEllipse(ctx, p.x, p.y, w * 0.72 + 1.2 * sig, col, wetA(w * 0.72, 0.3), ang, sq, wetH(w * 0.72, 0.3));
softEllipse(ctx, p.x + ux * len * 0.18, p.y + uy * len * 0.18, w * halo + 1.2 * sig,
col, wetA(w * halo, 0.18) * 0.45, ang, sq * 0.9, wetH(w * halo, 0.18));
}
} }
const REALIZERS = { const REALIZERS = {
......
// Champ de blé aux cyprès — a homage to Van Gogh's wheat fields at
// Saint-Rémy, June 1889: a sea of yellow under a churning sky, dark
// cypresses standing like green flames, the burning South of France at
// midday.
//
// THE WIND IS THE SPINE. One dice-rolled lean angle and one gust-wave
// function are shared by every layer — the wheat leans in coherent gust
// waves (ridges catch the light, troughs deepen), the sky's comma-strokes
// stream on the same wind, the cypress tips flick with it. One weather
// system over the whole canvas; that directional coherence is what makes
// the field alive. Nothing scatters randomly.
//
// THE WHEAT is the protagonist: the field fills the lower two-thirds in
// every yellow the tube holds — chrome, cadmium, straw, ochre — painted as
// rhythmic short strokes far to near, with seed-head flicks nodding into
// the gusts. The SKY is churned, not calm: great rolling comma-strokes
// streaming on the same wind, riding waves across dice-rolled cloud masses,
// white clouds swelling, the blue deep and saturated between the forms.
// The CYPRESSES: one to three dark green-black flames, one
// dominant in a diced third, rising from the field's bottom edge past the
// horizon — built from upward-curving twisted strokes along a flame
// profile, with lighter olive flickers turbulent inside the dark mass.
// Accents, dice-gated: the Alpilles as a hazed blue-purple band, red
// poppies in the near wheat, a wheat stack or a ladder, a pale burnished
// sun, swallows as single curved strokes, stars at night.
//
// TONAL SPINE (daylight discipline): pale sky > cloud lit faces > wheat
// crest > wheat mass > wheat trough > Alpilles haze > cypress flame
// (darkest solid) > poppy accents. The yellow must never flatten: every
// wheat stroke is broken across the season palette, then re-graded by the
// gust wave — crest to cream, trough to deep ochre.
//
// CONDITIONS VARIATION — the wheat at every hour: dawn (grey-gold wheat,
// mist lying low), morning (first gold), noon (blazing, maximum chrome),
// goldenHour (amber-orange field), dusk (deep gold-brown under indigo),
// night (the field in shadow-gold, stars over the churning sky); spring
// (green-gold young wheat), summer (the canonical gold), autumn (bronze);
// storm (overcast/rain: the sky boils dark, the wheat goes wilder), mist
// (the field half-ghosted), clear (the canonical burning South).
//
// Contract: identical to scenes/jardin.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; nothing consumes
// randomness at draw time. All touch primitives route through
// brushes.handFor(conditions.technique) + handDab; the load-bearing prime
// rides direct (a pointillisme wrist must not starve the tonal spine).
import {
mulberry32, makeConditions, weatherOverlay, skyGradient,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, dab, roundDab, rigger, stroke, aerialFade,
} from '../brushes.js';
let strokes = null;
export default {
id: 'bled',
title: 'Champ de blé aux cyprès',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Champ de blé, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1889));
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', noon = tod === 'noon', morning = tod === 'morning';
const spring = season === 'spring', summer = season === 'summer';
const autumn = season === 'autumn', winter = season === 'winter';
const stormy = weather === 'overcast' || weather === 'rain';
const misty = weather === 'mist';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
// one dedicated jitter stream per stroke, consumed lazily exactly once;
// weather touches ride on ~14% of strokes so the sirocco stays airy.
const stamped = (p, fn) => {
const jr = mulberry32((0x51E4D + Math.floor(rng() * 0xffffff) * 673) >>> 0);
const wr = (weather !== 'clear' && rng() < (night ? 0.06 : 0.14))
? 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 */ }
}
});
};
// -- THE HAND: the rolled technique decides how every touch sits -------
const hand = handFor(cond.technique);
// small hands (sec, pointillisme) cover less area per touch; the
// structural masses compensate so the field closes in every hand —
// the wrist changes how the paint sits, never what the composition says.
const covK = Math.min(1.32, Math.max(1, 1 / Math.max(0.25,
((hand.strokeLength[0] + hand.strokeLength[1]) / 2) *
((hand.dabSize[0] + hand.dabSize[1]) / 2))));
const touch = (x, y, w, c, a, ang = 0, squash = 1) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len: 0, ang, w, c, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: a, angle: ang, squash }),
});
const smear = (x, y, len, ang, w, c, a, squash = 1) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len, ang, w, c, a, squash,
plain: (g) => stroke(g, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: c, alpha: a, taper: 'none', spacing: 0.55,
}),
});
// the soft air — mist, halos, sun haze — stays translucent in every hand
const veil = (x, y, len, ang, w, c, a) => (cc) => {
const half = Math.max(1, len * 0.5);
const alpha = a * hand.alphaBase;
dab(cc, x, y, half, c, alpha, ang, Math.min(1, w / half));
dab(cc, x, y, half * 1.45, c, alpha * 0.35, ang, Math.min(1, (w * 1.5) / (half * 1.45)));
};
// -- THE WIND: one dice-rolled weather system for the whole canvas -----
const windStrength = Math.min(1, (0.45 + rng() * 0.55) + (stormy ? 0.28 : 0));
const windSign = rng() < 0.5 ? -1 : 1;
const windLean = windSign * (0.20 + 0.30 * windStrength); // radians, ±0.50 max
const gustFreq = 1.2 + rng() * 1.6; // gust waves across the width
const gustPhase = rng() * Math.PI * 2;
const gustDrift = (rng() - 0.5) * 2.2; // waves shear with depth
const swellPhase = rng() * Math.PI * 2;
// the gust field: -1 trough .. +1 crest, riding a slower swell; the phase
// carries the wind's own shear, so the crest lines lean WITH the wind —
// the wave itself is wind-slanted, legible under every hand's jitter
const gust = (xn, yn) => Math.sin(xn * gustFreq * Math.PI * 2 + gustPhase
+ yn * gustDrift + xn * windSign * 0.55) * 0.75
+ Math.sin(xn * 1.7 + swellPhase - yn * 1.1 + xn * windSign * 0.3) * 0.25;
// -- palettes: the tube of yellows, graded by hour and weather ---------
const WHEAT_SEASON = {
spring: ['#b8bc50', '#c8cc60', '#a8b048', '#d2d474', '#9aa844'],
summer: ['#e0bc40', '#ecd068', '#d4ac34', '#f2dc80', '#c8a030'],
autumn: ['#c89848', '#b8883c', '#d8a858', '#a07834', '#cfa050'],
winter: ['#c8b878', '#b8a868', '#d4c488', '#a89858', '#d8cc90'],
};
const TOD_WHEAT = {
dawn: ['#b0a878', 0.30], morning: [null, 0], noon: ['#f6e050', 0.22],
goldenHour: ['#e8a840', 0.30], dusk: ['#8a6838', 0.38], night: ['#3e3420', 0.68],
};
const WX_WHEAT = {
clear: [null, 0], mist: ['#c8cabc', 0.16], overcast: ['#8a9488', 0.20],
rain: ['#7a8a84', 0.22], snow: ['#d0d8d8', 0.26],
};
const hazeCol = weather === 'mist' ? mixHex('#d6dad4', '#8a988c', night ? 0.5 : 0.1)
: mixHex('#b0bcac', '#5a6a60', night ? 0.55 : 0.12);
// in clear air the distance glows warm (light-baked haze), never grey
const farHaze = (stormy || misty || night) ? hazeCol : mixHex(hazeCol, '#e8d8a0', 0.60);
const wheatGrade = (col, base = 0) => {
let out = col;
const [tc, ta] = TOD_WHEAT[tod]; if (tc && ta) out = mixHex(out, tc, ta);
const [wc, wa] = WX_WHEAT[weather]; if (wc) out = mixHex(out, wc, night ? wa * 0.45 : wa);
if (base > 0) out = mixHex(out, farHaze, clamp01(base));
return out;
};
const CYP = {
dark: ['#1c2c1a', '#22381e', '#16241a'],
mid: ['#2c4824', '#3a5a30', '#244020'],
flick: ['#7a8a44', '#8e9a50', '#a4ac58', '#6a7c3c'],
};
if (night) { CYP.dark = ['#0e1810', '#121e14', '#0a120c']; CYP.mid = ['#1a2c18', '#203418']; CYP.flick = ['#3a4c26', '#48582c']; }
if (dusk) { CYP.dark = ['#141f16', '#1a2a1a', '#101a14']; CYP.mid = ['#223a1e', '#2c4824']; CYP.flick = ['#5a6c34', '#6c7a3c']; }
if (stormy) {
CYP.dark = CYP.dark.map((c) => mixHex(c, '#2c3a34', 0.25));
CYP.mid = CYP.mid.map((c) => mixHex(c, '#3a4c44', 0.2));
}
// -- geometry -------------------------------------------------------------
const hzYn = clamp01(cond.horizonY); // 0.35-0.60 from the conditions
const hzY = hzYn * H;
// -- prime: the burning South as a ground, painted once -------------------
{
// only the sky's own stops feed the sky half; its dark `deep` stop below
// the horizon would drag a grey band through the field's gradient
const sg = skyGradient(cond).filter((s) => s.at <= hzYn + 0.0001).map((s) => {
let col = stormy ? mixHex(s.color, '#46564f', 0.32)
: misty ? mixHex(s.color, '#d8dcd6', night ? 0.04 : 0.16)
: !night && !golden && !dusk && !dawn // clear air: deepen the blue,
? mixHex(s.color, '#2a52a8', 0.06 + 0.20 * (1 - s.at / Math.max(0.05, hzYn))) // most at the top
: s.color;
if (night) col = mixHex(col, '#10182c', 0.42); // the LUT's weather lift must not bleach the night
return { at: s.at, color: col };
});
const g = ctx.createLinearGradient(0, 0, 0, H);
for (const s of sg) g.addColorStop(s.at, s.color);
// the wheat's ground tone below the horizon, richest toward the frame edge
g.addColorStop(Math.min(1, hzYn + 0.001), wheatGrade(pick(WHEAT_SEASON[season]), 0.10));
g.addColorStop(Math.min(1, hzYn + 0.25), wheatGrade(mixHex(WHEAT_SEASON[season][0], '#6a5218', 0.22), 0.04));
g.addColorStop(1, wheatGrade(mixHex(WHEAT_SEASON[season][0], '#5c4a14', 0.30), 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// a few broad unifying veils so the prime never reads as a flat wash
for (let i = 0; i < 26; i++) {
const y = rng() * H;
const col = y < hzY
? mixHex(skyGradient(cond)[1].color, '#f4f0e4', 0.15)
: wheatGrade(pick(WHEAT_SEASON[season]), 0.10);
stamped(0.010 + 0.025 * (y / H) + rng() * 0.008, veil(rng() * W, y, W * (0.10 + rng() * 0.22),
(rng() - 0.5) * 0.1, Math.max(2, H * (0.02 + rng() * 0.05)), col, 0.05 + rng() * 0.06));
}
// -- the sky's dice: the rolling cloud masses ------------------------------
const CLOUD_LIT = {
dawn: '#f2ddc4', morning: '#f6f0de', noon: '#faf6e6',
goldenHour: '#f6d8a0', dusk: '#c8a088', night: '#465274',
}[tod];
const CLOUD_SHADE = {
dawn: '#a8a2b0', morning: '#b8bcc4', noon: '#c4c8c8',
goldenHour: '#b08878', dusk: '#6a5a80', night: '#2c3854',
}[tod];
const gradeSky = (col, base = 0) => {
let out = col;
if (stormy) out = mixHex(out, '#4c5c54', 0.30);
if (misty) out = mixHex(out, '#d8dcd6', night ? 0.04 : 0.14);
if (base > 0) out = mixHex(out, hazeCol, clamp01(base));
return out;
};
const nCloud = 3 + Math.floor(rng() * 3);
const clouds = [];
for (let i = 0; i < nCloud; i++) {
clouds.push({
cx: rng(), cy: 0.10 + rng() * (hzYn * 0.62),
rx: 0.18 + rng() * 0.30, ry: 0.10 + rng() * 0.18, w: 0.9 + rng() * 0.8,
});
}
const swellA = 0.18 + rng() * 0.16, swellFx = 1.2 + rng() * 1.6;
const swellFy = 2 + rng() * 3, swellP = rng() * 6.28;
const cloudField = (xn, ynn) => {
let v = stormy ? 0.40 : 0.04;
v += swellA * Math.sin(xn * swellFx * Math.PI * 2 + swellP) * Math.sin(ynn * swellFy + swellP * 0.7);
for (const c of clouds) {
const dx = (xn - c.cx) / c.rx, dy = (ynn - c.cy) / c.ry;
v += c.w * Math.exp(-(dx * dx + dy * dy));
}
return v;
};
const cloudTh = stormy ? 0.56 : 0.72;
// -- the churned sky: great rolling comma-strokes streaming with the wind --
{
const sg = skyGradient(cond);
const skyTop = sg[0].color, skyMid = sg[1].color, skyHor = sg[2].color;
// the rolling waves the comma-strokes ride (dice, in units of sky height)
const rollF = 1.6 + rng() * 2.2, rollP = rng() * Math.PI * 2, rollA = 0.22 + rng() * 0.20;
for (let i = 0, nSky = Math.round(1350 * covK); i < nSky; i++) {
const xn = rng();
const ynn = Math.pow(rng(), 1.1) * hzYn * 1.015; // the churn thickens near the top
const x = xn * W, y = ynn * H;
const v = cloudField(xn, ynn);
// flow angle: the wind's own lean riding a rolling wave across the sky —
// one weather system with the wheat, streaming, never a swirl
const ang = windSign * windStrength * 0.20
+ rollA * Math.sin(ynn / Math.max(0.1, hzYn) * rollF * Math.PI + rollP)
+ 0.10 * gust(xn, ynn / Math.max(0.1, hzYn))
+ (rng() - 0.5) * 0.24;
let col, a;
if (v > cloudTh) { // the bulge: lit tops, shaded bellies
col = gust(xn, ynn) > 0 ? mixHex(CLOUD_LIT, CLOUD_SHADE, 0.08 + rng() * 0.16)
: mixHex(CLOUD_LIT, CLOUD_SHADE, 0.30 + rng() * 0.26);
if (v > cloudTh + 0.5) col = mixHex(col, CLOUD_SHADE, 0.20); // the dense core
if (v > cloudTh + 0.30) col = mixHex(col, '#fffdf2', 0.22); // the bulge catches the sun
col = gradeSky(col, 0.02);
a = 0.60 + rng() * 0.34;
} else if (v > cloudTh - 0.20) { // the torn edge where cloud meets blue
col = gradeSky(mixHex(pick([skyMid, skyTop]), CLOUD_LIT, 0.14 + rng() * 0.18), 0.03);
a = 0.42 + rng() * 0.28;
} else { // the deep blue between the forms — saturated, alive
col = gradeSky(mixHex(pick([skyTop, skyMid]), '#1e3a78', 0.42 + rng() * 0.30), 0.04);
if (night) col = mixHex(col, '#141d33', 0.45);
a = 0.52 + rng() * 0.36;
}
const big = rng() < 0.12; // a few long rolling gestures carry the churn
const len = W * (big ? 0.07 + rng() * 0.07 : 0.020 + rng() * 0.040);
const wd = Math.max(1.2, H * (0.005 + rng() * 0.009)) * (big ? 1.5 : 1);
stamped(0.035 + 0.24 * (ynn / Math.max(0.1, hzYn)) + rng() * 0.015,
smear(x, y, len, ang, wd, col, a, 0.55));
}
if (night) { // stars live in the deep blue only — before the wheat
const nStars = 70 + Math.floor(rng() * 50);
for (let i = 0; i < nStars; i++) {
const xn = rng(), ynn = Math.pow(rng(), 1.2) * hzYn * 0.9;
if (cloudField(xn, ynn) > cloudTh - 0.18 && rng() < 0.6) continue; // most hide behind cloud
const x = xn * W, y = ynn * H;
const big = rng() < 0.12;
stamped(0.905 + rng() * 0.02, touch(x, y, Math.max(0.8, H * (big ? 0.005 : 0.0026)),
pick(['#e8ecda', '#f4f0c8', '#d8e4f0', '#f6f2e0']), big ? 0.9 : 0.45 + rng() * 0.45));
if (big) stamped(0.907 + rng() * 0.02, (c) => dab(c, x, y, H * 0.012, '#c8d4a8', 0.14));
}
}
}
// -- the Alpilles: a hazed blue-purple band on the horizon (dice) ---------
const hasAlpilles = rng() < 0.72;
if (hasAlpilles) {
const peakF = 1.5 + rng() * 2.5, peakP = rng() * Math.PI * 2;
const peakA = H * (0.02 + rng() * 0.035);
const af = aerialFade(0.08);
for (let i = 0, nAlp = Math.round(110 * covK); i < nAlp; i++) {
const xn = rng();
const ridge = hzY - H * 0.004 - peakA * (0.5 + 0.5 * Math.sin(xn * peakF * Math.PI * 2 + peakP))
* (0.4 + 0.6 * Math.abs(Math.sin(xn * 7.3 + peakP * 0.4)));
const yn = Math.pow(rng(), 1.4);
const x = xn * W, y = ridge + yn * Math.max(3, hzY - ridge);
let col = pick(['#8a86a8', '#7a7a9c', '#94869e', '#6e6e90']);
col = wheatGrade(mixHex(col, af.haze, 0.35 + af.desat), (1 - yn) * 0.22);
stamped(0.272 + rng() * 0.03, smear(x, y, W * (0.010 + rng() * 0.026), (rng() - 0.5) * 0.12,
Math.max(1.1, H * 0.005), col, 0.30 + rng() * 0.26, 0.55));
}
}
// -- the sun: a pale burnished disc on some seeds ---------------------------
if (cond.sunVisible && rng() < 0.58) {
const sx = cond.sunX * W, sy = cond.sunY * H;
const strength = cond.sunStrength;
const discCol = night ? '#d8e0ea' : golden ? '#f8dc9c' : dusk ? '#e8c898' : '#f8ecc4';
stamped(0.316, (c) => dab(c, sx, sy, H * 0.028, discCol, 0.10 + 0.14 * strength));
stamped(0.318, (c) => dab(c, sx, sy, H * 0.014, discCol, 0.30 + 0.35 * strength));
stamped(0.320, (c) => dab(c, sx, sy, H * 0.008, mixHex(discCol, '#ffffff', 0.4), 0.55));
for (let i = 0; i < 8; i++) { // the burnished rim
const ang = rng() * Math.PI * 2;
stamped(0.322 + rng() * 0.004, touch(sx + Math.cos(ang) * H * 0.016, sy + Math.sin(ang) * H * 0.016,
Math.max(1, H * 0.004), discCol, 0.16 + rng() * 0.14));
}
}
// -- THE WHEAT: the protagonist, far to near, leaning with the wind --------
for (let i = 0, nWheat = Math.round(1890 * covK); i < nWheat; i++) {
const yn = Math.pow(rng(), 0.8); // 0 at the horizon, 1 at the frame bottom
const xn = rng();
const x = xn * W;
const y = hzY + yn * (H - hzY) + (rng() - 0.5) * H * 0.008;
const g = gust(xn, yn);
// the coherent lean: gust ridges stand taller into the wind, troughs bend
const lean = windLean * (0.70 + 0.45 * g) + 0.04 * Math.sin(xn * 7 + gustPhase);
// colour follows the gust field continuously: the wave reads as ridges
const base = wheatGrade(pick(WHEAT_SEASON[season]), (1 - yn) * 0.10);
const crestCol = night ? '#9a8c50' : '#f8eec0'; // moonlight, not sunlight
const troughCol = night ? '#241f10' : '#4a3a10';
let col = g > 0 ? mixHex(base, crestCol, Math.min(1, 0.34 + g * 0.45))
: mixHex(base, troughCol, Math.min(1, 0.34 - g * 0.45));
if (yn < 0.06) col = mixHex(col, '#6a4c10', 0.55); // the dark far seam
else if (yn < 0.10) col = mixHex(col, farHaze, 0.22); // the far edge hazes out
if (yn > 0.72) col = mixHex(col, '#b8860e', 0.18 + (yn - 0.72) * 0.5); // the near field richens
if (winter && yn > 0.5 && rng() < 0.30) col = mixHex(col, '#dce4e8', 0.30); // snow in the stubble
const len = W * (0.020 + rng() * 0.036) * (0.5 + yn * 0.95) * (stormy ? 1.18 : 1);
const wd = Math.max(1.2, H * (0.0045 + rng() * 0.0085) * (0.5 + yn));
stamped(0.345 + 0.345 * yn + rng() * 0.012, smear(x, y, len, lean, wd, col, 0.56 + rng() * 0.32, 0.45));
}
// the dark seam where the field meets the sky — the far wheat stands dense
for (let i = 0, nSeam = Math.round(110 * covK); i < nSeam; i++) {
const xn = rng();
const x = xn * W;
const y = hzY + rng() * H * 0.014;
const g = gust(xn, 0.02);
let col = wheatGrade(mixHex(pick(WHEAT_SEASON[season]), '#6a4c10', 0.55), 0.05);
if (g > 0.4) col = mixHex(col, '#c8a840', 0.30);
stamped(0.360 + rng() * 0.015, smear(x, y, W * (0.012 + rng() * 0.024), windLean * 0.8,
Math.max(1.1, H * 0.0045), col, 0.52 + rng() * 0.30, 0.45));
}
// seed-head flicks: nodding verticals with the wind's lean in their neck
for (let i = 0, nHead = Math.round(135 * covK); i < nHead; i++) {
const yn = 0.15 + rng() * 0.85, xn = rng();
const x = xn * W, y = hzY + yn * (H - hzY);
const g = gust(xn, yn);
const lean = windLean * (0.8 + 0.3 * g);
const col = wheatGrade(pick(WHEAT_SEASON[season]), 0.04);
const hh = H * (0.014 + rng() * 0.030) * (0.5 + yn);
stamped(0.50 + 0.18 * yn + rng() * 0.02, (c, jr) => rigger(c, jr, {
x, y, length: hh, color: mixHex(col, '#f2e2a0', 0.25),
alpha: 0.42 * hand.alphaBase,
angle: -Math.PI / 2 + lean * 1.25 + (rng() - 0.5) * 0.25,
width: Math.max(0.8, H * 0.0024 * (0.5 + yn)),
bow: lean * hh * 0.9,
}));
}
// -- THE CYPRESSES: green flames rising from the field ---------------------
const cypGeoms = []; // kept so the late accents never strike the flames
{
const nCyp = cond.motifCount; // 1-3
const domSide = cond.motifSide;
const domX = domSide === 'left' ? 0.12 + rng() * 0.17 : 0.69 + rng() * 0.17;
const xs = [domX];
if (nCyp > 1) xs.push(domSide === 'left' ? 0.55 + rng() * 0.28 : 0.14 + rng() * 0.28);
if (nCyp > 2) xs.push(domX + (domSide === 'left' ? 1 : -1) * (0.07 + rng() * 0.09));
for (let ci = 0; ci < nCyp; ci++) {
const isDom = ci === 0;
const cx0 = Math.min(0.97, Math.max(0.03, xs[ci]));
const baseY = H * 1.03;
const topY = isDom ? hzY - H * (0.10 + rng() * 0.15) : hzY + H * (-0.03 + rng() * 0.12);
const hgt = baseY - topY;
const maxW = W * (isDom ? 0.085 + rng() * 0.040 : 0.045 + rng() * 0.022);
const twistF = 1 + rng() * 1.6, twistP = rng() * Math.PI * 2;
const twistA = W * (0.008 + rng() * 0.022);
cypGeoms.push({ cx0: cx0 * W, baseY, topY, hgt, maxW, twistF, twistP, twistA });
const nC = Math.round((isDom ? 340 : 180) * covK);
for (let k = 0; k < nC; k++) {
const t = rng(); // 0 at the base, 1 at the flame tip
const prof = Math.pow(Math.sin(Math.PI * Math.pow(t, 0.47)), 0.8);
const halfW = prof * maxW * 0.5;
if (halfW < 0.45) continue; // the very tip: the profile closes there
const y = baseY - t * hgt;
const sway = twistA * Math.sin(t * twistF * Math.PI + twistP) * (0.25 + 0.75 * t);
const u = rng() * 2 - 1; // across the flame's width
const cx = cx0 * W + sway + u * halfW * (0.62 + rng() * 0.45);
// the twist's local slope tilts the strokes; the flame edges flare out
const dSway = twistA * twistF * Math.PI * Math.cos(t * twistF * Math.PI + twistP)
* (0.25 + 0.75 * t) / Math.max(1, hgt);
const flare = u * (0.18 + 0.55 * t);
const ang = -Math.PI / 2 + dSway * 2.2 + flare + windLean * 0.14 + (rng() - 0.5) * 0.20;
// colour: a near-black silhouette edge, turbulent olive flickers inside
let col;
let flick = false;
const roll = rng();
if (Math.abs(u) > 0.55) col = pick(CYP.dark);
else if (roll < 0.12) { col = pick(CYP.flick); flick = true; }
else col = roll < 0.55 ? pick(CYP.mid) : pick(CYP.dark);
if (t > 0.88 && rng() < 0.5) col = mixHex(col, hazeCol, 0.20); // the tip thins into the air
const len = H * (0.007 + rng() * 0.013) * (flick ? 0.6 : 1) * (isDom ? 1 : 0.8);
const wd = Math.max(1.5, halfW * 0.85 * (0.6 + rng() * 0.55) * (flick ? 0.65 : 1));
// the under-block: a wider dark twin stamped just before its stroke,
// so the flame's mass saturates and no sky glows through the hand
stamped(0.698 + 0.125 * t + rng() * 0.008, smear(cx, y, len, ang, wd * 1.35,
pick(CYP.dark), 0.42 + rng() * 0.2, 0.6));
stamped(0.700 + 0.125 * t + rng() * 0.012, smear(cx, y, len, ang, wd, col, 0.85 + rng() * 0.15, 0.6));
}
// the dark pool the flame drinks from, in the wheat at its foot
for (let k = 0; k < Math.round(18 * covK); k++) {
const fx = cx0 * W + (rng() - 0.5) * maxW * 2.4;
const fy = baseY - H * (0.01 + rng() * 0.06);
stamped(0.830 + rng() * 0.010, smear(fx, fy, W * (0.010 + rng() * 0.02),
(rng() - 0.5) * 0.3, Math.max(1.4, H * 0.007), pick(CYP.dark), 0.35 + rng() * 0.25, 0.5));
}
}
}
// -- accents, dice-gated: the human scale in the burning field -------------
// nothing late may strike the flames: a point inside a cypress silhouette
const inCypress = (x, y, pad = 0) => cypGeoms.some((c) => {
if (y < c.topY || y > c.baseY) return false;
const t = (c.baseY - y) / c.hgt;
const halfW = Math.pow(Math.sin(Math.PI * Math.pow(t, 0.47)), 0.8) * c.maxW * 0.5;
const sway = c.twistA * Math.sin(t * c.twistF * Math.PI + c.twistP) * (0.25 + 0.75 * t);
return Math.abs(x - (c.cx0 + sway)) < halfW * 1.2 + pad;
});
const accent = rng();
if (accent < 0.38) { // the wheat stack: a conical ochre cone in mid-field
const sx = W * (0.22 + rng() * 0.56), sy = hzY + (0.42 + rng() * 0.22) * (H - hzY);
const sw = W * (0.045 + rng() * 0.030), sh = H * (0.09 + rng() * 0.05);
for (let k = 0; k < 20; k++) {
const t = rng();
const yy = sy - t * sh;
const half = sw * 0.5 * (1 - t * 0.85);
const xx = sx + (rng() - 0.5) * half * 2;
if (inCypress(xx, yy)) continue; // the flame keeps its silhouette
let col = t < 0.5 ? pick(['#b08838', '#c09848', '#a07830']) : pick(['#7a5c28', '#8a6a34']);
col = wheatGrade(col, 0.03);
stamped(0.845 + rng() * 0.012, smear(xx, yy, W * (0.008 + rng() * 0.014),
windLean * 0.8 + (rng() - 0.5) * 0.3, Math.max(1.2, H * 0.005), col, 0.55 + rng() * 0.30, 0.55));
}
stamped(0.857, touch(sx, sy - sh, Math.max(1.2, H * 0.005), '#6a5020', 0.6)); // the capped top
} else if (accent < 0.68) { // the ladder leaning in the field
const lx = W * (0.25 + rng() * 0.5), ly = hzY + (0.55 + rng() * 0.28) * (H - hzY);
const ll = H * (0.10 + rng() * 0.06);
const leanA = windLean * 0.6 + (rng() - 0.5) * 0.15;
const ladderHidden = inCypress(lx, ly) || inCypress(lx + Math.sin(leanA) * ll, ly - ll)
|| inCypress(lx - ll * 0.10, ly) || inCypress(lx + ll * 0.10, ly);
const rungCol = wheatGrade('#4a3a22', 0.05);
for (const side of [-1, 1]) {
if (ladderHidden) break; // the flame keeps its silhouette
stamped(0.845 + rng() * 0.004, (c, jr) => rigger(c, jr, {
x: lx + side * ll * 0.10, y: ly, length: ll, color: rungCol,
alpha: 0.62 * hand.alphaBase, angle: -Math.PI / 2 + leanA,
width: Math.max(1, H * 0.0026), bow: (rng() - 0.5) * ll * 0.05,
}));
}
for (let k = 0; k < 5 && !ladderHidden; k++) {
const t = 0.12 + k * 0.19;
const px = lx + Math.sin(leanA) * ll * t, py = ly - ll * t;
stamped(0.849 + rng() * 0.004, (c, jr) => rigger(c, jr, {
x: px - ll * 0.10 * Math.cos(leanA), y: py, length: ll * 0.20, color: rungCol,
alpha: 0.55 * hand.alphaBase, angle: leanA, width: Math.max(0.9, H * 0.0022),
}));
}
}
if (rng() < 0.68 && !night) { // red poppies in the near wheat
const nPoppy = 2 + Math.floor(rng() * 4);
for (let i = 0; i < nPoppy; i++) {
const yn = 0.72 + rng() * 0.26;
const x = rng() * W, y = hzY + yn * (H - hzY);
if (inCypress(x, y)) continue; // the flame keeps its silhouette
const s = Math.max(1.2, H * 0.006 * (0.5 + yn));
stamped(0.860 + rng() * 0.010, touch(x, y, s, pick(['#c23426', '#cc3a2a', '#b02c20']), 0.72));
stamped(0.863 + rng() * 0.010, touch(x + s * 0.15, y + s * 0.15, s * 0.45, '#58140e', 0.5));
}
}
// -- the weather has its own hand -------------------------------------------
if (morning || misty) { // mist lying low over the field
const nMist = morning && misty ? 56 : morning ? 38 : misty ? (night ? 24 : 52) : 0;
for (let i = 0; i < nMist; i++) {
const y = hzY + Math.pow(rng(), 1.5) * (H - hzY) * 0.45;
const mx = rng() * W;
// a wide veil centred outside a flame still crosses it: pad the test
if (inCypress(mx, y, W * 0.14)) continue;
stamped(0.878 + rng() * 0.025, veil(mx, y, W * (0.05 + rng() * 0.16),
windLean * 0.5 + (rng() - 0.5) * 0.05, H * (0.008 + rng() * 0.022),
mixHex(morning ? '#ece0d4' : hazeCol, '#f0ece0', 0.3),
(night ? 0.03 : 0.05) + rng() * (night ? 0.05 : 0.09)));
}
}
if (stormy) { // boiling undercurrents: darker welts rolling with the wind
for (let i = 0; i < 60; i++) {
const xn = rng(), ynn = Math.pow(rng(), 1.2) * hzYn;
if (cloudField(xn, ynn) < 0.5) continue; // keep them inside the churn
if (inCypress(xn * W, ynn * H)) continue; // the flame keeps its silhouette
stamped(0.888 + rng() * 0.014, veil(xn * W, ynn * H, W * (0.03 + rng() * 0.08),
windSign * windStrength * 0.3 + (rng() - 0.5) * 0.1, H * (0.006 + rng() * 0.014),
mixHex('#46564e', hazeCol, 0.2), 0.08 + rng() * 0.10));
}
}
// -- swallows: single curved strokes stitching the air (0-4) -----------------
const nSwallow = Math.floor(rng() * 5); // 0-4
for (let i = 0; i < nSwallow; i++) {
const x0 = W * (0.08 + rng() * 0.84), y0 = H * (0.06 + rng() * hzYn * 0.5);
const span = W * (0.02 + rng() * 0.03);
const dir = rng() < 0.5 ? -1 : 1;
const dip = H * (0.004 + rng() * 0.010);
stamped(0.925 + rng() * 0.012, (c, jr) => stroke(c, jr, {
path: [[x0 - span * 0.5, y0 - dip * 0.6], [x0, y0 + dip], [x0 + span * 0.5, y0 - dip * 0.6]],
brush: 'round', width: Math.max(1, H * 0.0022),
alpha: 0.55 * hand.alphaBase * (night ? 0.5 : 1), taper: 'both', spacing: 0.3,
color: night ? '#2c3444' : '#2a3038',
}));
}
// -- the last notes: wind-swept glints along the crests ----------------------
for (let i = 0, nGlint = Math.round(100 * covK); i < nGlint; i++) {
const xn = rng(), yn = rng();
const g = gust(xn, yn);
if (g < 0.25) continue; // only the ridges catch the last light
const x = xn * W, y = hzY + yn * (H - hzY);
if (inCypress(x, y)) continue; // the flame keeps its silhouette
const col = night ? mixHex('#8a8840', '#c8c088', 0.3) : golden ? '#f8d888' : '#f8eec0';
stamped(0.948 + rng() * 0.035, smear(x, y, W * (0.006 + rng() * 0.014),
windLean * (0.75 + 0.35 * g), Math.max(0.9, H * 0.0025), col, 0.16 + rng() * 0.22, 0.5));
}
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);
}
},
};
// La Terrasse du Café — a homage to Van Gogh's "Café Terrace at Night"
// (Arles, 1888). The night is built ENTIRELY from blues and violets — no pure
// black anywhere, per Van Gogh's own instruction — and the light entirely
// from yellows and ambers: the café's glowing awning and wall on the left,
// its lamps spilling a pool of amber onto the cobbles, tables and chairs as
// loose dark-blue accents, a few tiny warm figures; and the street receding
// to the right into deep blue houses whose windows echo the café's amber in
// reverse (mostly dark glass, a few scattered warm lights). The complementary
// engine IS the piece: every shadow is a blue or violet, every glow is a
// yellow, and the hand rolled by conditions.technique decides HOW the paint
// sits — an impâtement hand slabs the wall proud, a vernis hand glazes the
// sky in veils, a pointillisme hand shivers the whole terrace into dots.
//
// The original famously paints NO stars — but this is the combinatorial
// atelier: a clear night earns a modest, cold starfield (far fewer than
// nuit's), the Big-Dipper arrangement appears at low probability (composition
// dice), and overcast, mist and rain thin or erase it. Night is home turf;
// dawn, dusk and golden hour become the café's TRANSITIONS — the glow burning
// smaller against a lightening or warming sky. Morning and noon are re-rolled
// away (see conditions()).
//
// Seasons and weather genuinely transform the painting: winter lays snow-blue
// cobbles and breath-warmth above the figures; summer warms the stone and
// hangs a wisteria pot at the terrace edge; rain doubles the amber in
// shimmering wet reflections and haloes every lamp; mist dissolves the
// street into the sky.
//
// Contract: identical to scenes/vallee.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. All touch primitives are realized
// through brushes.handFor(conditions.technique) + handDab.
import {
mulberry32, makeConditions, weatherOverlay,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, Horizon,
dab, roundDab, knife, rigger, stroke,
} from '../brushes.js';
let strokes = null;
// The café paints the hours its colour story can tell honestly: night
// (canonical), dusk (amber vs lingering rose), goldenHour (the glow against
// warm light) and dawn (the late transition — deep blue dominating, the café
// a small fire). Morning and noon are re-rolled away; the dedicated
// conditions stream is consumed identically every pass, so this stays
// deterministic. Eight tries; a morning/noon survivor paints as goldenHour.
const HOME_TOD = ['night', 'dusk', 'goldenHour', 'dawn'];
export default {
id: 'cafe',
title: 'La Terrasse du Café',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => {
let c = makeConditions(rng, constraints);
for (let i = 0; i < 12; i++) {
if (c.timeOfDay === 'night') return c; // the canonical hour keeps its weight
if (HOME_TOD.includes(c.timeOfDay) && rng() < 0.2) return c; // transitions welcome
c = makeConditions(rng, constraints);
}
// fall-through: a user-locked hour is honoured, otherwise night it is
if (HOME_TOD.includes(c.timeOfDay) || (constraints && constraints.timeOfDay)) return c;
return makeConditions(rng, { ...(constraints || {}), timeOfDay: 'night' });
},
subtitle: (c) => 'la terrasse, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1888));
const tod = cond.timeOfDay === 'morning' || cond.timeOfDay === 'noon'
? 'goldenHour' : cond.timeOfDay;
const night = tod === 'night', dusk = tod === 'dusk';
const dawn = tod === 'dawn', golden = tod === 'goldenHour';
const season = cond.season, weather = cond.weather;
const winter = season === 'winter', summer = season === 'summer';
const mirror = cond.motifSide === 'right'; // dice may hang the terrace right
const MX = (xn) => (mirror ? 1 - xn : xn);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
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 so rain and mist stay airy.
const stamped = (p, fn) => {
const jr = mulberry32((0xCAFE1 + 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 */ }
}
});
};
// -- THE HAND: the rolled technique decides how every touch sits -------
const hand = handFor(cond.technique);
// touch primitives routed through handDab; `plain` is the scene's own
// original stroke, executed verbatim by the default hand.
const touch = (x, y, w, c, a, ang = 0, squash = 1) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len: 0, ang, w, c, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: a, angle: ang, squash }),
});
const smear = (x, y, len, ang, w, c, a, squash = 1) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len, ang, w, c, a, squash,
plain: (g) => stroke(g, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: c, alpha: a, taper: 'none', spacing: 0.55,
}),
});
const slab = (x, y, len, ang, w, c, a) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len, ang, w, c, a, squash: 0.4,
plain: (g) => knife(g, jr, { x, y, size: Math.max(1, len * 0.5), color: c, alpha: a, angle: ang, squash: 0.35 }),
});
// -- palettes: the complementary engine, amber against deep blue --------
const wxGrey = {
clear: null, mist: ['#6e7c96', 0.30], overcast: ['#566079', 0.45],
rain: ['#46506b', 0.50], snow: ['#8a98b4', 0.28],
}[weather];
const SKY_BASE = {
night: ['#101a36', '#182750', '#233460', '#2e4066'],
dusk: ['#1c2a54', '#313a68', '#5c4468', '#96605a'],
goldenHour: ['#28407a', '#465284', '#8a7288', '#dca268'],
dawn: ['#20305e', '#3a4a7c', '#726a92', '#c2a482'],
}[tod];
const skyTint = (h) => {
let out = h;
if (wxGrey) out = mixHex(out, wxGrey[0], wxGrey[1]);
if (winter) out = mixHex(out, '#9ab0cc', 0.10);
if (summer) out = mixHex(out, '#c8a878', 0.05);
return out;
};
const skyCols = SKY_BASE.map(skyTint);
// the amber choir: awning, wall, lamps, glow — never pure white, never black
const ambers = {
awning: ['#e4c44a', '#eed45e', '#d8b840', '#f0dc74'],
wall: ['#dc9a2e', '#e2a63c', '#cc8622', '#eab054'],
core: '#f8e08a', glow: '#d99a3e', rim: '#f0c860',
};
const deepBlue = (h) => night ? h
: dusk ? mixHex(h, '#7c6480', 0.18)
: golden ? mixHex(h, '#8a6a72', 0.22)
: mixHex(h, '#7a86a8', 0.20); // dawn: the blue lightens, the café stays fire
const houseCols = ['#2a3460', '#232c52', '#323e6e', '#3c3a6c', '#1f2848'].map(deepBlue);
const cobbleCols = night
? ['#283254', '#202a4a', '#303c64', '#263052'].map(deepBlue)
: ['#323e6a', '#2a3458', '#3c4a76', '#303a60'].map(deepBlue);
const hazeCol = weather === 'mist' ? mixHex('#8b98b0', '#2c3860', night ? 0.35 : 0.15)
: mixHex('#4a5680', '#26304e', 0.35);
const hazer = (h, k) => mixHex(h, hazeCol, clamp01(k));
const snowGround = weather === 'snow' || (winter && weather !== 'rain');
// -- geometry: the one-point street plane -------------------------------
const vx = MX(0.60 + rng() * 0.16); // vanishing x, café-left space
const hzY = H * cond.horizonY;
const horizon = new Horizon(hzY, vx * W, 1.55, H);
const gpN = (xn, d) => horizon.computeGroundPlane(MX(xn) * W, d, H);
// the café frontage: base line from a near corner (A) receding to a far
// corner (B), the wall rising from it, the awning leaning out over the
// terrace. All in café-left space, MX'd at the plane.
const dWallNear = 0.97, dWallFar = 0.46;
const xWallNear = 0.20 + rng() * 0.04, xWallFar = 0.30 + rng() * 0.05;
const A = gpN(xWallNear, dWallNear), B = gpN(xWallFar, dWallFar);
const wallHN = H * 0.38, wallHF = H * 0.38 * 0.45;
const At = { x: A.x, y: A.y - wallHN }, Bt = { x: B.x, y: B.y - wallHF };
// the upper storey above the awning: deep violet facade, dark windows
const UAt = { x: At.x, y: At.y - H * 0.075 }, UBt = { x: Bt.x, y: Bt.y - H * 0.045 };
const dAwn = mirror ? -1 : 1;
const Af = { x: At.x + dAwn * W * 0.075, y: At.y + H * 0.085 };
const Bf = { x: Bt.x + dAwn * W * 0.05, y: Bt.y + H * 0.05 };
// glow sources: the door, two café windows, two hanging lamps
const lerp2 = (p, q, t) => ({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
const wallBase = (t) => lerp2(A, B, t);
const wallHAt = (t) => wallHN + (wallHF - wallHN) * t;
const doorT = 0.16 + rng() * 0.10;
const door = wallBase(doorT);
door.y -= wallHAt(doorT) * 0.24;
const winT1 = 0.50 + rng() * 0.08, winT2 = 0.76 + rng() * 0.08;
const win1 = wallBase(winT1), win2 = wallBase(winT2);
win1.y -= wallHAt(winT1) * 0.58; win2.y -= wallHAt(winT2) * 0.58;
const lampU1 = 0.30 + rng() * 0.10, lampU2 = 0.66 + rng() * 0.10;
const lamp1 = lerp2(Af, Bf, lampU1), lamp2 = lerp2(Af, Bf, lampU2);
lamp1.y += H * 0.042; lamp2.y += H * 0.038;
const glows = [
{ x: door.x, y: door.y, r: H * 0.22, s: 1.0 },
{ x: win1.x, y: win1.y, r: H * 0.13, s: 0.7 },
{ x: win2.x, y: win2.y, r: H * 0.12, s: 0.6 },
{ x: lamp1.x, y: lamp1.y, r: H * 0.18, s: 0.9 },
{ x: lamp2.x, y: lamp2.y, r: H * 0.15, s: 0.7 },
];
const glow = (x, y) => {
let v = 0;
for (const g of glows) {
const dx = (x - g.x) / g.r, dy = (y - g.y) / g.r;
v += g.s * Math.exp(-(dx * dx + dy * dy));
}
return v;
};
// ground points hidden by verticals: tested in SCREEN space — a ground
// point is occluded when its projection lands above a facade's base line
// (depth alone lies: near-reference x drifts with depth).
const occluders = []; // { x0, x1, y } screen-space base segments
const hidden = (xn, d) => {
const g = gpN(xn, d);
// the café frontage: base line A->B on screen
if (g.x > Math.min(A.x, B.x) - W * 0.012 && g.x < Math.max(A.x, B.x) + W * 0.012) {
const t = clamp01((g.x - B.x) / (A.x - B.x || 1));
if (g.y < B.y + (A.y - B.y) * t - H * 0.006) return true;
}
for (const o of occluders) {
if (g.x > o.x0 && g.x < o.x1 && g.y < o.y - H * 0.004) return true;
}
return false;
};
// -- prime: a vertical breath of the night, no flat black ---------------
{
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, skyCols[0]);
g.addColorStop(0.45, skyCols[1]);
g.addColorStop(clamp01(hzY / H), skyCols[2]);
g.addColorStop(clamp01(hzY / H + 0.11), mixHex(skyCols[2], '#1a2440', 0.45));
g.addColorStop(1, mixHex(skyCols[1], '#1a2440', 0.40));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// ---- sky: broad blue smears, the deep of the night --------------------
for (let i = 0; i < 540; i++) {
const x = -W * 0.03 + rng() * W * 1.06;
const yn = Math.pow(rng(), 0.9);
const y = H * 0.01 + yn * Math.max(4, hzY - H * 0.02);
const band = yn; // 0 high, 1 near horizon
const col = hazer(skyCols[Math.min(3, 1 + Math.floor(band * 2.6))],
0.12 * band * (weather === 'mist' ? 2.2 : 0.6));
const ang = (rng() - 0.5) * 0.10 + (dusk || golden ? 0.04 : 0);
const len = W * (0.06 + rng() * 0.15);
const wd = H * (0.008 + rng() * 0.018) * (0.75 + band * 0.5);
const a = 0.14 + rng() * 0.24;
stamped(0.02 + 0.13 * band + rng() * 0.015, smear(x, y, len, ang, wd, col, a));
}
// a warm breath low in the sky at dusk / golden hour / dawn
if (!night) {
const warm = dusk ? '#a4685c' : golden ? '#d89a5e' : '#c09878';
for (let i = 0; i < 90; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY - Math.pow(rng(), 1.8) * H * 0.16;
stamped(0.10 + rng() * 0.06, smear(x, y, W * (0.04 + rng() * 0.10), (rng() - 0.5) * 0.08,
H * (0.006 + rng() * 0.014), mixHex(warm, skyCols[3], 0.4), 0.10 + rng() * 0.16));
}
}
// the low sun of the transitions: a modest warm disc with a soft halo
if (cond.sunVisible && !night) {
const sx = cond.sunX * W, sy = Math.max(H * 0.05, Math.min(cond.sunY * H, hzY - H * 0.03));
const core = golden ? '#f4c882' : dusk ? '#e8a878' : '#f0d2a0';
for (let i = 0; i < 26; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.7) * H * 0.017;
stamped(0.135 + rng() * 0.015, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.005 + rng() * 0.007), i < 15 ? core : mixHex(core, '#f8f0e0', 0.3), 0.30 + rng() * 0.30));
}
for (let i = 0; i < 14; i++) {
const a = rng() * 6.2832, r = H * (0.028 + rng() * 0.05);
stamped(0.145 + rng() * 0.015, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.012 + rng() * 0.018), mixHex(core, skyCols[2], 0.5), 0.05 + rng() * 0.07));
}
}
// ---- stars: modest, cold, few — and rarely, the Dipper ----------------
{
const starWx = { clear: 1, mist: 0.30, overcast: 0.10, rain: 0.22, snow: 0.20 }[weather];
const starBase = night ? 66 : dusk ? 30 : golden ? 12 : 8;
const nStars = Math.round(starBase * starWx);
const starCol = () => wpick([['#d8e4f8', 5], ['#e8eefa', 3], ['#c2d2ee', 3], ['#f2f4f8', 1]]);
for (let i = 0; i < nStars; i++) {
const x = rng() * W;
const y = rng() * Math.max(8, hzY - H * 0.05);
const bright = rng();
const w = H * (0.0016 + bright * bright * 0.004);
const a = 0.35 + bright * 0.5;
stamped(0.17 + rng() * 0.06, touch(x, y, w, starCol(), a));
if (bright > 0.82) { // the brightest few get a cold whisper of corona
stamped(0.19 + rng() * 0.05, (c) => dab(c, x, y, w * 3.2, mixHex(starCol(), '#5a6a9a', 0.5), 0.10 + rng() * 0.08));
}
}
if (weather === 'clear' && night && rng() < 0.25) { // composition dice: the Dipper
const bx = W * (0.10 + rng() * 0.28), by = H * (0.06 + rng() * 0.14);
const sc = W * (0.05 + rng() * 0.03), rot = (rng() - 0.5) * 1.2;
const bowl = [[0, 0], [0.7, -0.18], [1.3, 0.12], [0.9, 0.66]];
const handle = [[1.3, 0.12], [1.9, 0.42], [2.45, 0.95], [3.1, 1.15]];
for (const [px, py] of bowl.concat(handle)) {
const x = bx + (px * Math.cos(rot) - py * Math.sin(rot)) * sc;
const y = by + (px * Math.sin(rot) + py * Math.cos(rot)) * sc * 0.6;
if (x < W * 0.02 || x > W * 0.98 || y > hzY - H * 0.06) continue;
stamped(0.20 + rng() * 0.03, touch(x, y, H * 0.0042, '#eaf0fa', 0.85));
stamped(0.21 + rng() * 0.03, (c) => dab(c, x, y, H * 0.011, '#8a9cc8', 0.10));
}
}
}
// ---- houses: cool violet-blue masses receding on the far side ---------
const houseRows = [
{ d: 0.15, n: 5, hMul: 0.9, p: 0.23 },
{ d: 0.33, n: 6, hMul: 1.0, p: 0.30 },
{ d: 0.56, n: 6, hMul: 0.85, p: 0.37 },
];
const litWindowProb = (weather === 'rain' ? 0.16 : 0.12) * (night ? 1 : dusk ? 0.8 : 0.4);
for (let ri = 0; ri < houseRows.length; ri++) {
const row = houseRows[ri];
const gscr = horizon.ground(row.d);
const baseY = hzY + (H - hzY) * gscr;
let cursorXn = 1.10;
const nB = row.n + Math.floor(rng() * 2);
for (let bi = 0; bi < nB; bi++) {
const wNear = W * (0.06 + rng() * 0.10);
cursorXn -= (wNear + W * 0.008) / W;
if (cursorXn < 0.52) break; // never cross toward the café
const sc = Math.max(0.02, gscr);
const gw = gpN(cursorXn + (wNear / W), row.d); // right edge on screen
const bw = wNear * sc;
if (bw < 4) continue;
const bx = gw.x - bw;
const hh = Math.max(H * 0.02, H * (0.16 + rng() * 0.22) * (0.22 + sc * 1.15) * row.hMul);
const byBase = baseY + (rng() - 0.5) * H * 0.01 * sc;
occluders.push({ x0: bx - bw * 0.08, x1: bx + bw * 1.08, y: byBase });
const col0 = hazer(houseCols[Math.floor(rng() * houseCols.length)],
(1 - sc) * (weather === 'mist' ? 0.60 : 0.38));
const nStrokes = Math.round(14 + (bw * hh) / (W * H) * 2400);
const rowDark = ri >= 1 ? 0.22 : 0.10; // near rows sit darker against the lit street
for (let i = 0; i < nStrokes; i++) {
const x = bx + rng() * bw;
const y = byBase - Math.pow(rng(), 0.85) * hh;
const shade = y < byBase - hh * 0.75 ? 0.16 : (rng() - 0.5) * 0.12;
const col = mixHex(col0, '#151d3a', clamp01(rowDark + shade * 0.5));
stamped(row.p + rng() * 0.05, smear(x, y,
Math.min(bw * 0.7, W * (0.02 + rng() * 0.05)) * (0.5 + sc),
(rng() - 0.5) * 0.24, Math.max(1.4, H * (0.008 + rng() * 0.014) * (0.4 + sc)),
col, 0.30 + rng() * 0.30));
}
// roof cap: a slightly darker crown, chimneys as small notes
for (let i = 0; i < Math.round(3 + bw * 0.08); i++) {
const x = bx + rng() * bw;
stamped(row.p + 0.03 + rng() * 0.02, smear(x, byBase - hh + (rng() - 0.5) * H * 0.006 * sc,
bw * (0.10 + rng() * 0.2), (rng() - 0.5) * 0.15, Math.max(1.2, H * 0.008 * sc),
mixHex(col0, '#10182f', 0.30), 0.35 + rng() * 0.25));
}
for (let ci = 0; ci < (rng() < 0.5 ? 1 : 2); ci++) {
const cw = Math.max(1.5, bw * 0.05);
const cx = bx + bw * (0.15 + rng() * 0.7);
stamped(row.p + 0.045 + rng() * 0.01, smear(cx, byBase - hh - hh * 0.03, hh * 0.06, -Math.PI / 2,
cw, mixHex(col0, '#10182f', 0.35), 0.5));
}
// windows: dark glass mostly, a few echoing the café's amber in reverse
const cols = Math.max(1, Math.min(6, Math.round(bw / (W * 0.016))));
const rowsW = Math.max(1, Math.min(5, Math.round(hh / (H * 0.06))));
for (let cxi = 0; cxi < cols; cxi++) {
for (let cyi = 0; cyi < rowsW; cyi++) {
if (rng() < 0.35) continue;
const wx = bx + bw * (0.14 + (0.72 * cxi) / Math.max(1, cols - 1 || 1));
const wy = byBase - hh * (0.16 + (0.66 * cyi) / Math.max(1, rowsW - 1 || 1));
const wwx = Math.max(1.2, bw * 0.10), wwy = Math.max(1.4, hh * 0.05);
const isLit = rng() < litWindowProb;
const prog = 0.40 + 0.07 * (1 - row.d / 0.6) + rng() * 0.02;
if (isLit) {
const lc = wpick([['#f0c860', 5], ['#e8b850', 3], ['#f6dc86', 2]]);
stamped(prog, touch(wx, wy, Math.max(2, wwx * 1.1), lc, 0.75, 0, wwy / wwx));
stamped(prog + 0.004, (c) => dab(c, wx, wy, wwx * 2.4, mixHex(lc, '#5a4a70', 0.55), 0.14));
} else {
stamped(prog, touch(wx, wy, wwx * 0.9, mixHex('#161e3c', col0, 0.35), 0.5, 0, wwy / wwx));
}
}
}
}
}
// ---- lampposts: gas lights marching toward the vanishing point --------
for (let li = 0; li < 4; li++) {
const d = 0.30 + li * 0.19 + rng() * 0.05;
const g = gpN(0.455 + (rng() - 0.5) * 0.02, d);
const sc = g.scale;
const postH = H * 0.20 * (0.35 + sc);
const col = mixHex('#3a4470', '#1c2444', 0.35);
const prog = 0.48 + 0.06 * (1 - d);
stamped(prog, (c, jr) => rigger(c, jr, { x: g.x, y: g.y, length: postH, color: col,
alpha: 0.75 * hand.alphaBase, angle: -Math.PI / 2, width: Math.max(0.8, W * 0.0022 * (0.35 + sc)) }));
stamped(prog + 0.004, smear(g.x, g.y - postH, postH * 0.07, 0, Math.max(1.2, W * 0.003 * (0.35 + sc)),
mixHex(col, '#121a34', 0.3), 0.7));
const lampY = g.y - postH - H * 0.008 * sc;
const glowAmp = weather === 'overcast' || weather === 'mist' ? 1.25 : 1;
stamped(prog + 0.006, touch(g.x, lampY, Math.max(1.6, W * 0.006 * sc), ambers.core, 0.9 * glowAmp));
for (let k = 0; k < 5; k++) {
const a = rng() * 6.2832, r = W * 0.004 * sc * (1 + rng() * 1.6);
stamped(prog + 0.008 + rng() * 0.01, (c) => dab(c, g.x + Math.cos(a) * r, lampY + Math.sin(a) * r * 0.8,
Math.max(1.4, W * 0.008 * sc * (1 + rng())), mixHex(ambers.glow, '#4a3a70', 0.35), (0.08 + rng() * 0.10) * glowAmp));
}
if (weather === 'rain') { // the wet street carries the lamp downward
for (let k = 0; k < 5; k++) {
const rx = g.x + (rng() - 0.5) * 2, ry = g.y + rng() * H * 0.02 * sc;
const rlen = H * (0.03 + rng() * 0.06) * sc, ra = 0.10 + rng() * 0.12;
const rang = Math.PI / 2 + (rng() - 0.5) * 0.08;
stamped(0.90 + rng() * 0.02, (c, jr) => rigger(c, jr, { x: rx, y: ry, length: rlen,
color: ambers.glow, alpha: ra, angle: rang, width: Math.max(0.8, 1.6 * sc) }));
}
}
}
// ---- the street: cobbles under one-point perspective ------------------
// Ground recedes to the vanishing point; the café's amber pools over the
// terrace-side cobbles via glow(); the far street stays deep blue.
for (let i = 0; i < 1250; i++) {
const d = 1 - Math.pow(rng(), 1.55) * 0.92; // bias toward the near plane
const xn = -0.05 + rng() * 1.12;
if (hidden(xn, d)) continue;
const g = gpN(xn, d);
const sc = g.scale;
let col = pick(cobbleCols);
const gl = glow(g.x, g.y);
// the terrace side drinks the amber; the far street keeps its blue
const terracePull = clamp01(gl * 0.95
+ (d > 0.5 ? 0.14 : 0.04) * Math.max(0, 1 - Math.abs(g.x / W - (mirror ? 0.72 : 0.28)) * 3));
col = mixHex(col, ambers.glow, terracePull * (night ? 0.66 : 0.46));
if (snowGround) col = mixHex(col, pick(['#c8d4e4', '#bccadb', '#d4deec']), 0.45 + rng() * 0.30);
if (weather === 'rain') col = mixHex(col, '#4c5a80', 0.14);
col = hazer(col, (1 - sc) * (weather === 'mist' ? 0.55 : 0.25));
const rx = Math.max(1.2, W * (0.005 + rng() * 0.012) * (0.3 + sc * 1.3));
const ang = (rng() - 0.5) * 0.16;
const a = (0.26 + rng() * 0.30) * (0.55 + 0.45 * Math.min(1, d * 1.6));
stamped(0.52 + 0.28 * (1 - d) + rng() * 0.02,
smear(g.x, g.y, rx * (1.6 + rng() * 1.4), ang, rx * 0.9, col, a, 0.45));
}
// the amber pool: the lamps' light lying on the terrace floor
for (let i = 0; i < 130; i++) {
const d = 0.55 + Math.pow(rng(), 0.8) * 0.43;
const xn = 0.04 + rng() * 0.30;
if (hidden(xn, d)) continue;
const g = gpN(xn, d);
const gl = glow(g.x, g.y);
if (gl < 0.18) continue;
stamped(0.795 + rng() * 0.02, smear(g.x, g.y, W * (0.010 + rng() * 0.020) * (0.5 + g.scale),
(rng() - 0.5) * 0.15, Math.max(1.5, H * 0.006 * (0.4 + g.scale)),
mixHex(ambers.glow, ambers.core, rng() * 0.5), 0.08 + gl * 0.12, 0.5));
}
// ---- the café: wall, awning, glow — the amber heart -------------------
// upper storey first: a quiet violet band with dark windows, against the sky
{
for (let i = 0; i < 90; i++) {
const t = rng();
const v = Math.pow(rng(), 0.9);
const bot = lerp2(At, Bt, t), top = lerp2(UAt, UBt, t);
const x = bot.x + (top.x - bot.x) * v, y = bot.y + (top.y - bot.y) * v;
const col = hazer(mixHex('#3a3460', '#2c2a50', rng() * 0.5), 0.08);
stamped(0.565 + rng() * 0.018, smear(x, y, W * (0.015 + rng() * 0.035),
Math.atan2(Bt.y - At.y, Bt.x - At.x) + (rng() - 0.5) * 0.3,
Math.max(1.4, H * (0.008 + rng() * 0.012)), col, 0.40 + rng() * 0.30));
}
const nUpWin = 3 + Math.floor(rng() * 3);
for (let w = 0; w < nUpWin; w++) {
const t = 0.12 + (w / Math.max(1, nUpWin - 1)) * 0.76 + (rng() - 0.5) * 0.05;
const bot = lerp2(At, Bt, t), top = lerp2(UAt, UBt, t);
const wx = bot.x + (top.x - bot.x) * 0.5, wy = bot.y + (top.y - bot.y) * 0.52;
stamped(0.578 + rng() * 0.01, touch(wx, wy, Math.max(2.2, H * 0.016), mixHex('#1c2444', '#2a3258', rng() * 0.4), 0.65, 0, 0.62));
}
}
// wall: warm stone under the lamps, violet-shadowed under the awning rim
for (let i = 0; i < 620; i++) {
const t = rng();
const hgt = Math.pow(rng(), 1.05);
const b = wallBase(t), hh = wallHAt(t);
const x = b.x + (At.x + (Bt.x - At.x) * t - b.x) * hgt;
const y = b.y - hh * hgt;
let col = pick(ambers.wall);
const gl = glow(x, y);
col = mixHex(col, ambers.core, clamp01(gl * 0.22));
// under the awning the wall falls into deep warm violet — never black
const awnY = At.y + (Bt.y - At.y) * t + H * 0.075;
if (y > awnY) col = mixHex(col, '#5c4468', 0.42 * clamp01((y - awnY) / (H * 0.12)) + 0.10);
if (winter) col = mixHex(col, '#b0a2b8', 0.10);
if (summer) col = mixHex(col, '#e8b060', 0.08);
col = mixHex(col, hazeCol, 0.05);
const len = W * (0.016 + rng() * 0.042);
const ang = Math.atan2(B.y - A.y, B.x - A.x) + (rng() - 0.5) * 0.5;
stamped(0.58 + 0.06 * hgt + rng() * 0.02,
smear(x, y, len, ang, Math.max(1.5, H * (0.008 + rng() * 0.014)), col, 0.45 + rng() * 0.35));
// a few bright rim accents where the lamps strike the wall hardest
if (gl > 0.85 && rng() < 0.10) {
stamped(0.645 + rng() * 0.014, touch(x, y, Math.max(1.2, H * 0.004 * (0.5 + rng())),
wpick([['#f4d470', 5], ['#f8e08a', 3], ['#e8b44e', 2]]), 0.40 + rng() * 0.25));
}
}
// contact shadow: the wall seats onto its base line in deep violet
for (let i = 0; i < 60; i++) {
const t = rng();
const b = wallBase(t);
stamped(0.665 + rng() * 0.012, smear(b.x + (rng() - 0.5) * W * 0.006, b.y - H * (0.002 + rng() * 0.010),
W * (0.012 + rng() * 0.024), Math.atan2(B.y - A.y, B.x - A.x) + (rng() - 0.5) * 0.2,
Math.max(1.6, H * 0.009), mixHex('#3a3050', ambers.glow, 0.18 + rng() * 0.15), 0.45 + rng() * 0.30));
}
// the glow cores: door and windows, struck as layered amber embers
{
const cores = [
{ p: door, w: H * 0.055, n: 26, halo: 18 },
{ p: win1, w: H * 0.028, n: 14, halo: 10 },
{ p: win2, w: H * 0.024, n: 12, halo: 8 },
];
for (const cr of cores) {
for (let i = 0; i < cr.n; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.8) * cr.w;
stamped(0.735 + rng() * 0.02, touch(cr.p.x + Math.cos(a) * r, cr.p.y + Math.sin(a) * r * 1.25,
H * (0.004 + rng() * 0.007), wpick([['#f8e08a', 5], ['#f4d470', 3], ['#fbe89a', 2]]), 0.65 + rng() * 0.30));
}
for (let i = 0; i < cr.halo; i++) {
const a = rng() * 6.2832, r = cr.w * (1.1 + rng() * 1.5);
stamped(0.75 + rng() * 0.02, (c) => dab(c, cr.p.x + Math.cos(a) * r, cr.p.y + Math.sin(a) * r,
H * (0.010 + rng() * 0.018), mixHex(ambers.glow, '#5a4a70', 0.40), 0.08 + rng() * 0.10));
}
}
// awning: the great yellow canopy, its top catching the lamps' light
const angAwn = Math.atan2(Bf.y - Af.y, Bf.x - Af.x);
const awnLen = Math.hypot(Bf.x - Af.x, Bf.y - Af.y);
const angBack = Math.atan2(Bt.y - At.y, Bt.x - At.x);
for (let i = 0; i < 240; i++) {
const u = rng();
const v = Math.pow(rng(), 0.85); // 0 back edge (wall), 1 front edge
const back = lerp2(At, Bt, u), front = lerp2(Af, Bf, u);
const x = back.x + (front.x - back.x) * v;
const y = back.y + (front.y - back.y) * v;
let col = pick(ambers.awning);
col = mixHex(col, ambers.glow, 0.22 * (1 - v)); // shading toward the wall
const gl = glow(x, y);
col = mixHex(col, ambers.core, clamp01(gl * 0.35));
if (weather === 'snow') col = mixHex(col, '#e8eef6', 0.30 * v);
stamped(0.68 + 0.05 * v + rng() * 0.015, smear(x, y, Math.min(awnLen * 0.18, W * (0.02 + rng() * 0.04)),
angBack + (rng() - 0.5) * 0.2, Math.max(1.6, H * (0.010 + rng() * 0.014)), col, 0.50 + rng() * 0.35));
}
// underside: the deep violet shadow band under the front edge
for (let i = 0; i < 56; i++) {
const u = rng();
const front = lerp2(Af, Bf, u);
const y = front.y + H * (0.004 + rng() * 0.020);
stamped(0.725 + rng() * 0.012, smear(front.x + (rng() - 0.5) * W * 0.01, y,
W * (0.015 + rng() * 0.03), angAwn + (rng() - 0.5) * 0.2, Math.max(1.6, H * 0.010),
mixHex('#3a2c50', ambers.glow, 0.15 + rng() * 0.2), 0.50 + rng() * 0.30));
}
// scalloped fringe: small alternating dabs below the front edge
const nScallop = 9 + Math.floor(rng() * 4);
for (let i = 0; i < nScallop; i++) {
const u = (i + 0.5) / nScallop;
const front = lerp2(Af, Bf, u);
stamped(0.730 + rng() * 0.008, touch(front.x, front.y + H * 0.016, Math.max(2.4, H * 0.014),
mixHex('#3a2e50', ambers.glow, 0.18), 0.55, 0, 0.7));
}
// the two hanging lamps: cord, brass, and a halo the night leans into
for (const ls of [{ p: lamp1, u: lampU1 }, { p: lamp2, u: lampU2 }]) {
const attach = lerp2(Af, Bf, ls.u);
stamped(0.74 + rng() * 0.01, (c, jr) => rigger(c, jr, { x: attach.x, y: attach.y, length: H * 0.035,
color: mixHex('#2a2440', ambers.glow, 0.25), alpha: 0.6, angle: Math.PI / 2, width: 1.1 }));
const lp = { x: attach.x, y: attach.y + H * 0.040 };
const glowAmp = weather === 'overcast' || weather === 'mist' ? 1.3 : 1;
for (let i = 0; i < 16; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.75) * H * 0.014;
stamped(0.755 + rng() * 0.015, touch(lp.x + Math.cos(a) * r, lp.y + Math.sin(a) * r,
H * (0.005 + rng() * 0.008), i < 9 ? ambers.core : '#f4d470', (0.60 + rng() * 0.30) * glowAmp));
}
for (let i = 0; i < 16; i++) {
const a = rng() * 6.2832, r = H * (0.020 + rng() * 0.045);
stamped(0.765 + rng() * 0.02, (c) => dab(c, lp.x + Math.cos(a) * r, lp.y + Math.sin(a) * r * 0.85,
H * (0.014 + rng() * 0.022), mixHex(ambers.glow, '#5a4a70', 0.38), (0.06 + rng() * 0.10) * glowAmp));
}
}
}
// ---- terrace furniture: loose dark-blue accents, amber-rimmed ---------
{
const nSets = 16 + Math.floor(rng() * 6);
for (let i = 0; i < nSets; i++) {
const d = 0.60 + Math.pow(rng(), 0.9) * 0.36;
const xn = 0.05 + rng() * 0.26;
if (hidden(xn, d)) continue;
const g = gpN(xn, d);
const sc = g.scale;
const gl = glow(g.x, g.y);
const lit = clamp01(0.15 + gl * 0.7);
const topCol = mixHex('#2a3256', ambers.rim, lit * 0.55);
const darkCol = '#1e2648';
const ts = Math.max(3.5, H * 0.042 * sc);
const prog = 0.815 + rng() * 0.02;
// table top: an amber-rimmed ellipse on a dark pedestal
stamped(prog, smear(g.x, g.y - ts * 1.1, ts * 1.6, (rng() - 0.5) * 0.2, ts * 0.55, topCol, 0.55 + rng() * 0.3, 0.45));
stamped(prog + 0.003, (c, jr) => rigger(c, jr, { x: g.x, y: g.y - ts * 0.9, length: ts * 0.9,
color: darkCol, alpha: 0.6, angle: Math.PI / 2, width: Math.max(0.8, ts * 0.14) }));
// chairs: loose slabs, one catching the light on the lamp side
const chN = rng() < 0.65 ? 2 : 1;
for (let ch = 0; ch < chN; ch++) {
const side = rng() < 0.5 ? -1 : 1;
const cx = g.x + side * ts * (0.9 + rng() * 0.5);
const cy = g.y + (rng() - 0.3) * ts * 0.5;
const cs = Math.max(1.6, ts * 0.5);
const angC = -Math.PI / 2 + side * 0.25 + (rng() - 0.5) * 0.2;
stamped(prog + 0.006 + ch * 0.002, slab(cx, cy, cs * 1.4, angC, cs * 0.5, darkCol, 0.6));
stamped(prog + 0.009 + ch * 0.002, (c) => dab(c, cx - side * cs * 0.2, cy - cs * 0.55,
Math.max(0.8, cs * 0.16), ambers.rim, 0.30 + lit * 0.25, -Math.PI / 2, 1.8));
}
}
}
// ---- figures: a few tiny warm presences, suggested in < 50 strokes ----
{
const nFig = 5 + Math.floor(rng() * 3);
const figSpots = [];
for (let i = 0; i < nFig; i++) {
const d = 0.62 + rng() * 0.32;
const xn = 0.06 + rng() * 0.22;
if (hidden(xn, d)) continue;
figSpots.push({ d, xn });
}
for (const fs of figSpots) {
const g = gpN(fs.xn, fs.d);
const sc = g.scale;
const fh = H * 0.070 * (0.35 + sc * 1.1);
const coat = mixHex('#2c3050', '#8a5a30', 0.25 + rng() * 0.35);
const prog = 0.86 + rng() * 0.02;
stamped(prog, slab(g.x, g.y - fh * 0.42, fh * 0.8, -Math.PI / 2 + (rng() - 0.5) * 0.15, fh * 0.28, coat, 0.75));
stamped(prog + 0.004, touch(g.x, g.y - fh * 0.92, Math.max(1.2, fh * 0.16), '#e0a860', 0.8));
if (winter) stamped(0.945 + rng() * 0.02, (c) => dab(c, g.x, g.y - fh * 1.02,
Math.max(1.5, fh * 0.22), '#e8b080', 0.045 + rng() * 0.03, 0, 0.8)); // breath-warmth
}
const nStreet = rng() < 0.6 ? 2 : 1;
for (let i = 0; i < nStreet; i++) { // walkers on the far pavement, cooler
const d = 0.34 + rng() * 0.18;
const xn = 0.44 + rng() * 0.14;
const g = gpN(xn, d);
const sc = g.scale;
const fh = Math.max(4, H * 0.05 * (0.35 + sc));
const coat = mixHex('#262c4e', '#3a3a5e', rng() * 0.5);
const prog = 0.865 + rng() * 0.015;
stamped(prog, smear(g.x, g.y - fh * 0.45, fh * 0.75, -Math.PI / 2 + (rng() - 0.5) * 0.2, fh * 0.26, coat, 0.7, 0.55));
stamped(prog + 0.004, touch(g.x, g.y - fh * 0.92, Math.max(0.9, fh * 0.14), mixHex('#e0a860', '#8a8aa0', 0.3), 0.7));
}
}
// ---- weather's own hand: reflections, mist, snow ----------------------
if (weather === 'rain') { // wet cobbles double the amber
for (const src of glows) {
const nR = Math.round(8 + src.s * 8);
for (let k = 0; k < nR; k++) {
const rx = src.x + (rng() - 0.5) * src.r * 0.8;
const ry = src.y + src.r * (0.2 + rng() * 0.6);
const rlen = H * (0.03 + rng() * 0.09), ra = 0.08 + rng() * 0.14;
const rang = Math.PI / 2 + (rng() - 0.5) * 0.1, rw = 1.5 + rng() * 3;
stamped(0.90 + rng() * 0.02, (c, jr) => rigger(c, jr, {
x: rx, y: ry, length: rlen, color: ambers.glow, alpha: ra,
angle: rang, width: rw,
}));
}
}
for (let k = 0; k < 60; k++) { // shimmering dashes doubling the glow
const d = 0.5 + rng() * 0.5;
const xn = 0.02 + rng() * 0.4;
const g = gpN(xn, d);
stamped(0.91 + rng() * 0.02, smear(g.x, g.y, W * 0.01 * (0.5 + g.scale) * (1 + rng()), (rng() - 0.5) * 0.1,
Math.max(1, H * 0.003 * g.scale), ambers.rim, 0.10 + rng() * 0.15, 0.4));
}
}
if (weather === 'mist' || weather === 'overcast') { // the street dissolves
const nBands = weather === 'mist' ? 34 : 18;
const wallX0 = Math.min(A.x, B.x) - W * 0.02, wallX1 = Math.max(A.x, B.x) + W * 0.02;
const wallTopY = Math.min(UAt.y, UBt.y) - H * 0.01;
const wallBotY = Math.max(A.y, B.y) + H * 0.02;
for (let i = 0; i < nBands; i++) {
const d = 0.15 + rng() * 0.5;
const g = gpN(0.5, d);
const y = g.y + (rng() - 0.4) * H * 0.10;
const x = rng() * W;
// the café façade stays solid: mist drifts around it, not through it
if (x > wallX0 && x < wallX1 && y > wallTopY && y < wallBotY) continue;
stamped(0.42 + rng() * 0.30, smear(x, y, W * (0.10 + rng() * 0.26), (rng() - 0.5) * 0.05,
H * (0.012 + rng() * 0.03), hazeCol, 0.05 + rng() * 0.08));
}
}
if (snowGround) { // undisturbed snow pockets between the cobbles
for (let i = 0; i < 70; i++) {
const d = 0.4 + rng() * 0.6, xn = -0.04 + rng() * 1.08;
if (hidden(xn, d)) continue;
const g = gpN(xn, d);
stamped(0.88 + rng() * 0.03, touch(g.x, g.y, Math.max(2, W * 0.010 * g.scale),
pick(['#dfe8f2', '#eef4fa', '#d0dcea']), 0.35 + rng() * 0.30, 0, 0.5));
}
}
if (summer) { // the wisteria pot at the terrace edge
const g = gpN(0.26 + rng() * 0.06, 0.86 + rng() * 0.08);
const s = Math.max(3, H * 0.026 * (0.4 + g.scale));
const prog = 0.945 + rng() * 0.01;
stamped(prog, smear(g.x, g.y - s * 1.6, s * 1.4, -Math.PI / 2, s * 0.9, '#8a4a30', 0.7, 0.7));
stamped(prog + 0.004, smear(g.x, g.y - s * 2.6, s * 2.2, -Math.PI / 2 + 0.1, s * 0.5, '#3c5238', 0.65, 0.6));
for (let k = 0; k < 20; k++) { // hanging violet racemes
const a = rng() * 6.2832, r = rng() * s * 1.1;
stamped(prog + 0.006 + rng() * 0.01, touch(g.x + Math.cos(a) * r,
g.y - s * (2.2 + rng() * 1.6) + Math.sin(a) * r * 0.4,
Math.max(1, s * 0.22), wpick([['#9a86c8', 5], ['#8a76bc', 3], ['#b0a0d8', 2]]), 0.5 + rng() * 0.3, -Math.PI / 2, 1.5));
}
}
// ---- final glints: the brightest notes, struck last -------------------
for (let i = 0; i < 60; i++) {
const d = 0.45 + Math.pow(rng(), 0.8) * 0.55;
const xn = rng() < 0.72 ? 0.02 + rng() * 0.38 : 0.42 + rng() * 0.5;
if (hidden(xn, d)) continue;
const g = gpN(xn, d);
const gl = glow(g.x, g.y);
if (gl < 0.15 && weather !== 'rain') continue;
stamped(0.955 + rng() * 0.03, smear(g.x, g.y, W * 0.006 * (0.5 + g.scale) * (1 + rng() * 2), (rng() - 0.5) * 0.08,
Math.max(0.8, H * 0.0022 * (0.4 + g.scale)), wpick([['#f0c860', 5], ['#f8e08a', 3], ['#e8b44e', 2]]), 0.30 + rng() * 0.35));
}
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);
}
},
};
// Le Jardin de Giverny — a homage to Monet's garden: the Japanese footbridge
// over the water-lily pond, wisteria hanging, the garden as a green cathedral
// where water and vegetation mirror each other.
//
// The composition closes over the water: a wisteria canopy runs across the
// top edge and lets its lilac cascades hang, sometimes trailing to the
// waterline itself; a sliver of sky breathes between the canopy and the far
// bank's tree line. The pond fills the lower half and is painted as a MIRROR
// IN MOTION — broken horizontal strokes carrying sky light, wisteria violet
// and bank greens, never a flat fill. The Japanese bridge crosses the middle
// distance as curved green strokes, and its reflection completes the circle
// beneath: each bridge touch is stamped with its own wobbling twin in the
// water, one gesture folded in two. Lily pads float in clusters (layered
// greens over violet-blue shadows), a dice-gated handful carries blossoms;
// irises and agapanthus stand on the banks, a willow trails on one side
// (composition dice), and the whole garden is wrapped in layered greens —
// yellow-green light against blue-green depth, so it never goes muddy.
//
// Conditions genuinely transform it: morning lays cool-pink mist over the
// water, noon strikes high-contrast greens, golden hour gilds the pond and
// rims the bridge, dusk and night sink the garden into blue-violet (lily
// pads become dark shapes; a few fireflies strike at night); spring blossoms
// the banks, summer loads the foliage, autumn russets the canopy and floats
// russet leaves on the pond, winter bares the bridge, pales the water and
// bands the banks with snow. Rain rings the pond; mist ghosts the bridge.
//
// Contract: identical to scenes/cafe.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; nothing consumes randomness at draw time. All touch
// primitives route through brushes.handFor(conditions.technique) + handDab.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, Horizon, aerialFade,
dab, roundDab, knife, rigger, stroke,
} from '../brushes.js';
let strokes = null;
export default {
id: 'jardin',
title: 'Le Jardin de Giverny',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Giverny, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1899));
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', golden = tod === 'goldenHour';
const morning = tod === 'morning', dawn = tod === 'dawn', noon = tod === 'noon';
const winter = season === 'winter', spring = season === 'spring';
const autumn = season === 'autumn', summer = season === 'summer';
const willowSide = cond.motifSide === 'right' ? 1 : -1;
const wisteriaGone = autumn || winter; // the racemes only hang in the warm seasons
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
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 ~12% of strokes so rain and mist stay airy.
const stamped = (p, fn) => {
const jr = mulberry32((0x61A2D1 + Math.floor(rng() * 0xffffff) * 613) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; scene keeps painting */ }
}
});
};
// -- THE HAND: the rolled technique decides how every touch sits -------
const hand = handFor(cond.technique);
// small hands (sec, pointillisme) cover less area per touch; the
// structural masses compensate so the garden closes over in every hand —
// the wrist changes how the paint sits, never what the composition says.
const covK = Math.min(1.6, Math.max(1, 1 / Math.max(0.25,
((hand.strokeLength[0] + hand.strokeLength[1]) / 2) *
((hand.dabSize[0] + hand.dabSize[1]) / 2))));
const touch = (x, y, w, c, a, ang = 0, squash = 1) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len: 0, ang, w, c, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: a, angle: ang, squash }),
});
const smear = (x, y, len, ang, w, c, a, squash = 1) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len, ang, w, c, a, squash,
plain: (g) => stroke(g, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: c, alpha: a, taper: 'none', spacing: 0.55,
}),
});
const slab = (x, y, len, ang, w, c, a) => (cc, jr) => handDab(cc, jr, hand, {
x, y, len, ang, w, c, a, squash: 0.4,
plain: (g) => knife(g, jr, { x, y, size: Math.max(1, len * 0.5), color: c, alpha: a, angle: ang, squash: 0.35 }),
});
// the soft air — mist, light, glints — stays translucent in every hand:
// painted directly, only its ink load follows the hand's alphaBase.
const veil = (x, y, len, ang, w, c, a) => (cc) => {
const half = Math.max(1, len * 0.5);
const alpha = a * hand.alphaBase;
dab(cc, x, y, half, c, alpha, ang, Math.min(1, w / half)); // the veil proper
dab(cc, x, y, half * 1.45, c, alpha * 0.35, ang, Math.min(1, (w * 1.5) / (half * 1.45))); // its breath
};
// -- palettes: layered greens that never go muddy ----------------------
// Light rides the yellow side, depth the blue side; the hand of the hour
// and the weather grade everything together so the garden stays one air.
const greens = {
light: ['#b8cc6e', '#a8c25e', '#c8d47a'],
mid: ['#6e9a50', '#5e8a46', '#7ea65a'],
deep: ['#3c6a4e', '#325c44', '#447458'],
};
if (autumn) {
greens.light = ['#c8a852', '#d4b464', '#b89848'];
greens.mid = ['#96883e', '#a8924c', '#84763a'];
greens.deep = ['#4e5c3c', '#42502f', '#5a6846'];
}
if (winter) {
greens.light = ['#c6cec9', '#d2d8d3', '#bac4bf'];
greens.mid = ['#8c9a92', '#7e8c86', '#98a49c'];
greens.deep = ['#5c6a63', '#505e58', '#66706a'];
}
if (summer) greens.light = greens.light.map((c) => mixHex(c, '#d8dc70', 0.15));
const lilacs = ['#b4a4dc', '#a290cc', '#c4b4e8', '#8a76b8', '#d0c4ec'];
const blossomPinks = ['#eab6cc', '#f2cadc', '#e09cb8'];
const russets = ['#c07838', '#a86030', '#cc8a48', '#8a4c2a'];
const lilyGreens = ['#5a8a4a', '#6a9a52', '#4a7a42'];
const TOD_VEG = { dawn: ['#8a90b8', 0.10], morning: ['#c8b8c8', 0.05], noon: [null, 0],
goldenHour: ['#e0a860', 0.08], dusk: ['#5a4a80', 0.28], night: ['#1c2444', 0.55] };
const WX_VEG = { clear: [null, 0], mist: ['#c4cac6', 0.15], overcast: ['#9aa4a4', 0.20],
rain: ['#7a8a90', 0.25], snow: ['#dce4ea', 0.22] };
const hazeCol = weather === 'mist' ? mixHex('#cfd6d2', '#7a8a80', night ? 0.5 : 0.1)
: mixHex('#a8bca8', '#5a6a60', night ? 0.55 : 0.12);
const grade = (col, base = 0) => {
let out = col;
const [tc, ta] = TOD_VEG[tod]; if (tc) out = mixHex(out, tc, ta);
const [wc, wa] = WX_VEG[weather]; if (wc) out = mixHex(out, wc, wa);
if (winter) out = mixHex(out, '#c8d2d4', 0.10);
if (base > 0) out = mixHex(out, hazeCol, clamp01(base));
return out;
};
const WATER_DEEP = { dawn: '#34505a', morning: '#3c6a5e', noon: '#3a7a66',
goldenHour: '#4a6a4c', dusk: '#2e3c58', night: '#1a2640' };
const SKY_REFL = { dawn: '#d8b098', morning: '#dcc8c8', noon: '#c8dce8',
goldenHour: '#f0c888', dusk: '#a88a98', night: '#3a4a6e' };
const deepBase = grade(WATER_DEEP[tod], 0);
const deepCols = [deepBase,
grade(mixHex(WATER_DEEP[tod], '#2c4438', 0.35), 0),
grade(mixHex(WATER_DEEP[tod], '#1c3040', 0.40), 0)];
const skyRefl = grade(SKY_REFL[tod], 0);
// -- geometry: the closed garden ----------------------------------------
const hzYn = Math.min(0.44, Math.max(0.30, cond.horizonY)); // the far bank
const hzY = hzYn * H;
const horizon = new Horizon(hzY, W * 0.5, 1.7, H);
const gp = (xn, d) => horizon.computeGroundPlane(xn * W, d, H);
// the bridge: a green arch over the middle distance
const yFeet = hzY + H * 0.045;
const xL = W * (0.07 + rng() * 0.10), xR = W * (0.83 + rng() * 0.10);
const apexH = H * (0.10 + rng() * 0.05);
const railH = H * 0.020;
const arcY = (t, h) => yFeet - h * Math.pow(Math.max(0, Math.sin(Math.PI * t)), 0.85);
const arcAngle = (t, h) => {
const e = 0.01;
const t0 = Math.max(0, t - e), t1 = Math.min(1, t + e);
return Math.atan2(arcY(t1, h) - arcY(t0, h), (xR - xL) * (t1 - t0));
};
let railCol = '#5e8a6a', railLight = '#8ab088', deckCol = '#446a52', postCol = '#3e5e4c';
if (golden) { railLight = mixHex('#8ab088', '#d8b878', 0.55); }
if (winter) { railCol = mixHex(railCol, '#dce4e4', 0.28); railLight = mixHex(railLight, '#e8eef0', 0.35); }
const ghost = weather === 'mist' ? 0.48 : 0; // the bridge a ghost in the mist
// -- prime: the closed air of the garden --------------------------------
let skyColsTop, skyColsHor;
{
const sg = skyGradient(cond);
// the sky palette the sliver and the mirror will borrow
skyColsTop = sg[1].color; skyColsHor = sg[2].color;
// the garden closes over: the prime is foliage-dark at the edges, with
// light only in the sliver between the wisteria and the far bank
const vegDark = grade(mixHex(greens.deep[0], '#18241e', 0.35), 0.02);
const sliverLight = mixHex(skyColsHor, '#f6f2e4', night ? 0 : 0.10);
const bankLine = mixHex(sg[2].color, greens.deep[0], 0.45);
const behind = mixHex(bankLine, '#20301e', 0.50);
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, vegDark);
g.addColorStop(Math.min(hzYn * 0.14, 0.9), vegDark);
g.addColorStop(Math.min(hzYn * 0.22, 0.92), sliverLight);
g.addColorStop(Math.min(hzYn * 0.32, 0.94), skyColsHor);
g.addColorStop(Math.min(hzYn * 0.42, 0.96), bankLine);
g.addColorStop(Math.min(hzYn * 0.55, 0.98), behind);
g.addColorStop(hzYn, behind);
g.addColorStop(Math.min(1, hzYn + 0.02), mixHex(bankLine, deepBase, 0.55));
g.addColorStop(Math.min(1, hzYn + 0.30), mixHex(deepBase, '#16262c', 0.25));
g.addColorStop(1, mixHex(deepBase, '#101e26', 0.45));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// ---- the sliver of sky between the wisteria and the far bank ----------
for (let i = 0; i < 100; i++) {
const x = rng() * W;
const y = H * (0.05 + rng() * 0.055);
let col = mixHex(rng() < 0.5 ? skyColsTop : skyColsHor, '#f6f2e4', night ? 0 : 0.07);
if (golden || dawn || dusk) col = mixHex(col, '#f4ead6', 0.35); // soft light, not a sky strip
stamped(0.015 + rng() * 0.05, veil(x, y, W * (0.02 + rng() * 0.06), (rng() - 0.5) * 0.08,
Math.max(1.4, H * (0.006 + rng() * 0.014)), col, 0.12 + rng() * 0.16));
}
// ---- far bank: the tree line closing over the water --------------------
for (let i = 0, nTree = Math.round(1100 * covK); i < nTree; i++) {
const x = rng() * W;
const treetop = H * (0.10 + 0.045 * Math.sin((x / W) * 4 + 1.3) + (rng() - 0.5) * 0.025);
const yn = Math.pow(rng(), 0.75); // 0 at the crown, 1 at the bank line
const y = treetop + yn * Math.max(4, hzY - treetop);
let col = yn < 0.10 ? pick(greens.light) : yn < 0.55 ? pick(greens.mid) : pick(greens.deep);
if (yn >= 0.10 && rng() < 0.10) col = pick(greens.light); // sparse crown highlights
col = grade(col, (1 - yn) * 0.10);
stamped(0.08 + 0.10 * yn + rng() * 0.012, smear(x, y, W * (0.016 + rng() * 0.040),
(rng() - 0.5) * 0.4, Math.max(1.5, H * (0.008 + rng() * 0.014)), col, 0.40 + rng() * 0.30));
}
{ // the dark bank edge the whole mirror will reflect
for (let i = 0, nEdge = Math.round(140 * covK); i < nEdge; i++) {
const x = rng() * W;
const y = hzY - H * (0.002 + rng() * 0.045);
stamped(0.19 + rng() * 0.02, smear(x, y, W * (0.018 + rng() * 0.036), (rng() - 0.5) * 0.2,
Math.max(1.8, H * 0.011), grade(pick(greens.deep), 0.02), 0.55 + rng() * 0.30));
}
}
if (spring) { // the far banks blossom
for (let i = 0; i < 120; i++) {
const x = rng() * W, y = H * (0.13 + rng() * 0.12);
stamped(0.235 + rng() * 0.03, touch(x, y, Math.max(1.2, H * 0.005), pick(blossomPinks), 0.35 + rng() * 0.35));
}
}
if (golden) { // a raking warm rim along the crowns
for (let i = 0; i < 70; i++) {
const x = rng() * W, y = H * (0.14 + rng() * 0.045);
stamped(0.262 + rng() * 0.02, touch(x, y, Math.max(1.2, H * 0.005), '#f0c880', 0.28 + rng() * 0.30));
}
}
// ---- the wisteria: canopy above, hanging cascades below ---------------
for (let i = 0, nCanopy = Math.round(260 * covK); i < nCanopy; i++) {
const x = rng() * W;
const y = rng() * H * 0.10 + (rng() - 0.5) * H * 0.012; // even, no fringe line
const col = grade(pick(winter ? greens.mid : greens.deep), 0.04);
stamped(0.26 + 0.06 * (y / (H * 0.10)) + rng() * 0.012, smear(x, y, W * (0.015 + rng() * 0.035),
(rng() - 0.5) * 0.3, Math.max(1.5, H * (0.006 + rng() * 0.010)), col, 0.35 + rng() * 0.30));
}
if (winter) { // bare branches under the empty pergola
for (let i = 0; i < 26; i++) {
const x = rng() * W, y0 = H * 0.02, len = H * (0.05 + rng() * 0.10);
stamped(0.305 + rng() * 0.02, (c, jr) => rigger(c, jr, {
x, y: y0, length: len, color: grade('#5a5248', 0.06), alpha: 0.5 * hand.alphaBase,
angle: Math.PI / 2 + (rng() - 0.5) * 0.35, width: Math.max(0.8, H * 0.002),
bow: (rng() - 0.5) * W * 0.02,
}));
}
}
const trailXs = [];
for (let ci = 0, nCascade = 6 + Math.floor(rng() * 4); ci < nCascade; ci++) {
const cxn = (ci + 0.25 + rng() * 0.5) / nCascade;
trailXs.push(cxn * W);
if (wisteriaGone) continue; // autumn strips it, winter never grew it
const nR = 7 + Math.floor(rng() * 7);
for (let r = 0; r < nR; r++) {
const rx = cxn * W + (rng() - 0.5) * W * 0.030;
const top = H * (0.055 + rng() * 0.05);
// the side cascades trail longest, hanging over the water's edge
let drop = H * (0.05 + rng() * 0.12) * (Math.abs(cxn - 0.5) > 0.36 ? 1.6 : 1);
drop = Math.min(drop, hzY + H * 0.03 - top);
const segs = 4 + Math.floor(rng() * 6);
for (let s = 0; s < segs; s++) {
const t = s / segs;
const y = top + drop * t;
const x = rx + Math.sin(t * 3 + r) * W * 0.004 + (rng() - 0.5) * W * 0.004;
const w = Math.max(1.2, H * 0.007 * (1 - t * 0.55) * (0.7 + rng() * 0.6));
stamped(0.30 + 0.08 * t + rng() * 0.01, touch(x, y, w, grade(pick(lilacs), 0.02), 0.45 + rng() * 0.35));
}
}
}
// ---- the willow trailing from one side (composition dice) --------------
{
const baseX = willowSide < 0 ? W * (0.03 + rng() * 0.09) : W * (0.88 + rng() * 0.09);
const nStrand = winter ? 7 : 9 + Math.floor(rng() * 6);
for (let i = 0; i < nStrand; i++) {
const x0 = baseX + (rng() - 0.5) * W * 0.10;
const y0 = H * (0.05 + rng() * 0.06);
const len = H * (winter ? 0.10 : 0.12) + rng() * H * 0.16;
const bow = willowSide * W * (0.01 + rng() * 0.03);
const col = grade(pick(winter ? greens.mid : greens.mid), 0.06);
stamped(0.385 + rng() * 0.05, (c, jr) => rigger(c, jr, {
x: x0, y: y0, length: len, color: col, alpha: 0.5 * hand.alphaBase,
angle: Math.PI / 2 + (rng() - 0.5) * 0.18, width: Math.max(0.8, H * 0.0022), bow,
}));
for (let k = 0; k < 5; k++) { // leaf dabs along the strand, thicker at the crown
const t = Math.pow(rng(), 0.7);
stamped(0.39 + rng() * 0.05, touch(x0 + bow * t * t, y0 + len * t,
Math.max(1.4, H * 0.006), grade(pick(greens.mid), 0.04), 0.5));
}
}
}
// ---- the pond: a mirror in motion, painted stroke by stroke ------------
for (let i = 0, nWater = Math.round(1500 * covK); i < nWater; i++) {
const yn = Math.pow(rng(), 0.85); // 0 at the far bank, 1 at the frame bottom
const y = hzY + yn * (H - hzY);
const x = -W * 0.02 + rng() * W * 1.04;
let col = mixHex(pick(deepCols), skyRefl, clamp01((0.40 - yn * 0.8) * (0.5 + rng() * 0.8)));
if (yn < 0.22) col = mixHex(col, pick(greens.deep), 0.30); // the dark bank mirrored
if (yn > 0.30 && yn < 0.70) { // open water breathes: light patches, deep pockets
const roll = rng();
if (roll < 0.28) col = mixHex(col, skyRefl, 0.22 + rng() * 0.18);
else if (roll < 0.46) col = mixHex(col, '#1c3038', 0.20 + rng() * 0.15);
}
if (yn > 0.55 && rng() < 0.30) col = mixHex(col, pick(greens.deep), 0.30); // bank greens slide in
// wisteria violet wobbling down into the top of the water
if (!wisteriaGone && yn < 0.16) {
for (const tx of trailXs) {
if (Math.abs(x - tx) < W * 0.045) { col = mixHex(col, pick(lilacs), 0.30 + rng() * 0.30); break; }
}
}
col = grade(col, (1 - yn) * (weather === 'mist' ? 0.30 : 0.10));
const len = W * (0.02 + rng() * 0.08) * (0.6 + yn * 0.8);
const ang = (rng() - 0.5) * 0.14;
const wd = Math.max(1.2, H * (0.004 + rng() * 0.010) * (0.6 + yn));
const a = (0.20 + rng() * 0.32) * (0.5 + 0.5 * Math.min(1, 0.3 + yn));
stamped(0.42 + 0.18 * yn + rng() * 0.015, smear(x, y, len, ang, wd, col, a, 0.5));
}
// long light paths drifting across the open water
for (let i = 0; i < 3; i++) {
const y = hzY + (0.15 + rng() * 0.6) * (H - hzY);
const x0 = rng() * W * 0.3, x1 = W * (0.6 + rng() * 0.4);
const drift = (rng() - 0.5) * H * 0.03;
stamped(0.615 + rng() * 0.02, (c, jr) => stroke(c, jr, {
path: [[x0, y], [(x0 + x1) / 2, y + drift], [x1, y + drift * 1.8 + (rng() - 0.5) * H * 0.02]],
brush: 'round', width: Math.max(1.5, H * 0.006), alpha: 0.14 * hand.alphaBase,
taper: 'both', colorFn: (t) => mixHex(skyRefl, deepBase, 0.30 + t * 0.50), spacing: 0.5,
}));
}
// ---- the bridge, and its reflection as one gesture ---------------------
// every touch on the arch is stamped with a wobbling twin in the water:
// bridge and reflection as a single folded stroke.
for (let i = 0; i < 92; i++) {
const t = rng();
const x = xL + (xR - xL) * t;
const onRail = rng() < 0.6;
const h = onRail ? apexH : apexH - railH;
const y = arcY(t, h) + (rng() - 0.5) * H * 0.004;
let col = onRail ? (t > 0.38 && t < 0.62 ? railLight : railCol) : deckCol;
col = grade(col, 0.04);
if (ghost) col = mixHex(col, hazeCol, ghost);
const ang = arcAngle(t, h) + (rng() - 0.5) * 0.1;
const p = 0.600 + rng() * 0.026;
stamped(p, smear(x, y, W * (0.012 + rng() * 0.02), ang, Math.max(1.4, H * 0.006), col, 0.55 + rng() * 0.3, 0.6));
// the twin: mirrored below the waterline, broken and swaying
const ry = yFeet + (yFeet - y) * 0.78 + (rng() - 0.5) * H * 0.008;
const rx = x + (rng() - 0.5) * W * 0.012;
stamped(p + 0.003, smear(rx, ry, W * (0.010 + rng() * 0.018), (rng() - 0.5) * 0.12,
Math.max(1.2, H * 0.005), mixHex(col, deepBase, 0.40), (0.30 + rng() * 0.22) * (ghost ? 0.7 : 1), 0.5));
}
// the rail as one continuous gesture (its twin arcs right after it)
const railPts = [], reflPts = [];
for (let k = 0; k <= 10; k++) {
const t = k / 10;
railPts.push([xL + (xR - xL) * t, arcY(t, apexH)]);
reflPts.push([xL + (xR - xL) * t + (rng() - 0.5) * W * 0.014,
yFeet + (yFeet - arcY(t, apexH)) * 0.78 + (rng() - 0.5) * H * 0.008]);
}
stamped(0.629, (c, jr) => stroke(c, jr, {
path: railPts, brush: 'round', width: Math.max(1.6, H * 0.006),
alpha: 0.62 * hand.alphaBase * (ghost ? 0.7 : 1), taper: 'both', spacing: 0.35,
colorFn: (t) => grade(t > 0.38 && t < 0.62 ? railLight : railCol, 0.03),
}));
stamped(0.633, (c, jr) => stroke(c, jr, {
path: reflPts, brush: 'round', width: Math.max(1.4, H * 0.005),
alpha: 0.30 * hand.alphaBase, taper: 'both', spacing: 0.4,
colorFn: (t) => mixHex(grade(railCol, 0.05), deepBase, 0.45 + 0.25 * Math.sin(Math.PI * t)),
}));
// deck, posts, feet
stamped(0.636, (c, jr) => stroke(c, jr, {
path: railPts.map(([x, y]) => [x, y + railH * 0.9]), brush: 'round',
width: Math.max(2.2, H * 0.010), alpha: 0.55 * hand.alphaBase * (ghost ? 0.7 : 1),
taper: 'none', spacing: 0.4, color: grade(deckCol, 0.04),
}));
for (let k = 0; k < 7; k++) {
const t = 0.10 + k * 0.1333 + (rng() - 0.5) * 0.02;
const x = xL + (xR - xL) * t;
stamped(0.639 + rng() * 0.004, (c, jr) => rigger(c, jr, {
x, y: arcY(t, apexH) + railH * 0.9, length: railH * 0.9,
color: grade(postCol, 0.04), alpha: 0.5 * hand.alphaBase * (ghost ? 0.7 : 1),
angle: Math.PI / 2, width: Math.max(0.9, H * 0.0022),
}));
}
for (const fe of [0, 1]) { // the legs drinking at the water, and their drips
const fx = fe === 0 ? xL : xR;
stamped(0.644 + rng() * 0.004, (c, jr) => rigger(c, jr, {
x: fx, y: yFeet - apexH * 0.12, length: apexH * 0.12 + H * 0.02,
color: grade(postCol, 0.05), alpha: 0.55 * hand.alphaBase, angle: Math.PI / 2,
width: Math.max(1.2, H * 0.004),
}));
for (let k = 0; k < 6; k++) {
stamped(0.648 + rng() * 0.008, smear(fx + (rng() - 0.5) * W * 0.014,
yFeet + H * (0.006 + rng() * 0.030), H * (0.012 + rng() * 0.020),
Math.PI / 2 + (rng() - 0.5) * 0.15, Math.max(1.2, H * 0.004),
grade(mixHex(postCol, deepBase, 0.4), 0.08), 0.22 + rng() * 0.16, 0.5));
}
}
// ---- water light: the hour lying on the surface -------------------------
for (let i = 0; i < 150; i++) {
const yn = Math.pow(rng(), 0.9);
const x = rng() * W, y = hzY + yn * (H - hzY);
let col = skyRefl;
if (golden) col = mixHex('#f0c880', skyRefl, rng() * 0.4);
if (night) col = mixHex('#8a9cc0', skyRefl, rng() * 0.5);
if (noon) col = mixHex('#f2f6ea', skyRefl, rng() * 0.5);
col = grade(col, (1 - yn) * 0.10);
const ga = golden ? 0.14 + rng() * 0.18 : 0.07 + rng() * 0.13;
stamped(0.665 + 0.05 * yn + rng() * 0.01, veil(x, y, W * (0.015 + rng() * 0.05) * (0.7 + yn),
(rng() - 0.5) * 0.04, Math.max(1, H * 0.0035 * (0.6 + yn)), col, ga, 0.4));
}
if (golden) { // the pond gilded: raking light across the surface
for (let i = 0; i < 80; i++) {
const yn = Math.pow(rng(), 0.8);
const x = rng() * W, y = hzY + yn * (H - hzY);
stamped(0.675 + 0.04 * yn + rng() * 0.01, veil(x, y, W * (0.02 + rng() * 0.06) * (0.7 + yn),
(rng() - 0.5) * 0.04, Math.max(1, H * 0.0032 * (0.6 + yn)),
pick(['#f0c880', '#f4d494', '#e8b868']), 0.12 + rng() * 0.16, 0.4));
}
}
// ---- banks: the garden wrapping the water ------------------------------
const gpal = groundPalette(season, cond.warmth);
for (let i = 0, nBank = Math.round(450 * covK); i < nBank; i++) {
const side = rng() < 0.5 ? 0 : 1;
const d = 0.30 + Math.pow(rng(), 0.8) * 0.68;
const p = gp(0.5, d); // the ground plane sets the scale of near foliage
const xn = side === 0 ? rng() * 0.16 : 1 - rng() * 0.16;
const x = side === 0 ? xn * W * (2 - p.scale) / 1 : W - xn * W * (2 - p.scale) / 1;
const y = hzY + d * (H - hzY) + (rng() - 0.5) * H * 0.02;
const hgt = H * (0.08 + rng() * 0.20) * (0.4 + p.scale);
const v = Math.pow(rng(), 0.8);
const yy = y - hgt * v;
let col = v < 0.4 ? pick(gpal.light) : v < 0.75 ? pick(gpal.mid) : pick(gpal.shade);
const af = aerialFade(d);
col = grade(mixHex(col, af.haze, af.desat), 0.04);
stamped(0.70 + 0.14 * d + rng() * 0.015, smear(x, yy, W * (0.015 + rng() * 0.04) * (0.5 + p.scale),
(rng() - 0.5) * 0.5, Math.max(1.5, H * (0.007 + rng() * 0.013) * (0.5 + p.scale)), col,
(0.35 + rng() * 0.30) * af.alpha));
}
// ---- irises and agapanthus at the water's edge --------------------------
for (let i = 0, nIris = 10 + Math.floor(rng() * 8); i < nIris; i++) {
const side = rng() < 0.5 ? 0 : 1;
const d = 0.45 + rng() * 0.5;
const p = gp(0.5, d);
const af = aerialFade(d);
const x = side === 0 ? W * (0.01 + rng() * 0.09) : W * (0.90 + rng() * 0.09);
const yBase = hzY + Math.min(1.05, d * (1.02 + rng() * 0.1)) * (H - hzY);
const nB = 3 + Math.floor(rng() * 4);
for (let b = 0; b < nB; b++) {
const bx = x + (rng() - 0.5) * W * 0.035 * p.scale;
const bh = H * (0.05 + rng() * 0.09) * (0.4 + p.scale);
stamped(0.815 + rng() * 0.04, (c, jr) => rigger(c, jr, {
x: bx, y: yBase, length: bh, color: grade('#3a5a3a', 0.05),
alpha: 0.55 * hand.alphaBase * af.alpha,
angle: -Math.PI / 2 + (rng() - 0.5) * 0.3, width: Math.max(0.9, H * 0.0025 * (0.5 + p.scale)),
}));
if (rng() < 0.6) {
const bloomCol = rng() < 0.5 ? pick(['#6a5ab8', '#7a68c4']) : pick(['#4a6ac0', '#5a72cc']);
stamped(0.825 + rng() * 0.03, touch(bx, yBase - bh,
Math.max(1.6, H * 0.008 * (0.4 + p.scale)), grade(bloomCol, 0.02), (0.6 + rng() * 0.3) * af.alpha));
}
}
}
// ---- lily pads: floating clusters, violet-blue shadows ------------------
for (let ci = 0, nCluster = (winter ? 3 : 5) + Math.floor(rng() * 6) + (summer ? 3 : 0);
ci < nCluster; ci++) {
const cxn = 0.06 + rng() * 0.88;
const cyn = 0.30 + Math.pow(rng(), 0.8) * 0.66;
const cx = cxn * W, cy = hzY + cyn * (H - hzY);
const cs = H * 0.028 * (0.5 + cyn);
const nPads = winter ? 1 + Math.floor(rng() * 2) : 3 + Math.floor(rng() * 5);
for (let k = 0; k < nPads; k++) {
const px = cx + (rng() - 0.5) * cs * 5;
const py = cy + (rng() - 0.5) * cs * 2.2;
const ps = cs * (0.6 + rng() * 0.8);
const ang = (rng() - 0.5) * 0.8;
const gcol = rng() < 0.3 ? pick(greens.light) : rng() < 0.6 ? pick(greens.mid) : pick(lilyGreens);
const prog = 0.735 + 0.10 * cyn + rng() * 0.02;
stamped(prog, smear(px, py, ps * 1.7, ang, ps * 0.6, grade(gcol, 0.04), 0.55 + rng() * 0.30, 0.38));
stamped(prog + 0.004, touch(px + ps * 0.3, py + ps * 0.45, ps * 0.55,
grade('#46548e', 0.05), 0.28 + rng() * 0.18, 0, 0.5)); // the shadow that floats it
if (rng() < 0.5) {
stamped(prog + 0.006, touch(px - ps * 0.2, py - ps * 0.25, ps * 0.40,
grade(pick(greens.light), 0.02), 0.35 + rng() * 0.20));
}
}
}
// ---- the blossoms: sparse, precious, dice-gated -------------------------
if (!winter && !autumn) {
const nBlossom = 3 + Math.floor(rng() * 7); // 3-9
for (let i = 0; i < nBlossom; i++) {
const cyn = 0.25 + Math.pow(rng(), 0.9) * 0.70;
const x = (0.05 + rng() * 0.90) * W, y = hzY + cyn * (H - hzY);
const s = H * 0.0065 * (0.6 + cyn);
const pink = pick(blossomPinks);
stamped(0.858 + rng() * 0.012, touch(x, y, s, pink, 0.75));
stamped(0.861 + rng() * 0.012, touch(x + s * 0.7, y + s * 0.3, s * 0.7, mixHex(pink, '#f6eef2', 0.55), 0.65));
stamped(0.864 + rng() * 0.012, touch(x - s * 0.2, y + s * 0.4, s * 0.30, '#d05030', 0.55));
}
}
// ---- the weather and the season have their own hand ---------------------
if (autumn) { // russet leaves adrift on the pond
for (let i = 0; i < 120; i++) {
const yn = 0.05 + rng() * 0.90;
const x = rng() * W, y = hzY + yn * (H - hzY);
stamped(0.885 + rng() * 0.02, smear(x, y, Math.max(2, H * 0.012 * (0.5 + yn)),
(rng() - 0.5) * 1.2, Math.max(1, H * 0.004), grade(pick(russets), 0.03),
0.50 + rng() * 0.35, 0.5));
}
}
if (winter) { // snow bands on the banks and the bare deck
for (let i = 0; i < 110; i++) {
const band = Math.floor(rng() * 3);
const y = band === 0 ? hzY - H * (0.005 + rng() * 0.02)
: band === 1 ? yFeet + (rng() - 0.5) * H * 0.015 : H * (0.55 + rng() * 0.40);
stamped(0.885 + rng() * 0.02, smear(rng() * W, y, W * (0.02 + rng() * 0.06),
(rng() - 0.5) * 0.06, Math.max(1.6, H * 0.008),
pick(['#e8eef4', '#f2f6fa', '#d8e2ec']), 0.30 + rng() * 0.35, 0.4));
}
}
if (weather === 'rain') { // rings stippled across the pond
const nRings = 70 + Math.floor(rng() * 30);
for (let i = 0; i < nRings; i++) {
const yn = 0.05 + Math.pow(rng(), 0.9) * 0.90;
const x = rng() * W, y = hzY + yn * (H - hzY);
const r = Math.max(1.5, H * 0.006 * (0.5 + yn) * (0.7 + rng() * 0.9));
const sq = 0.32 + rng() * 0.12;
const ringCol = mixHex('#c8d4dc', skyRefl, 0.4);
const lw = Math.max(0.6, r * 0.18);
const al = 0.18 + rng() * 0.20;
const dbl = rng() < 0.5;
stamped(0.89 + rng() * 0.02, (c) => {
c.save();
c.globalAlpha = al;
c.strokeStyle = ringCol;
c.lineWidth = lw;
c.beginPath(); c.ellipse(x, y, r, r * sq, 0, 0, 6.2832); c.stroke();
if (dbl) {
c.globalAlpha = al * 0.6;
c.beginPath(); c.ellipse(x, y, r * 0.55, r * 0.55 * sq, 0, 0, 6.2832); c.stroke();
}
c.restore();
});
}
}
if (morning || weather === 'mist') { // mist lying low over the water
const nMist = morning && weather === 'mist' ? 60 : morning ? 42 : 30;
for (let i = 0; i < nMist; i++) {
const y = hzY + Math.pow(rng(), 1.4) * (H - hzY) * 0.55;
stamped(0.878 + rng() * 0.03, veil(rng() * W, y, W * (0.06 + rng() * 0.16),
(rng() - 0.5) * 0.05, H * (0.010 + rng() * 0.024),
mixHex(morning ? '#ecdcd8' : hazeCol, skyRefl, 0.4), 0.06 + rng() * 0.10));
}
}
// ---- the last notes ------------------------------------------------------
if (night) { // fireflies over the dark water
const nFly = 3 + Math.floor(rng() * 5);
for (let i = 0; i < nFly; i++) {
const x = (0.08 + rng() * 0.84) * W;
const y = hzY + (0.10 + rng() * 0.75) * (H - hzY) * (rng() < 0.4 ? 0.35 : 1);
stamped(0.955 + rng() * 0.02, touch(x, y, Math.max(1.6, H * 0.006), '#e0f088', 0.95));
const halo = H * 0.014;
stamped(0.957 + rng() * 0.02, (c) => dab(c, x, y, halo, mixHex('#d8e878', '#3a4a2e', 0.4), 0.28));
}
if (cond.moonVisible) { // a pale breath of moon through the sliver
stamped(0.960, (c) => dab(c, cond.sunX * W, H * 0.105, H * 0.05, '#8a9cc0', 0.10));
}
}
for (let i = 0; i < 60; i++) { // final glints struck across the mirror
const yn = rng();
const x = rng() * W, y = hzY + yn * (H - hzY);
const glintCol = night ? '#5a6a8e' : golden ? '#f4d088' : morning ? '#e8d8d0' : '#e8f0ea';
stamped(0.965 + rng() * 0.03, veil(x, y, W * 0.008 * (0.6 + yn) * (1 + rng() * 2),
(rng() - 0.5) * 0.04, Math.max(0.8, H * 0.0025 * (0.5 + yn)), glintCol,
0.14 + rng() * 0.22, 0.45));
}
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);
}
},
};
...@@ -3,5 +3,14 @@ import soleil from './soleil.js'; ...@@ -3,5 +3,14 @@ import soleil from './soleil.js';
import coquelicots from './coquelicots.js'; import coquelicots from './coquelicots.js';
import nuit from './nuit.js'; import nuit from './nuit.js';
import vallee from './vallee.js'; import vallee from './vallee.js';
import skyline from './skyline.js';
import cafe from './cafe.js';
import jardin from './jardin.js';
import pluie from './pluie.js';
import mer from './mer.js';
import bled from './bled.js';
import nympheas from './nympheas.js';
import neige from './neige.js';
import tournesols from './tournesols.js';
export default [soleil, coquelicots, nuit, vallee]; export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols];
// Les Falaises d'Étretat — a homage to the Normandy coast: white chalk cliffs
// arching over a turquoise sea, the eternal conversation between stone, water
// and light. Courbet's weight, Monet's light. The CLIFFS are the structural
// hero — a great white-cream chalk mass entering from one side (dice may send
// a second, hazed mass from the other, or open a porte d'Aval-like arch at
// low probability), built in facet planes like Cézanne's mountain but
// chalk-bright: cream, ivory, pale ochre strata with grey-blue shadow faces,
// grass-topped in broken greens, meeting the water in a dark broken band.
// The SEA is the complementary engine — turquoise/emerald horizontal broken
// strokes in layered bands (deep teal far, turquoise mid, pale glassy near)
// against the chalk's warm white and an ochre beach. Foam dabs where swell
// meets shore; dice-gated wave lines roll in; the cliff trembles in reflection
// near its base. A strip of wet ochre-sand carries a few tiny figures whose
// scale proves the cliff's enormity — the human against the geological.
//
// TONAL SPINE (the daylight discipline): pale sky > chalk mass (brightest
// solid) > sunlit sea > shadowed sea > beach > grass cap > sea-line band >
// figures. The chalk must always read as light-catching stone — never flat
// white: every chalk stroke is broken between cream/ivory/ochre, shadow faces
// go grey-blue, strata run ochre, and the lit rim under the grass cap catches
// the hour's colour (rose at dawn, orange at goldenHour, violet at dusk).
//
// SKELETON AND HAND: the load-bearing strokes — the sky wash, the sea's
// layered bands, the cliff mass blocking, the beach plane — ride the house
// stroke plain (pluie precedent: masses plain, accents through the hand),
// because a pointillisme wrist must not starve the tonal spine. Every
// expressive touch — facet colour, cloud banks, strata, grass, foam, shimmer,
// sparkle, figure flesh — routes through brushes.handFor(conditions.technique)
// + handDab.
//
// CONDITIONS VARIATION: the coast paints every hour and every weather.
// dawn (rose light on the chalk, glassy sea), noon (high glare, sea at
// maximum turquoise), goldenHour (the chalk blushing orange, long shadows
// across the beach), dusk/night (violet cliffs, dark sea, a trembling
// vertical of moonlight on the water); spring/summer (greens on the cliff
// top), autumn (russet topping), winter (pale sea, snow-dusted beach);
// mist (the far cliff a ghost, the arch dissolving), overcast/rain (storm:
// dark swell, foam bursts, gulls flying low).
//
// Contract: identical to scenes/pluie.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. Brush
// micro-jitter and weather touches run on per-stroke mulberry32 streams seeded
// from init's rng: nothing depends on the draw()-time rng the frame hands in.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, lerp, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, brokenColor,
dab, roundDab, rigger, stroke,
} from '../brushes.js';
let strokes = null;
// Sea palettes per hour: far -> mid -> near, plus the glint note and the
// foam white. Noon sings at maximum turquoise; night drops the whole spine.
const SEA = {
dawn: { far: '#3a6272', mid: '#5f9a9e', near: '#a8ccc6', glint: '#f4ddc2', foam: '#f2ece2' },
morning: { far: '#2a7484', mid: '#43a8a6', near: '#9adcd2', glint: '#f2fbf4', foam: '#f8f6ee' },
noon: { far: '#16687a', mid: '#27a09e', near: '#7fd4ca', glint: '#f0fdf6', foam: '#ffffff' },
goldenHour: { far: '#2a6a76', mid: '#3f9e98', near: '#a5d8c8', glint: '#f8d9a0', foam: '#f6ead2' },
dusk: { far: '#28324e', mid: '#3d5a6e', near: '#74909a', glint: '#d8b890', foam: '#c8c4c0' },
night: { far: '#101a2c', mid: '#1c3046', near: '#2c4258', glint: '#cdd6e4', foam: '#8a94a4' },
};
// Chalk palettes per hour: [lit face, shadow face]. The lit face carries the
// hour (rose at dawn, orange at goldenHour, moonlit grey-blue at night).
const CHALK = {
dawn: ['#f2d9c6', '#a7a2b4'],
morning: ['#f4edda', '#a8b2c2'],
noon: ['#f7f0de', '#9fb0c0'],
goldenHour: ['#f4c896', '#a8929e'],
dusk: ['#c0aec0', '#6a6484'],
night: ['#8e98b0', '#3a4258'],
};
export default {
id: 'mer',
title: 'Les Falaises d’Étretat',
artist: 'Atelier Impressionniste',
durationMs: 55000,
// The coast is honest at every hour and in every weather — pure passthrough.
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Étretat, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1883));
const tod = cond.timeOfDay;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour';
const season = cond.season, weather = cond.weather;
const misty = weather === 'mist';
const stormy = weather === 'overcast' || weather === 'rain';
const snowy = weather === 'snow' || season === 'winter';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
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 so the sea air stays airy.
const stamped = (p, fn) => {
const jr = mulberry32((0xE727A + Math.floor(rng() * 0xffffff) * 733) >>> 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; the scene keeps painting */ }
}
});
};
// -- THE HAND: the rolled technique decides how every accent sits ------
const hand = handFor(cond.technique);
const touch = (c, jr, x, y, w, col, a, ang = 0, squash = 1) => handDab(c, jr, hand, {
x, y, len: 0, ang, w, c: col, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: col, alpha: a, angle: ang, squash }),
});
const smear = (c, jr, x, y, len, ang, w, col, a, squash = 1) => handDab(c, jr, hand, {
x, y, len, ang, w, c: col, a, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, col, a),
});
// the skeleton stroke, hand-independent: the composition must survive
// every wrist (pluie precedent — masses plain, accents through the hand)
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.35,
});
// -- the sky: sampled from the house gradient (hour+season+weather) -----
const stops = skyGradient(cond);
const skyAt = (t) => {
const x = clamp01(t);
for (let i = 1; i < stops.length; i++) {
if (x <= stops[i].at) {
const a = stops[i - 1], b = stops[i];
const f = (x - a.at) / Math.max(1e-6, b.at - a.at);
return mixHex(a.color, b.color, f);
}
}
return stops[stops.length - 1].color;
};
const skyLow = skyAt(0.97);
const hazeC = misty ? mixHex(skyAt(0.85), '#e4e8e6', 0.5) : skyAt(0.85);
// -- geometry ------------------------------------------------------------
const hzY = H * clamp01(cond.horizonY);
const shoreY = Math.min(H * 0.82, hzY + H * (0.17 + rng() * 0.10)); // sea meets beach
const sunX = cond.sunX * W;
const moonX = W * (0.25 + rng() * 0.5);
// -- prime: the day's air itself ----------------------------------------
{
const g = ctx.createLinearGradient(0, 0, 0, H);
for (const s of stops) g.addColorStop(clamp01(s.at), s.color);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
add(0.001, (c) => {
const g = c.createLinearGradient(0, 0, 0, H);
for (const s of stops) g.addColorStop(clamp01(s.at), s.color);
c.fillStyle = g;
c.fillRect(0, 0, W, H);
});
// ---- sky: broad soft horizontal courses, breathing ---------------------
// the light itself: plain house stroke — the spine must survive every wrist
for (let i = 0; i < 480; i++) {
const bandN = Math.pow(rng(), 0.9);
const y = H * 0.01 + bandN * Math.max(4, hzY - H * 0.03);
const cols = brokenColor(null, rng, skyAt(bandN * 0.9), 0.5);
const col = cols[Math.floor(rng() * cols.length)];
const x = -W * 0.03 + rng() * W * 1.06;
const len = W * (0.07 + rng() * 0.18) * (0.7 + bandN * 0.5);
const ang = (rng() - 0.5) * 0.07;
const wd = H * (0.010 + rng() * 0.022) * (0.75 + bandN * 0.6);
const a = 0.10 + rng() * 0.16;
stamped(0.008 + 0.092 * bandN + rng() * 0.012, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// ---- cloud masses with silver linings ----------------------------------
const nClouds = night ? 1 + Math.floor(rng() * 2) : 2 + Math.floor(rng() * 3);
for (let ci = 0; ci < nClouds; ci++) {
const cx = 0.10 + rng() * 0.80, cy = 0.06 + rng() * 0.48;
const sx = 0.09 + rng() * 0.12, sy = 0.018 + rng() * 0.024;
const n = 12 + Math.floor(rng() * 14);
const under = skyAt(0.35 + rng() * 0.3);
for (let i = 0; i < n; i++) {
const x = (cx + gauss() * sx) * W;
const y = (cy + gauss() * sy) * H;
const rim = gauss() < -0.2; // the silver lining rides the top edge
const base = mixHex(under, night ? '#2a3852' : '#f4f1e8', rim ? 0.55 : night ? 0.10 : 0.28);
const col = mixHex(base, stormy ? '#8a949c' : hazeC, 0.15);
const wd = H * (0.010 + rng() * 0.016);
const len = W * (0.03 + rng() * 0.08);
const ang = (rng() - 0.5) * 0.10;
const a = (night ? 0.05 : 0.10) + rng() * (rim ? 0.16 : 0.12);
stamped(0.115 + rng() * 0.028, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.5);
});
}
}
// ---- the night's sparse stars ------------------------------------------
if (night) {
for (let i = 0; i < 60; i++) {
const x = rng() * W;
const y = rng() * hzY * 0.85;
const size = Math.max(0.5, H * (0.0012 + rng() * 0.002));
const a = 0.10 + rng() * 0.35;
stamped(0.146 + rng() * 0.008, (c) => dab(c, x, y, size, '#dce4f0', a));
}
}
// ---- the sun or the moon: a modest disc in breathing air ---------------
if (cond.sunVisible) {
const sy = Math.max(H * 0.05, Math.min(cond.sunY * H, hzY - H * 0.04));
const warm = dawn ? '#f6dfc2' : golden ? '#f6cf92' : dusk ? '#e8b284' : '#f4ecd8';
for (let i = 0; i < 26; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.8) * H * 0.014;
const x = sunX + Math.cos(a) * r, y = sy + Math.sin(a) * r;
const size = H * (0.004 + rng() * 0.007);
const col = i < 14 ? warm : skyAt(0.8);
const al = 0.10 + rng() * 0.14;
stamped(0.152 + rng() * 0.010, (c) => dab(c, x, y, size, col, al));
}
} else {
const my = hzY * (0.22 + rng() * 0.25);
for (let i = 0; i < 30; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.8) * H * 0.012;
const x = moonX + Math.cos(a) * r, y = my + Math.sin(a) * r;
const size = H * (0.003 + rng() * 0.006);
const col = i < 16 ? '#e6ecf4' : skyAt(0.4);
const al = 0.12 + rng() * 0.20;
stamped(0.152 + rng() * 0.010, (c) => dab(c, x, y, size, col, al));
}
}
// ---- wheeling gulls: two-stroke accents, dice 0-6 ----------------------
const nGulls = Math.floor(rng() * 7);
for (let i = 0; i < nGulls; i++) {
const gx = W * (0.08 + rng() * 0.84);
const gy = night ? H * (0.10 + rng() * 0.25)
: stormy ? hzY - H * (0.03 + rng() * 0.18) // storm gulls fly low
: H * (0.06 + rng() * (hzY / H) * 0.8);
const s = W * (0.006 + rng() * 0.010);
const flap = (rng() - 0.5) * 0.7;
const col = night ? '#9aa4b4' : '#4a4e56';
const ga = 0.55 + rng() * 0.25;
stamped(0.166 + rng() * 0.010, (c, jr) => {
rigger(c, jr, { x: gx, y: gy, length: s, color: col, alpha: ga,
angle: Math.PI * 1.18 + flap, width: Math.max(0.7, s * 0.16), bow: s * 0.35 });
rigger(c, jr, { x: gx, y: gy, length: s, color: col, alpha: ga,
angle: Math.PI * 1.82 + flap, width: Math.max(0.7, s * 0.16), bow: -s * 0.35 });
});
}
// ---- THE SEA: layered horizontal bands, the complementary engine -------
// deep teal far, turquoise mid, pale glassy near. Weather and season tilt
// the whole water body: storm darkens it, mist lifts it, winter pales it.
const P0 = SEA[tod] || SEA.morning;
const seaFar = (() => {
let c = P0.far;
if (stormy) c = mixHex(c, '#39494c', 0.40);
if (misty) c = mixHex(c, '#cfd8d4', 0.22);
if (snowy && !night) c = mixHex(c, '#dce8ec', 0.22);
return c;
})();
const seaMid = (() => {
let c = P0.mid;
if (stormy) c = mixHex(c, '#46585a', 0.38);
if (misty) c = mixHex(c, '#d4dcd8', 0.18);
if (snowy && !night) c = mixHex(c, '#cfe0e4', 0.26);
return c;
})();
const seaNear = (() => {
let c = P0.near;
if (stormy) c = mixHex(c, '#5a6a68', 0.30);
if (misty) c = mixHex(c, '#dce4e0', 0.20);
if (snowy && !night) c = mixHex(c, '#d8e8ea', 0.30);
return c;
})();
const glintC = stormy ? mixHex(P0.glint, '#c2ccd0', 0.4) : P0.glint;
const foamC = stormy ? mixHex(P0.foam, '#b8c2c4', 0.25) : P0.foam;
const seaRange = shoreY - hzY;
const seaCol = (t, x) => {
let col = t < 0.34 ? seaFar
: t < 0.68 ? mixHex(seaFar, seaMid, (t - 0.34) / 0.34)
: mixHex(seaMid, seaNear, (t - 0.68) / 0.32);
// the sun's or moon's road brightens the water beneath it
const road = Math.exp(-Math.pow((x - (night ? moonX : sunX)) / (W * 0.10), 2));
if (road > 0.02) col = mixHex(col, night ? '#33465e' : glintC, road * (golden || night ? 0.30 : 0.16));
return col;
};
for (let i = 0; i < 1500; i++) {
const t = Math.pow(rng(), 0.85); // even coverage, slight bias to the near glass
const y = hzY + 2 + t * (seaRange - 4);
const x = -W * 0.03 + rng() * W * 1.06;
const col = seaCol(t, x);
const len = W * (0.03 + rng() * 0.11) * (0.55 + t * 1.1);
const ang = (rng() - 0.5) * (0.05 + (stormy ? 0.09 : 0.02)); // storm: a choppier wrist
const wd = Math.max(1.2, H * (0.005 + rng() * 0.012) * (0.5 + t * 1.2));
const a = (0.22 + rng() * 0.26) * (0.55 + t * 0.55);
stamped(0.175 + 0.225 * t + rng() * 0.010, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// the hand over the water: the technique shivers the swell — kept faint
for (let i = 0; i < 160; i++) {
const t = Math.pow(rng(), 0.9);
const y = hzY + 2 + t * (seaRange - 4);
const x = -W * 0.02 + rng() * W * 1.04;
const col = mixHex(seaCol(t, x), glintC, rng() * 0.25);
const len = W * (0.015 + rng() * 0.05) * (0.5 + t);
const ang = (rng() - 0.5) * 0.08;
const wd = Math.max(1.2, H * 0.005 * (0.5 + t * 1.3));
const a = 0.14 + rng() * 0.18; // shimmer, never slab
stamped(0.408 + 0.014 * t + rng() * 0.006, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.55);
});
}
// ---- the sun's road / the moon's trembling path ------------------------
{
const roadX = night ? moonX : sunX;
const roadC = night ? '#cdd6e4' : glintC;
const roadN = night ? 110 : (golden || dawn || dusk ? 70 : 40);
const roadA = night ? 0.14 : (golden || dawn || dusk ? 0.12 : 0.08);
for (let i = 0; i < roadN; i++) {
const t = Math.pow(rng(), 0.8);
const y = hzY + 3 + t * (seaRange - 6);
const x = roadX + gauss() * W * (0.012 + t * 0.030); // widening toward us
const len = Math.max(1.5, H * (0.003 + rng() * 0.008) * (0.6 + t));
const wd = Math.max(0.8, H * 0.0035 * (0.6 + t));
const a = roadA * (0.4 + rng() * 0.9) * (0.4 + t * 0.9);
const ang = (rng() - 0.5) * 0.10;
stamped(0.428 + rng() * 0.018, (c, jr) => {
band(c, jr, x, y, len, ang, wd, roadC, a);
});
}
}
// ---- dice-gated wave lines rolling in, foam where swell meets shore ----
const waveLines = rng() < 0.78 ? 3 + Math.floor(rng() * 4) : 0;
for (let k = 0; k < waveLines; k++) {
const t = 0.35 + (k / Math.max(1, waveLines - 1 || 1)) * 0.58 + (rng() - 0.5) * 0.04;
const y = hzY + clamp01(t) * (seaRange - H * 0.015);
const wob = W * (0.004 + rng() * 0.010);
const nDash = Math.round(W / (W * 0.035));
for (let i = 0; i < nDash; i++) {
if (rng() < 0.45) continue; // the break IS the wave
const x = (i + 0.5) / nDash * W + (rng() - 0.5) * W * 0.02;
const yy = y + (rng() - 0.5) * wob * 2;
const len = W * (0.010 + rng() * 0.024);
const wd = Math.max(1.0, H * (0.003 + rng() * 0.004) * (0.6 + t));
const a = 0.10 + rng() * 0.18;
const ang = (rng() - 0.5) * 0.06;
stamped(0.452 + 0.030 * (i / nDash) + rng() * 0.006, (c, jr) => {
band(c, jr, x, yy, len, ang, wd, foamC, a);
});
}
}
// shore foam: a broken white hem where the glassy water meets the sand —
// horizontal smears pulled along the waterline, not round dabs
const hemN = stormy ? 90 : 55;
for (let i = 0; i < hemN; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = shoreY - H * 0.004 - Math.pow(rng(), 1.4) * H * 0.022;
const len = W * (0.02 + rng() * 0.055);
const wd = Math.max(1.0, H * (0.0025 + rng() * 0.0045));
const a = 0.22 + rng() * 0.34;
const ang = (rng() - 0.5) * 0.05;
stamped(0.462 + rng() * 0.020, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, foamC, a, 0.35);
});
}
// ---- THE BEACH: wet ochre-sand, the warm third of the triad ------------
const sandLight = (() => {
let c = '#dcc298';
if (snowy && !night) c = mixHex(c, '#e8eef0', 0.45);
if (golden) c = mixHex(c, '#e8b078', 0.22);
if (night) c = mixHex(c, '#3c3850', 0.62);
return c;
})();
const sandMid = mixHex(sandLight, night ? '#2a2840' : '#b99a6e', 0.45);
const wetBand = mixHex(sandMid, night ? '#1e1c30' : '#8a704c', 0.55);
for (let i = 0; i < 480; i++) {
const f = Math.pow(rng(), 0.9); // 0 at shore, 1 at frame bottom
const y = shoreY + f * (H - shoreY);
const x = -W * 0.03 + rng() * W * 1.06;
const col = f < 0.12 ? mixHex(wetBand, sandMid, f / 0.12)
: mixHex(sandMid, sandLight, Math.pow(f, 0.7));
const cols = brokenColor(null, rng, col, 0.45);
const c2 = cols[Math.floor(rng() * cols.length)];
const len = W * (0.035 + rng() * 0.10) * (0.5 + f * 1.1);
const ang = (rng() - 0.5) * 0.06;
const wd = Math.max(1.6, H * (0.008 + rng() * 0.015) * (0.5 + f * 1.3));
const a = (0.34 + rng() * 0.30) * (0.7 + f * 0.5);
stamped(0.500 + 0.055 * f + rng() * 0.008, (c, jr) => {
band(c, jr, x, y, len, ang, wd, c2, a);
});
}
// pebbles: darker broken dots scattered near the frame bottom
for (let i = 0; i < 80; i++) {
const x = rng() * W;
const y = H - Math.pow(rng(), 0.7) * (H - shoreY) * 0.55;
const col = mixHex(sandMid, night ? '#1c1a28' : '#6a563c', 0.35 + rng() * 0.3);
const sz = Math.max(1.2, H * 0.004 * (0.5 + rng()));
const pa = 0.25 + rng() * 0.25;
stamped(0.556 + rng() * 0.014, (c, jr) => {
touch(c, jr, x, y, sz, col, pa);
});
}
// ---- THE CLIFFS: the structural hero -----------------------------------
const bothSides = rng() < 0.12;
const nearSide = cond.motifSide;
// a chalk cliff is a WALL: the top runs level (broken, breathing) from the
// frame edge, then PLUNGES near the toe — it never ramps. The base stands
// in the water clear of the beach and recedes to the horizon at the toe.
const buildCliff = (side, extentW, topEdgeY, hazed) => {
const N = 18;
const edgeX = side === 'left' ? -W * 0.04 : W * 1.04;
const toeX = side === 'left' ? extentW : W - extentW;
const topToe = hzY - H * (0.01 + rng() * 0.05);
const baseToe = hzY + H * (0.03 + rng() * 0.03);
const baseEdge = shoreY - H * (0.08 + rng() * 0.06);
const plungeU = 0.70 + rng() * 0.10; // where the wall sheers away
const xs = [], tops = [], bases = [];
for (let i = 0; i <= N; i++) {
const u = i / N;
xs.push(lerp(edgeX, toeX, Math.pow(u, 0.92)));
const wobT = i > 0 && i < N ? (rng() - 0.5) * H * 0.016 : 0;
const wobB = i > 0 && i < N ? (rng() - 0.5) * H * 0.007 : 0;
const top = u < plungeU
? topEdgeY + wobT + (rng() - 0.5) * H * 0.006 // the level, breathing crest
: lerp(topEdgeY, topToe, Math.pow((u - plungeU) / (1 - plungeU), 1.35)) + wobT * 0.5;
tops.push(top);
bases.push(lerp(baseEdge, baseToe, Math.pow(u, 1.25)) + wobB);
}
const at = (arr, u) => {
const f = clamp01(u) * N, i = Math.min(N - 1, Math.floor(f));
return lerp(arr[i], arr[i + 1], f - i);
};
return {
side, hazed, plungeU,
x: (u) => at(xs, u),
top: (u) => at(tops, u),
base: (u) => at(bases, u),
u: (x) => { // inverse of the x mapping
const f = (x - edgeX) / (toeX - edgeX);
return clamp01(Math.pow(Math.abs(f), 1 / 0.92)) * (f < 0 ? 0 : 1) || clamp01(f);
},
};
};
const nearExtent = W * (0.28 + rng() * 0.24);
const cliff = buildCliff(nearSide, nearExtent, H * (0.10 + rng() * 0.10), false);
const farCliff = bothSides
? buildCliff(nearSide === 'left' ? 'right' : 'left', W * (0.16 + rng() * 0.14),
H * (0.22 + rng() * 0.10), true)
: null;
// the porte d'Aval: an arch through the near cliff, low probability
const archOn = rng() < 0.35;
const archU = 0.74 + rng() * 0.14;
const archCx = cliff.x(archU);
const archBase = cliff.base(archU);
const archRx = W * (0.032 + rng() * 0.030);
const archRy = Math.max(H * 0.05, (archBase - cliff.top(archU)) * (0.28 + rng() * 0.20));
const archCy = archBase - archRy * 0.72;
const archInnerTop = mixHex(skyAt(0.8), seaFar, 0.35);
const archInner = mixHex(seaFar, '#0e1a24', 0.22);
const archDepth = (x, y) => { // 0 outside the opening, ~1 at its heart
if (!archOn) return 0;
const dx = (x - archCx) / archRx, dy = (y - archCy) / archRy;
const d = dx * dx + dy * dy;
return d < 1 ? 1 - d * 0.9 : 0;
};
// chalk colour at a point: the light-catching stone. Broken between
// cream/ivory, grey-blue in shadow, ochre in the strata, hour-tinted on
// the lit rim, hazed if the mass is far. colOff carries the persistent
// value of a facet COLUMN — Cézanne's planes, not per-dab noise.
const [chalkLit0, chalkShade0] = CHALK[tod] || CHALK.morning;
const chalkTint = golden ? '#e8a86a' : dawn ? '#e8b8a2' : dusk ? '#9a86ae' : null;
const chalkAt = (cl, x, y, u, colOff, hazed) => {
// light: columns nearer the sun catch it, the crest brighter than the
// base, plus the column's own persistent plane value
const nearSun = 1 - clamp01(Math.abs(x - sunX) / (W * 0.7));
const topY = cl.top(u), baseY = cl.base(u);
const heightK = clamp01((baseY - y) / Math.max(1, baseY - topY));
const lit = 0.34 + 0.40 * nearSun + colOff + 0.14 * heightK;
let col = mixHex(chalkShade0, chalkLit0, clamp01(lit));
// strata: pale ochre courses running through the chalk
const strataT = ((y - topY) / Math.max(1, H * 0.05)) % 1;
if (strataT < 0.22) col = mixHex(col, night ? '#5a5a70' : '#d2b488', 0.30 * (1 - strataT / 0.22));
// the hour blushes the whole sunward face at low light (a warm floor),
// and streaks stronger where the light lands
if (chalkTint) col = mixHex(col, chalkTint, Math.max((nearSun - 0.55) * 1.4, 0.28));
// the base drinks the sea: dark, wet broken band
const baseK = clamp01((y - (baseY - H * 0.05)) / (H * 0.05));
if (baseK > 0) col = mixHex(col, seaFar, baseK * 0.55);
// mist ghosts the mass, distance hazes it
const hazeMix = (hazed ? 0.45 : 0) + (misty ? 0.22 : 0);
if (hazeMix > 0) col = mixHex(col, hazeC, hazeMix);
return col;
};
const chalkStroke = (cl, x, y, u, colOff, hazed) => {
const a = archDepth(x, y);
let col = chalkAt(cl, x, y, u, colOff, hazed);
if (a > 0) { // the arch opens the cliff: sea and sky show through
const inner = y < archCy ? archInnerTop : archInner;
col = mixHex(col, inner, Math.min(1, a * 1.4));
}
return col;
};
const paintCliff = (cl, pMass, pFacet, pStrata, pCap) => {
const { side } = cl;
const dir = side === 'left' ? 1 : -1;
// mass blocking: wide vertical courses, plain — the spine must hold
const wS = W * 0.030;
const x0 = side === 'left' ? -W * 0.02 : W * 1.02;
const nS = Math.ceil(Math.abs(cl.x(1) - x0) / wS) + 1;
for (let i = 0; i < nS; i++) {
const x = x0 + dir * i * wS;
if (side === 'left' ? x > cl.x(1) + wS : x < cl.x(1) - wS) break;
const u = cl.u(x);
const top = cl.top(u), base = cl.base(u);
const colOff = (i % 2 === 0 ? 0.06 : -0.08) + (rng() - 0.5) * 0.10;
const segN = Math.max(1, Math.ceil((base - top) / (H * 0.055)));
for (let k = 0; k < segN; k++) {
const yMid = lerp(base, top, (k + 0.5) / segN);
const col = chalkStroke(cl, x, yMid, u, colOff, cl.hazed);
const wd = wS * (1.0 + rng() * 0.5);
const len = (base - top) / segN * 1.15;
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.06;
const a = (0.74 + rng() * 0.20) * (cl.hazed ? 0.75 : 1);
stamped(pMass[0] + (pMass[1] - pMass[0]) * (i / nS) + rng() * 0.006, (c, jr) => {
band(c, jr, x, yMid, len, ang, wd, col, a);
});
}
}
// facet planes: plain vertical slabs with a persistent per-column value —
// Cézanne's mountain in chalk. Structural, so the house stroke carries
// them; the hand returns for strata, sparkle and rim.
const wF = W * 0.016;
const nF = Math.ceil(Math.abs(cl.x(1) - x0) / wF) + 1;
for (let i = 0; i < nF; i++) {
const x = x0 + dir * i * wF;
if (side === 'left' ? x > cl.x(1) + wF : x < cl.x(1) - wF) break;
const u = cl.u(x);
const top = cl.top(u), base = cl.base(u);
const colOff = (i % 2 === 0 ? 0.18 : -0.20) + (rng() - 0.5) * 0.34;
const segN = Math.max(1, Math.ceil((base - top) / (H * 0.035)));
for (let k = 0; k < segN; k++) {
const yMid = lerp(base, top, (k + 0.5) / segN) + (rng() - 0.5) * H * 0.008;
const col = chalkStroke(cl, x + (rng() - 0.5) * wF * 0.6, yMid, u, colOff, cl.hazed);
const wd = wF * (0.7 + rng() * 0.6);
const len = (base - top) / segN * (0.7 + rng() * 0.5);
const tilt = (i % 2 === 0 ? 0.05 : -0.05) + (rng() - 0.5) * 0.10;
const ang = -Math.PI / 2 + tilt;
const a = (0.38 + rng() * 0.30) * (cl.hazed ? 0.7 : 1);
stamped(pFacet[0] + (pFacet[1] - pFacet[0]) * (i / nF) + rng() * 0.006, (c, jr) => {
band(c, jr, x, yMid, len, ang, wd, col, a);
});
}
}
// the plunge edge: a sheer-face shadow defining the silhouette where the
// wall drops to the toe — without it the glaze hands dissolve the edge
{
const inward = -dir * W * 0.006;
for (let i = 0; i < 30; i++) {
const u = cl.plungeU * 0.92 + (i / 30) * (1 - cl.plungeU * 0.92);
const x = cl.x(u) + inward + (rng() - 0.5) * W * 0.004;
const top = cl.top(u), base = cl.base(u);
if (base - top < H * 0.01) break;
const yMid = lerp(base, top, 0.2 + rng() * 0.7);
const col = mixHex(chalkStroke(cl, x, yMid, u, -0.25, cl.hazed), chalkShade0, 0.35);
const wd = W * 0.008 * (0.7 + rng() * 0.6);
const len = (base - top) * (0.25 + rng() * 0.35);
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.10;
const a = (0.35 + rng() * 0.25) * (cl.hazed ? 0.6 : 1);
stamped(pFacet[1] * 0.995 + rng() * 0.004, (c, jr) => {
band(c, jr, x, yMid, len, ang, wd, col, a);
});
}
}
// strata: broken ochre courses running with the slope — the hand's course
const nStr = 4 + Math.floor(rng() * 3);
for (let s2 = 0; s2 < nStr; s2++) {
const v = (s2 + 0.5) / nStr; // height fraction on the face
const nD = 26;
for (let i = 0; i < nD; i++) {
if (rng() < 0.25) continue;
const u = rng();
const x = cl.x(u);
const y = lerp(cl.base(u), cl.top(u), v) + (rng() - 0.5) * H * 0.006;
const col = mixHex(chalkStroke(cl, x, y, u, 0, cl.hazed), night ? '#4c4a62' : '#c2a074', 0.45);
const len = W * (0.015 + rng() * 0.045);
const ang = (rng() - 0.5) * 0.10;
const wd = Math.max(1.2, H * (0.003 + rng() * 0.005));
const a = (0.18 + rng() * 0.22) * (cl.hazed ? 0.6 : 1);
stamped(pStrata[0] + (pStrata[1] - pStrata[0]) * (s2 / nStr) + rng() * 0.005, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.5);
});
}
}
// the grass cap: broken greens (russet in autumn, snow-pale in winter),
// hugging the crest AND the plunge, spilling a little onto the chalk
const gpal = groundPalette(season, cond.warmth);
const capCol = () => {
const arr = rng() < 0.5 ? gpal.light : gpal.mid;
let c2 = arr[Math.floor(rng() * arr.length)];
if (golden) c2 = mixHex(c2, '#d8a860', 0.18);
if (dusk) c2 = mixHex(c2, '#5a5470', 0.35);
if (night) c2 = mixHex(c2, '#232a3c', 0.60);
if (misty || cl.hazed) c2 = mixHex(c2, hazeC, cl.hazed ? 0.45 : 0.18);
return c2;
};
const nCap = 170;
for (let i = 0; i < nCap; i++) {
const u = rng() * 0.995;
const x = cl.x(u) + (rng() - 0.5) * W * 0.010;
const y = cl.top(u) + H * (rng() * 0.014 - 0.003);
const col = capCol();
const len = W * (0.010 + rng() * 0.028);
const ang = (rng() - 0.5) * 0.16;
const wd = Math.max(1.2, H * (0.004 + rng() * 0.007));
const a = (0.30 + rng() * 0.30) * (night ? 0.55 : 1) * (cl.hazed ? 0.6 : 1);
stamped(pCap[0] + (pCap[1] - pCap[0]) * (i / nCap) + rng() * 0.005, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.5);
});
}
// the lit rim under the cap: the sun catching the chalk's crown
const rimC = mixHex(chalkLit0, '#fff8ea', 0.35);
for (let i = 0; i < 60; i++) {
const u = rng();
const x = cl.x(u);
const y = cl.top(u) + H * (0.002 + rng() * 0.010);
const len = W * (0.010 + rng() * 0.030);
const wd = Math.max(1.2, H * (0.003 + rng() * 0.006));
const a = (0.25 + rng() * 0.30) * (cl.hazed ? 0.55 : 1);
const ang = (rng() - 0.5) * 0.12;
stamped(pCap[1] * 0.97 + rng() * 0.006, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, rimC, a, 0.5);
});
}
};
paintCliff(cliff, [0.575, 0.645], [0.630, 0.715], [0.718, 0.738], [0.742, 0.762]);
if (farCliff) paintCliff(farCliff, [0.585, 0.625], [0.640, 0.680], [0.720, 0.730], [0.744, 0.752]);
// the sea-line: where chalk meets water, a dark broken band hugging the
// base — overlapping short courses, never a dotted line
const paintSeaLine = (cl, p) => {
for (let i = 0; i < 110; i++) {
const u = rng();
const x = cl.x(u) + (rng() - 0.5) * W * 0.012;
const y = cl.base(u) + (rng() - 0.5) * H * 0.008;
if (y > shoreY - H * 0.008) continue; // the band lives on water, not sand
const col = mixHex('#333f4a', seaFar, 0.25 + rng() * 0.45);
const len = W * (0.018 + rng() * 0.045);
const ang = (rng() - 0.5) * 0.10;
const wd = Math.max(1.6, H * (0.005 + rng() * 0.009));
const a = (0.26 + rng() * 0.24) * (cl.hazed ? 0.6 : 1);
stamped(p[0] + (p[1] - p[0]) * u + rng() * 0.004, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// spray: white dabs breaking against the foot of the wall
for (let i = 0; i < 26; i++) {
const u = rng();
const x = cl.x(u) + (rng() - 0.5) * W * 0.016;
const y = cl.base(u) + (rng() - 0.5) * H * 0.010;
const sz = Math.max(1.4, H * (0.003 + rng() * 0.005));
const a = 0.25 + rng() * 0.30;
stamped(p[1] * 0.99 + rng() * 0.004, (c, jr) => {
touch(c, jr, x, y, sz, foamC, a, 0, 0.55);
});
}
};
paintSeaLine(cliff, [0.764, 0.772]);
if (farCliff) paintSeaLine(farCliff, [0.766, 0.770]);
// the arch's rim: dark chalk edging the opening, a lit lip on the sun side
if (archOn) {
const nRim = 34;
for (let i = 0; i < nRim; i++) {
const a = (i / nRim) * Math.PI; // upper half of the ellipse (the vault)
const th = a + (rng() - 0.5) * 0.08;
const x = archCx + Math.cos(th) * archRx * (1.02 + rng() * 0.06);
const y = archCy - Math.sin(th) * archRy * (1.02 + rng() * 0.06);
const lit = Math.cos(th) * (cliff.side === 'left' ? 1 : -1) > 0;
const col = lit ? mixHex(chalkLit0, '#fff6e4', 0.3)
: mixHex(chalkShade0, '#20262e', 0.35);
const wd = Math.max(1.6, archRx * 0.14);
const ra = 0.55 + rng() * 0.25;
stamped(0.776 + (i / nRim) * 0.006, (c, jr) => {
touch(c, jr, x, y, wd, col, ra, th + Math.PI / 2, 0.6);
});
}
}
// ---- reflections: the cliff trembling near its base ---------------------
const paintReflection = (cl, p) => {
for (let i = 0; i < 70; i++) {
const u = rng();
const x = cl.x(u) + gauss() * W * 0.006;
const y = cl.base(u) + Math.pow(rng(), 0.8) * Math.min(H * 0.045, shoreY - cl.base(u) - 2);
if (!(y > cl.base(u) && y < shoreY - H * 0.005)) continue; // on water only
const chalkR = chalkAt(cl, x, cl.base(u), u, rng() < 0.5 ? 0.1 : -0.1, cl.hazed);
const col = mixHex(chalkR, seaMid, 0.45 + rng() * 0.25);
const len = Math.max(2, W * (0.008 + rng() * 0.020) * (0.6 + rng()));
const ang = Math.PI / 2 + (rng() - 0.5) * 0.5; // trembling verticals
const wd = Math.max(1.0, H * (0.0025 + rng() * 0.005));
const a = (0.08 + rng() * 0.14) * (cl.hazed ? 0.5 : 1);
stamped(p[0] + (p[1] - p[0]) * u + rng() * 0.006, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.4);
});
}
};
paintReflection(cliff, [0.784, 0.802]);
// storm foam bursts at the cliff's foot
if (stormy) {
for (let i = 0; i < 70; i++) {
const u = Math.pow(rng(), 1.5); // hugging the mass
const x = cliff.x(u) + gauss() * W * 0.015;
const y = cliff.base(u) + Math.abs(gauss()) * H * 0.02;
const sz = Math.max(1.6, H * 0.006 * (0.6 + rng()));
const fa = 0.30 + rng() * 0.35;
stamped(0.796 + rng() * 0.012, (c, jr) => {
touch(c, jr, x, y, sz, foamC, fa, 0, 0.6);
});
}
}
// ---- tiny figures: the human against the geological ---------------------
const nFig = Math.floor(rng() * 4);
for (let f = 0; f < nFig; f++) {
const fy = shoreY + (0.10 + rng() * 0.85) * (H - shoreY);
const fx = W * (0.08 + rng() * 0.84);
const depth = (fy - shoreY) / Math.max(1, H - shoreY);
const hh = H * (0.010 + 0.022 * depth); // height proves the cliff's size
const coat = night ? '#1e2028' : wpick([['#2e2a28', 5], ['#4a3a30', 2], ['#6a4a3a', 2], ['#3a4048', 2]]);
const coatC = weather === 'snow' ? mixHex(coat, '#c8ccd4', 0.12) : coat;
const bodyAng = -Math.PI / 2 + (rng() - 0.5) * 0.15;
stamped(0.810 + f * 0.010 + rng() * 0.004, (c, jr) => {
band(c, jr, fx, fy - hh * 0.45, hh * 0.55, bodyAng,
Math.max(1.2, hh * 0.30), coatC, 0.80);
band(c, jr, fx - hh * 0.08, fy - hh * 0.10, hh * 0.22, -Math.PI / 2,
Math.max(0.8, hh * 0.12), mixHex(coatC, '#14161c', 0.3), 0.65);
band(c, jr, fx + hh * 0.10, fy - hh * 0.10, hh * 0.22, -Math.PI / 2,
Math.max(0.8, hh * 0.12), mixHex(coatC, '#14161c', 0.3), 0.65);
dab(c, fx, fy - hh * 0.92, Math.max(0.8, hh * 0.16), mixHex(coatC, '#d8b89c', 0.35), 0.7);
});
// a faint reflection in the wet sand
stamped(0.824 + f * 0.008 + rng() * 0.003, (c, jr) => {
smear(c, jr, fx, fy + hh * 0.15, hh * 0.5, Math.PI / 2, Math.max(1, hh * 0.22),
mixHex(coatC, sandMid, 0.5), 0.16, 0.4);
});
}
// ---- the hand's last pass: sea shimmer, chalk sparkle -------------------
for (let i = 0; i < 120; i++) {
const t = 0.3 + rng() * 0.7;
const y = hzY + t * (seaRange - 4);
const x = rng() * W;
const col = mixHex(seaCol(t, x), glintC, 0.4 + rng() * 0.4);
const sz = Math.max(1.0, H * 0.004 * (0.5 + t));
const sa = 0.16 + rng() * 0.20;
stamped(0.845 + rng() * 0.020, (c, jr) => {
touch(c, jr, x, y, sz, col, sa, 0, 0.5);
});
}
for (let i = 0; i < 80; i++) { // the chalk catching light, struck late
const u = rng();
const x = cliff.x(u) + (rng() - 0.5) * W * 0.01;
const y = lerp(cliff.base(u), cliff.top(u), 0.15 + rng() * 0.75);
const nearSun = 1 - clamp01(Math.abs(x - sunX) / (W * 0.7));
if (nearSun < 0.45) continue; // only the lit face sparkles
const sa = 0.18 + rng() * 0.22;
stamped(0.862 + rng() * 0.016, (c, jr) => {
touch(c, jr, x, y, Math.max(1.0, H * 0.004), mixHex(chalkLit0, '#fffaf0', 0.5), sa, 0, 0.6);
});
}
// ---- glints: the brightest notes, struck last ---------------------------
for (let i = 0; i < 40; i++) {
const y = hzY + seaRange * (0.4 + Math.pow(rng(), 0.8) * 0.6);
const x = rng() * W;
const dx = (rng() - 0.5) * W * 0.015;
const col = mixHex(glintC, '#ffffff', 0.2);
const w = 0.9 + rng() * 1.4;
const dy = (rng() - 0.5) * 2;
const ga = 0.18 + rng() * 0.24;
stamped(0.900 + rng() * 0.016, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + dx, y1: y + dy },
brush: 'round', width: w, color: col, alpha: ga, taper: 'both' });
});
}
// ---- the final veil: mist and rain soften every edge one last time ------
if (misty || weather === 'rain' || weather === 'snow') {
const veilCol = mixHex('#e2e6e4', skyLow, night ? 0.55 : (dusk || dawn) ? 0.35 : 0.15);
for (let i = 0; i < 30; i++) {
const x = rng() * W;
const y = rng() * H;
const len = W * (0.15 + rng() * 0.30);
const ang = (rng() - 0.5) * 0.04;
const wd = H * (0.02 + rng() * 0.04);
const a = 0.03 + rng() * 0.05;
stamped(0.945 + rng() * 0.030, (c, jr) => {
band(c, jr, x, y, len, ang, wd, veilCol, a);
});
}
}
strokes = list.sort((a, b) => a.p - b.p);
if (typeof console !== 'undefined' && typeof window !== 'undefined' && window.__MER_DEBUG) {
console.log('[mer] strokes:', strokes.length, 'tod:', tod, 'weather:', weather,
'both:', bothSides, 'arch:', archOn, 'gulls:', nGulls, 'figures:', nFig,
'waves:', waveLines);
}
},
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);
}
},
};
// Neige à Louveciennes — a homage to Pissarro's snow effects at Louveciennes
// (1870-71): a village road under fresh snow, the world hushed. THE SNOW IS
// THE CANVAS: the ground plane fills the lower two-thirds in warm whites and
// cool whites laid side by side — rose-white where the low light touches,
// ochre-white where the sun warms it, and BLUE-VIOLET in the shadows, because
// Pissarro's secret is that snow shadows are the most colourful shadows in
// painting. Never monochrome: every snow stroke is broken between neighbours.
//
// The VILLAGE: a hamlet strung along the road (3-7 humble cottages, dice),
// walls in muted ochre/pink/grey, every roofline CAPPED WITH SNOW — the white
// band on each roof is what sells winter. Chimneys let up thin smoke threads
// (bent when the drifting dice roll). A few amber windows (2-8) are the only
// heat in the picture. The ROAD: a foot-packed track, not plowed clear — a
// slightly darker blue-white band winding to the vanishing point, fence posts
// marching beside it, each post capped. Winter trees (branch fans or pollarded
// pops, dice) stand black-brown with snow dusted on their upper limbs. 1-3
// tiny figures pass, footprints dotted behind them.
//
// TONAL SPINE (daylight): pale sky > snow field > village walls > roofs >
// trees/figures — the white field is NOT a white canvas; the value structure
// stays readable (pixel-verified per iteration). At night the spine inverts:
// dark sky, moonlit snow, dark village, amber windows doubled, one lit
// window's glow pooling on the snow.
//
// SKELETON AND HAND: the load-bearing strokes — the sky wash, the snow field,
// the road band, the cottage masses, the long blue shadows — ride the house
// stroke plain (mer/bled precedent: masses plain, accents through the hand),
// because a pointillisme wrist must not starve the tonal spine. Every
// expressive touch — snow texture, drift lines, roof snow sparkle, smoke,
// window glow, branch snow, footprints, glints — routes through
// brushes.handFor(conditions.technique) + handDab.
//
// MOUILLE WORKAROUND (house bug, documented): brushes.realizeWet routes every
// touch through bleed(), whose ctx.filter blur costs ~25ms PER STROKE at full
// canvas area — a snow field is thousands of touches. Under the mouille hand
// this scene does NOT send ordinary touches to handDab: it paints the wet look
// itself as soft multi-dab layering (a soft core plus a wider, fainter halo,
// edges fully lost, no filter). Only a handful of hero touches (window glows,
// the moon) keep the true handDab/bleed path — a dozen filter strokes, not
// four thousand.
//
// CONDITIONS VARIATION — snow at every hour: dawn (rose light, blue shadows at
// maximum), noon (flat white glare, shadows shrink to short slate), goldenHour
// (gold-and-lavender snow, shadows at their longest), dusk/night (blue snow,
// warm windows doubled, one glow pool on the snow); winter (canonical) vs
// spring (the thaw: snow patchy, ochre earth islands); clear (crisp), snowing
// (falling veils, distance whiting out), mist (the village ghosts), overcast
// (colour drained); an inner dice rolls drifting (bent smoke, denser drift
// lines, lee-side roof crests).
//
// OVERRIDES (documented, nuit precedent): rain and the hot seasons are
// re-rolled toward the cold states — this road only exists under snow. The
// dedicated conditions stream is consumed identically every pass, so the
// re-roll stays deterministic; a forced axis is always honoured (forced rain
// or forced summer paints as asked).
//
// Contract: identical to scenes/mer.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. Brush
// micro-jitter and weather touches run on per-stroke mulberry32 streams
// seeded from init's rng: nothing depends on the draw()-time rng.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient,
mix as mixHex, clamp01, lerp, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, Horizon, brokenColor, aerialFade,
dab, roundDab, rigger, stroke,
} from '../brushes.js';
let strokes = null;
// The scene's home turf: any weather but rain, any season that can carry snow
// (winter canonical, spring = the thaw).
const SNOWFALL = (c) => c.weather !== 'rain' && (c.season === 'winter' || c.season === 'spring');
// Snow per hour: [sunlit face, sun-warm note, shadow blue, shadow deep].
// The shadow pair is the soul — dawn at maximum blue, goldenHour lavender.
const SNOW = {
dawn: { light: '#f2e4e0', warm: '#f2cfc0', shadow: '#93a2d2', deep: '#6674a6' },
morning: { light: '#eeede6', warm: '#eadec8', shadow: '#a6b2d2', deep: '#7e8cb4' },
noon: { light: '#f0efe9', warm: '#eee9da', shadow: '#c4cdda', deep: '#9aa8c0' },
goldenHour: { light: '#f4e2c2', warm: '#eac284', shadow: '#9e8cc4', deep: '#70609c' },
dusk: { light: '#c4c0d4', warm: '#d2ac98', shadow: '#6e6e98', deep: '#46486e' },
night: { light: '#8a96b4', warm: '#96867c', shadow: '#3a446e', deep: '#242c4a' },
};
// Shadow conduct per hour: strength of the blue, and how long shadows stretch
// (low sun). Noon flattens to short slate; dawn/goldenHour sing.
const SHADOW = {
dawn: { k: 1.00, len: 2.0 },
morning: { k: 0.80, len: 1.1 },
noon: { k: 0.50, len: 0.40 },
goldenHour: { k: 0.95, len: 2.6 },
dusk: { k: 0.72, len: 2.2 },
night: { k: 0.55, len: 0.9 },
};
// Hourly warmth carried on the snow's sunlit column (rose/ochre whispers).
const WARM_AMP = { dawn: 0.50, morning: 0.38, noon: 0.14, goldenHour: 0.65, dusk: 0.28, night: 0.10 };
export default {
id: 'neige',
title: 'Neige à Louveciennes',
artist: 'Atelier Impressionniste',
durationMs: 55000,
// The scene may re-roll hot weathers and hot seasons toward cold ones. Ten
// tries; a forced axis always wins and a stubborn hot survivor is marched
// back to winter snow (this road only exists under snow).
conditions: (rng, constraints) => {
const forced = !!(constraints && (constraints.weather || constraints.season));
let c = makeConditions(rng, constraints);
for (let i = 0; i < 10; i++) {
if (SNOWFALL(c)) return c;
c = makeConditions(rng, constraints); // rain / hot season re-roll toward cold
}
if (forced || SNOWFALL(c)) return c;
return makeConditions(rng, { ...(constraints || {}), weather: 'snow', season: 'winter' });
},
subtitle: (c) => 'Louveciennes sous la neige, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1873));
const tod = cond.timeOfDay;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour';
const season = cond.season, weather = cond.weather;
const misty = weather === 'mist', snowing = weather === 'snow';
const overcast = weather === 'overcast', crisp = weather === 'clear';
const thaw = season === 'spring';
const drifting = rng() < 0.42; // the wind's dice: bent smoke, denser drifts
const driftSide = rng() < 0.5 ? -1 : 1;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
// one dedicated jitter stream per stroke, consumed lazily exactly once;
// weather touches ride more often while it actually snows.
const stamped = (p, fn) => {
const jr = mulberry32((0x51CE + Math.floor(rng() * 0xffffff) * 701) >>> 0);
const wr = (cond.weather !== 'clear' && rng() < (snowing ? 0.34 : misty ? 0.18 : 0.14))
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; the scene keeps painting */ }
}
});
};
// -- THE HAND: the rolled technique decides how every accent sits ------
const hand = handFor(cond.technique);
// small hands (sec, pointillisme) cover less area per touch; the
// hand-routed expressive layers compensate so the field closes in every
// hand — the wrist changes how the paint sits, never what the scene says.
const covK = Math.min(1.32, Math.max(1, 1 / Math.max(0.25,
((hand.strokeLength[0] + hand.strokeLength[1]) / 2) *
((hand.dabSize[0] + hand.dabSize[1]) / 2))));
// MOUILLE WORKAROUND — see the header note. wetTouch/wetSmear stand in for
// handDab's filter-priced wet realizer on ordinary touches.
const wet = hand.mode === 'wet';
const touch = (c, jr, x, y, w, col, a, ang = 0, squash = 1) => {
if (wet) { // soft multi-dab layering: the bleed look, no ctx.filter
dab(c, x, y, w * 0.62, col, a * 0.75, ang, squash);
dab(c, x, y, w * 1.05, col, a * 0.30, ang, squash * 0.92);
return;
}
handDab(c, jr, hand, {
x, y, len: 0, ang, w, c: col, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: col, alpha: a, angle: ang, squash }),
});
};
const smear = (c, jr, x, y, len, ang, w, col, a, squash = 1) => {
if (wet) {
const n = Math.max(2, Math.min(8, Math.round(len / Math.max(2.5, w))));
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0.5 : i / (n - 1);
dab(c, x - Math.cos(ang) * len * 0.5 + Math.cos(ang) * len * t,
y - Math.sin(ang) * len * 0.5 + Math.sin(ang) * len * t,
w * 0.55, col, a * 0.60, ang, squash);
}
dab(c, x, y, Math.max(1.5, len * 0.55), col, a * 0.22, ang, Math.min(1, squash * 0.8));
return;
}
handDab(c, jr, hand, {
x, y, len, ang, w, c: col, a, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, col, a),
});
};
// hero touches keep the true hand path (and, under mouille, pay the
// bleed filter): window glows, the moon — a dozen strokes at most.
const touchHero = (c, jr, x, y, w, col, a) => handDab(c, jr, hand, {
x, y, len: 0, ang: 0, w, c: col, a, squash: 1,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: col, alpha: a }),
});
// the skeleton stroke, hand-independent: the composition must survive
// every wrist (mer precedent — masses plain, accents through the hand)
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.35,
});
// the soft air — mist, halos, distance — stays translucent in every hand
const veil = (x, y, len, ang, w, c, a) => (cc) => {
const half = Math.max(1, len * 0.5);
dab(cc, x, y, half, c, a * hand.alphaBase, ang, Math.min(1, w / half));
dab(cc, x, y, half * 1.45, c, a * 0.35 * hand.alphaBase, ang, Math.min(1, (w * 1.5) / (half * 1.45)));
};
// -- the sky: sampled from the house gradient (hour+season+weather) -----
const stops = skyGradient(cond);
const skyAt = (t) => {
const x = clamp01(t);
for (let i = 1; i < stops.length; i++) {
if (x <= stops[i].at) {
const a = stops[i - 1], b = stops[i];
const f = (x - a.at) / Math.max(1e-6, b.at - a.at);
return mixHex(a.color, b.color, f);
}
}
return stops[stops.length - 1].color;
};
const skyLow = skyAt(0.97);
const hazeC = mixHex(skyAt(0.85), '#e6eaea', misty ? 0.55 : 0.22);
// NIGHT LIFT-BACK (nuit precedent): the season+weather LUTs wash the night
// sky toward pale grey — the tonal spine (dark sky, moonlit snow) demands
// the dark be returned. Applied to the prime and every sky stroke.
const nightFix = (col) => night ? mixHex(col, '#10182c', 0.56) : col;
// -- geometry: the road's one-point perspective --------------------------
// the ground plane owns the lower half at least (clamped from cond.horizonY
// so the snow stays the canvas); the gradient's deep stop below the line is
// buried under the far snow and the haze band.
const hzYn = Math.min(0.52, cond.horizonY);
const hzY = hzYn * H;
const vxN = 0.38 + rng() * 0.24;
const horizon = new Horizon(hzY, vxN * W, 1.55, H);
const groundK = (d) => horizon.ground(clamp01(d));
const yOf = (d) => hzY + (H - hzY) * groundK(d);
// the winding track: near-reference centre wanders, screen x converges
const xnRoad = 0.5 + (rng() - 0.5) * 0.18;
const wanderAmp = 0.02 + rng() * 0.08;
const wanderPh = rng() * 6.28;
const roadCX = (d) => {
const g = groundK(d);
const xn = xnRoad + Math.sin((1 - d) * 2.4 + wanderPh) * wanderAmp * (0.25 + d);
return vxN * W + (xn - vxN) * g * W;
};
const roadHalf = (d) => Math.max(1.5, W * 0.075 * groundK(d) * (1 + d * 0.15));
// -- the light ------------------------------------------------------------
const moonX = W * (0.22 + rng() * 0.56);
const lightX = night ? moonX : cond.sunX * W;
const lowSunF = (dawn || dusk || golden) ? 1.0 : tod === 'morning' ? 0.55 : 0.30;
const SN = SNOW[tod] || SNOW.morning;
let shK = SHADOW[tod].k, shLen = SHADOW[tod].len, warmAmp = WARM_AMP[tod];
if (overcast) { shK *= 0.45; warmAmp *= 0.5; }
if (snowing) { shK *= 0.75; warmAmp *= 0.8; }
if (misty) { shK *= 0.40; warmAmp *= 0.5; }
// snow colour at a point: cool blue away from the light, rose/ochre in
// the sun's column, dissolving toward the sky with distance. The spine:
// sunlit snow sits just under the sky's value; shadow snow well under.
const snowCol = (x, d, warmBias = 1) => {
const away = Math.min(1, Math.abs(x - lightX) / (W * 0.55));
let col = mixHex(SN.light, SN.shadow, clamp01(0.08 + 0.42 * away * lowSunF));
const sunK = Math.exp(-Math.pow((x - lightX) / (W * 0.24), 2)) * warmAmp * warmBias;
col = mixHex(col, SN.warm, sunK * 0.8);
col = mixHex(col, SN.light, clamp01(d) * 0.22); // near snow carries the light
col = mixHex(col, skyLow, (1 - d) * (0.38 + (snowing ? 0.22 : 0) + (misty ? 0.18 : 0)));
return col;
};
// thaw islands: where the earth shows through (spring only)
const islands = [];
if (thaw) {
for (let i = 0, n = 2 + Math.floor(rng() * 3); i < n; i++) {
islands.push({ cx: rng(), cd: 0.40 + rng() * 0.55, r: 0.10 + rng() * 0.13 });
}
}
const islandK = (x, d) => {
if (!thaw) return 0;
let k = 0;
for (const s of islands) {
const dd = Math.hypot((x / W) - s.cx, d - s.cd) / s.r;
k = Math.max(k, clamp01(1 - dd));
}
return k;
};
const earthCol = pick(['#8a7050', '#94785a', '#7c6448']);
// ---- DATA FIRST: the hamlet, the trees, the posts, the figures ---------
// everything the long shadows and the paint order need, computed before a
// single expressive stroke is stamped.
const houses = [];
const casters = []; // { x, y, span, len } — shadow sources on the snow
const glows = []; // lit windows: { x, y, s, d }
const smokes = []; // chimney tops: { x, y, d }
const nHouses = 3 + Math.floor(rng() * 5); // 3-7 humble cottages
let litBudget = (night ? 4 + Math.floor(rng() * 4) : 2 + Math.floor(rng() * 6)); // windows lit at once
for (let i = 0; i < nHouses; i++) {
const d = 0.30 + (i / Math.max(1, nHouses - 1)) * 0.56 + (rng() - 0.5) * 0.05;
const g = groundK(d);
const side = rng() < 0.5 ? -1 : 1;
const wallW = W * (0.055 + rng() * 0.050) * (0.45 + g * 1.15);
const wallH = H * (0.065 + rng() * 0.050) * (0.40 + g * 1.20);
const bx = roadCX(d) + side * (roadHalf(d) * 1.1 + wallW * 0.70);
if (bx < -wallW || bx > W + wallW) continue;
const yBase = yOf(d);
let wc = pick(['#c9a87c', '#c79c8a', '#b3a494', '#c2ae9e', '#a89a8c', '#bfab8a']);
if (golden) wc = mixHex(wc, '#d8a870', 0.18);
if (dusk) wc = mixHex(wc, '#565072', 0.38);
if (night) wc = mixHex(wc, '#2a2c3c', 0.62);
if (overcast) wc = mixHex(wc, '#9aa0a4', 0.16);
wc = mixHex(wc, hazeC, misty ? 0.30 + (1 - g) * 0.30 : (1 - g) * 0.22);
let rc = mixHex('#4c423c', night ? '#23242f' : '#5c4e46', 0.35);
if (dusk) rc = mixHex(rc, '#3c3854', 0.30);
if (night) rc = mixHex(rc, '#1e2028', 0.35);
rc = mixHex(rc, hazeC, (1 - g) * 0.30);
const ridgeH = wallH * (0.38 + rng() * 0.25);
const roofK = thaw ? 0.45 + rng() * 0.3 : 1; // the thaw thins the caps
const hasChimney = rng() < 0.75;
const chX = bx + (rng() - 0.5) * wallW * 0.5;
const chH = ridgeH * (0.55 + rng() * 0.5);
const chW = Math.max(1.2, wallW * 0.09);
const smoke = hasChimney && rng() < (night ? 0.45 : 0.8);
const wins = [];
const nWin = 1 + Math.floor(rng() * 3);
for (let k = 0; k < nWin; k++) {
const lit = litLeft(litBudget);
const wx = bx + (rng() - 0.5) * wallW * 0.62;
const wy = yBase - wallH * (0.30 + rng() * 0.45);
const s = Math.max(1.1, wallW * 0.07);
wins.push({ x: wx, y: wy, s, lit });
if (lit && (dusk || night) && yBase < H * 0.97) {
glows.push({ x: wx, y: yBase, s, d });
}
}
if (hasChimney && smoke) smokes.push({ x: chX, y: yBase - wallH - ridgeH - chH, d });
houses.push({ d, g, bx, yBase, wallW, wallH, ridgeH, wc, rc, hasChimney, chX, chH, chW, smoke, wins, roofK });
casters.push({ x: bx, y: yBase, span: wallW * 0.55, len: wallH * 1.6 });
}
// lit-window budget: decremented as windows claim their fire
function litLeft(budget) {
if (night) return true; // night: every pane burns
if (litBudget <= 0) return false;
litBudget -= 1;
return true;
}
houses.sort((a, b) => a.d - b.d); // far first, oil over oil
// trees: 2-5, branch fans or pollarded pops
const trees = [];
const nTrees = 2 + Math.floor(rng() * 4);
for (let i = 0; i < nTrees; i++) {
const d = 0.30 + rng() * 0.58;
const g = groundK(d);
const side = rng() < 0.5 ? -1 : 1;
let tx = roadCX(d) + side * (roadHalf(d) * 1.7 + W * (0.01 + rng() * 0.06) * g);
const ty = yOf(d);
const hgt = H * (0.10 + rng() * 0.16) * (0.4 + g * 1.1);
// a tree must never grow through a cottage: slide it outward, then try
// farther field positions — the 2-5 bare trees always paint SOMEWHERE
const hitsHouse = (x) => houses.some((hs) =>
x > hs.bx - hs.wallW * 1.7 && x < hs.bx + hs.wallW * 1.7
&& ty > hs.yBase - hs.wallH * 2.8 && ty < hs.yBase + hs.wallH * 0.6);
if (hitsHouse(tx)) {
let placed = false;
for (const s2 of [side, side, -side, -side, side, -side]) {
tx += s2 * W * 0.05;
if (!hitsHouse(tx) && tx > -W * 0.05 && tx < W * 1.05) { placed = true; break; }
}
if (!placed) { // the field is open: any clear x at this depth will do
for (let t2 = 0; t2 < 8 && !placed; t2++) {
tx = W * (0.04 + rng() * 0.92);
placed = !hitsHouse(tx);
}
if (!placed) continue;
}
}
if (tx < -W * 0.05 || tx > W * 1.05) continue;
trees.push({
d, g, tx, ty, hgt,
kind: rng() < 0.55 ? 'fan' : 'pollard',
lean: (rng() - 0.5) * 0.16,
nBr: 5 + Math.floor(rng() * 4),
seed: Math.floor(rng() * 0xffffff),
});
casters.push({ x: tx, y: ty, span: hgt * 0.06, len: hgt * 0.5 });
}
// fence posts marching along the road edges
const posts = [];
for (const side of [-1, 1]) {
let d = 0.14 + rng() * 0.04;
while (d < 0.97) {
const g = groundK(d);
const px = roadCX(d) + side * roadHalf(d) * 1.35;
const py = yOf(d);
if (px > -W * 0.02 && px < W * 1.02) {
posts.push({ x: px, y: py, h: H * (0.010 + 0.045 * g), d, side });
casters.push({ x: px, y: py, span: 0.8, len: H * 0.012 * g });
}
d += 0.045 + rng() * 0.035;
}
}
// figures: 1-3 tiny accents on or beside the road
const figures = [];
const nFig = 1 + Math.floor(rng() * 3);
const shawlIdx = rng() < 0.6 ? Math.floor(rng() * nFig) : -1;
for (let f = 0; f < nFig; f++) {
const d = 0.45 + rng() * 0.45;
const g = groundK(d);
const off = (rng() - 0.5) * 1.2;
const fx = roadCX(d) + off * roadHalf(d);
const fy = yOf(d);
const hh = H * (0.018 + 0.035 * g);
figures.push({
d, g, fx, fy, hh,
coat: pick(['#2c2a2e', '#3a3230', '#26303e', '#33261e']),
shawl: f === shawlIdx,
dir: rng() < 0.5 ? -1 : 1,
});
}
// ---- prime: the hushed day itself, painted once ------------------------
{
// only the sky's own stops feed the sky half; the gradient's dark `deep`
// stop below the horizon would drag a grey band through the snow
const sg = stops.filter((s) => s.at <= hzYn + 0.0001)
.map((s) => ({ at: s.at, color: nightFix(s.color) }));
const g = ctx.createLinearGradient(0, 0, 0, H);
for (const s of sg) g.addColorStop(s.at, s.color);
g.addColorStop(Math.min(1, hzYn + 0.001), mixHex(nightFix(skyLow), SN.light, 0.72)); // far snow dissolves into the sky
g.addColorStop(Math.min(1, hzYn + 0.22), SN.light);
g.addColorStop(1, mixHex(SN.light, SN.shadow, 0.26));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
add(0.001, (c) => {
const sg = stops.filter((s) => s.at <= hzYn + 0.0001)
.map((s) => ({ at: s.at, color: nightFix(s.color) }));
const g = c.createLinearGradient(0, 0, 0, H);
for (const s of sg) g.addColorStop(s.at, s.color);
g.addColorStop(Math.min(1, hzYn + 0.001), mixHex(nightFix(skyLow), SN.light, 0.72));
g.addColorStop(Math.min(1, hzYn + 0.22), SN.light);
g.addColorStop(1, mixHex(SN.light, SN.shadow, 0.26));
c.fillStyle = g;
c.fillRect(0, 0, W, H);
});
// -- the far haze band: the distance dissolves into the air ---------------
for (let i = 0; i < 26; i++) {
const y = hzY - H * 0.02 + rng() * H * 0.05;
stamped(0.004 + rng() * 0.014, veil(rng() * W, y, W * (0.10 + rng() * 0.24),
(rng() - 0.5) * 0.08, Math.max(2, H * (0.015 + rng() * 0.03)), hazeC, 0.04 + rng() * 0.07));
}
// ---- the sky: broad soft horizontal courses, breathing ------------------
// the light itself: plain house stroke — the spine must survive every wrist
for (let i = 0; i < 480; i++) {
const bandN = Math.pow(rng(), 0.9);
const y = H * 0.01 + bandN * Math.max(4, hzY - H * 0.03);
const cols = brokenColor(null, rng, nightFix(skyAt(bandN * 0.92)), 0.5);
const col = cols[Math.floor(rng() * cols.length)];
const x = -W * 0.03 + rng() * W * 1.06;
const len = W * (0.07 + rng() * 0.18) * (0.7 + bandN * 0.5);
const ang = (rng() - 0.5) * 0.07;
const wd = H * (0.010 + rng() * 0.022) * (0.75 + bandN * 0.6);
const a = 0.10 + rng() * 0.16;
stamped(0.020 + 0.074 * bandN + rng() * 0.012, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// ---- the sun or the moon: a pale, half-veiled disc ----------------------
if (cond.sunVisible && rng() < 0.45 && !snowing) { // Pissarro's snows often sunless
const sx = cond.sunX * W, sy = Math.min(cond.sunY * H, hzY - H * 0.05);
const discCol = golden ? '#f8dc9c' : dawn ? '#f2c8ac' : '#f6ecd2';
for (let i = 0; i < 10; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.8) * H * 0.012;
stamped(0.096 + rng() * 0.006, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.004 + rng() * 0.007), discCol, 0.10 + rng() * 0.16));
}
stamped(0.102, (c) => dab(c, sx, sy, H * 0.007, mixHex(discCol, '#ffffff', 0.4), 0.45));
} else if (night) {
for (let i = 0; i < 14; i++) { // the moon through its veil
const a = rng() * 6.2832, r = Math.pow(rng(), 0.8) * H * 0.016;
stamped(0.096 + rng() * 0.006, (c) => dab(c, moonX + Math.cos(a) * r, H * 0.14 + Math.sin(a) * r,
H * (0.005 + rng() * 0.010), '#dce4f0', 0.06 + rng() * 0.10));
}
stamped(0.102, (c, jr) => touchHero(c, jr, moonX, H * 0.14, H * 0.010, '#e6ecf4', 0.55));
}
// ---- the distant tree-line: a low hazed hedge on the horizon -----------
for (let i = 0, n = Math.round(130 * covK); i < n; i++) {
const x = rng() * W;
const y = hzY - Math.pow(rng(), 2) * H * 0.030 + rng() * H * 0.006;
const af = aerialFade(0.10);
let col = pick(['#6e7a6e', '#7a7484', '#5e6a62', '#867a70']);
col = mixHex(mixHex(col, af.haze, 0.45 + af.desat), skyLow, 0.15);
stamped(0.104 + rng() * 0.024, (c, jr) => smear(c, jr, x, y, W * (0.008 + rng() * 0.022),
(rng() - 0.5) * 0.12, Math.max(1.1, H * 0.005), col, 0.24 + rng() * 0.24, 0.55));
}
// ---- the night's sparse stars -------------------------------------------
if (night) {
for (let i = 0; i < 46; i++) {
const x = rng() * W;
const y = Math.pow(rng(), 1.2) * hzY * 0.85;
if (Math.hypot((x - moonX) / W, (y - H * 0.14) / H) < 0.08) continue; // the moon keeps its sky
stamped(0.128 + rng() * 0.008, (c) => dab(c, x, y, Math.max(0.5, H * (0.0012 + rng() * 0.0018)),
pick(['#dce4f0', '#e8ecda']), 0.10 + rng() * 0.32));
}
}
// ---- THE SNOW FIELD: the protagonist, the canvas itself -----------------
// warm whites and cool whites side by side; the light's column carries
// rose/ochre, the distance dissolves, the thaw opens ochre earth islands.
for (let i = 0, nField = Math.round(1250 * covK); i < nField; i++) {
const yn = Math.pow(rng(), 0.85); // 0 at the horizon, 1 at the frame bottom
const xn = rng();
const x = xn * W;
const y = hzY + 2 + yn * (H - hzY - 3);
const ik = islandK(x, yn);
let col = snowCol(x, yn);
const cols = brokenColor(null, rng, col, 0.42);
col = cols[Math.floor(rng() * cols.length)];
if (ik > 0.02) { // the thaw: ochre earth shows through in patches
col = mixHex(col, earthCol, Math.min(0.9, ik * 0.9));
col = mixHex(col, earthCol, rng() < 0.4 ? 0.3 : 0);
}
const len = W * (0.022 + rng() * 0.052) * (0.5 + yn * 1.1);
const ang = (rng() - 0.5) * 0.10;
const wd = Math.max(1.6, H * (0.008 + rng() * 0.016) * (0.5 + yn * 1.1));
const a = (0.24 + rng() * 0.26) * (0.65 + yn * 0.5) * (ik > 0.02 ? 0.75 : 1);
stamped(0.136 + 0.168 * yn + rng() * 0.012, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
// every third course adds a hand-laid touch: the wrist breaks the flat
if (i % 3 === 0) {
stamped(0.140 + 0.168 * yn + rng() * 0.014, (c, jr) => {
touch(c, jr, x + (rng() - 0.5) * len * 0.5, y + (rng() - 0.5) * wd,
Math.max(1.0, wd * 0.9), col, a * 0.55, ang, 0.7);
});
}
}
// ---- THE ROAD: a foot-packed track winding to the vanishing point -------
// most strokes run ALONG the track (the packed direction), a minority
// across it; the contrast stays gentle — a darker band, not a stripe set
const roadTangent = (d) => {
const e = 0.012;
const x0 = roadCX(Math.max(0, d - e)), x1 = roadCX(Math.min(1, d + e));
const y0 = yOf(Math.max(0, d - e)), y1 = yOf(Math.min(1, d + e));
return Math.atan2(y1 - y0, x1 - x0);
};
for (let i = 0; i < 240; i++) {
const d = Math.pow(rng(), 0.9);
const g = groundK(d);
const y = yOf(d);
const half = roadHalf(d);
const cx = roadCX(d);
const along = rng() < 0.62;
const x = cx + (rng() - 0.5) * 2 * half * (along ? 0.7 : 1);
let col = mixHex(snowCol(x, d), SN.shadow, 0.16 + rng() * 0.12);
col = mixHex(col, skyLow, (1 - d) * 0.30);
if (rng() < 0.2) col = mixHex(col, '#b0a48c', 0.08 + rng() * 0.08); // trodden earth
const len = along
? W * (0.035 + rng() * 0.075) * (0.4 + g) // long along the track: no rung gaps
: half * (0.5 + rng() * 0.8);
const ang = along ? roadTangent(d) + (rng() - 0.5) * 0.10 : roadTangent(d) + Math.PI / 2 + (rng() - 0.5) * 0.14;
const wd = Math.max(1.4, H * (0.005 + rng() * 0.011) * (0.4 + g));
const a = (0.16 + rng() * 0.18) * (0.6 + g * 0.5);
stamped(0.300 + 0.050 * d + rng() * 0.008, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// unify the surface: broad soft cross-veils so the track reads as one
// packed band, not a ladder of strokes
for (let i = 0; i < 14; i++) {
const d = 0.10 + rng() * 0.85;
const g = groundK(d);
const col = mixHex(snowCol(roadCX(d), d), SN.shadow, 0.14);
stamped(0.344 + rng() * 0.014, (c) => {
dab(c, roadCX(d), yOf(d), Math.max(3, roadHalf(d) * 0.9), col, 0.10 + rng() * 0.08,
roadTangent(d), 0.30);
});
}
// the ruts: two fainter lines where feet and wheels packed hardest
for (let i = 0; i < 36; i++) {
const d = Math.pow(rng(), 0.85);
const g = groundK(d);
const side = i % 2 === 0 ? -0.45 : 0.45;
const x = roadCX(d) + roadHalf(d) * side;
const y = yOf(d);
const col = mixHex(snowCol(x, d), SN.deep, 0.20 + rng() * 0.12);
stamped(0.352 + rng() * 0.018, (c, jr) => {
band(c, jr, x, y, W * (0.008 + rng() * 0.016) * (0.4 + g), roadTangent(d) + (rng() - 0.5) * 0.08,
Math.max(1.0, H * 0.003 * (0.4 + g)), col, 0.16 + rng() * 0.14);
});
}
// ---- drift lines: dice-placed wind shadows on the open field ------------
for (let i = 0, nDrift = Math.round((drifting ? 110 : 70) * covK); i < nDrift; i++) {
const yn = 0.06 + rng() * 0.92;
const x = rng() * W;
const y = hzY + yn * (H - hzY);
if (islandK(x, yn) > 0.3) continue;
const col = rng() < 0.55 ? mixHex(snowCol(x, yn), '#fbf8f0', 0.35) // a lit crest
: mixHex(snowCol(x, yn), SN.shadow, 0.35); // or a blue trough
stamped(0.374 + rng() * 0.024, (c, jr) => {
smear(c, jr, x, y, W * (0.012 + rng() * 0.030) * (0.5 + yn), (rng() - 0.5) * 0.06,
Math.max(1.0, H * (0.0025 + rng() * 0.004)), col, 0.16 + rng() * 0.22, 0.4);
});
}
// ---- THE LONG SHADOWS: the most colourful shadows in painting ----------
// cast from every caster (house, tree, post) away from the light, stretched
// by the hour's conduct; plain skeleton bands, hand-laid texture over.
const shadowPass = (cs, p0, p1) => {
const dir = (cs.x - lightX) >= 0 ? 1 : -1;
const baseLen = (W * 0.03 + cs.len * 1.1) * shLen * (0.7 + rng() * 0.6);
const nS = Math.max(5, Math.round(12 * (0.5 + shK)));
for (let k = 0; k < nS; k++) {
const t = k / Math.max(1, nS - 1);
const x = cs.x + dir * baseLen * t * (0.75 + rng() * 0.5);
const y = cs.y + baseLen * t * 0.14 * (0.4 + rng() * 0.6); // sun high-ish: shadows lean toward us
const col = mixHex(SN.shadow, SN.deep, 0.15 + rng() * 0.45);
const wd = Math.max(1.2, (cs.span + baseLen * t * 0.28) * (0.26 + rng() * 0.20));
const a = shK * (0.30 - 0.18 * t) * (0.7 + rng() * 0.5);
if (a <= 0.01) continue;
stamped(p0 + (p1 - p0) * t + rng() * 0.004, (c, jr) => {
band(c, jr, x, y, baseLen / nS * 1.6 + W * 0.004, dir * 0.02 + (rng() - 0.5) * 0.10, wd, col, a);
});
if (k % 2 === 0) { // a plain soft note breaks the band's edge — the
// hand's 'pure'/'opaque' curves would re-ink these mass shadows into
// polka dots (pointillisme/empatement lesson, iteration 3)
stamped(p0 + (p1 - p0) * t + 0.002 + rng() * 0.003, (c) => {
dab(c, x + dir * wd * 0.4, y, wd * 0.9, col, a * 0.5, 0, 0.6);
});
}
}
};
{
let ci = 0;
const total = casters.length;
for (const cs of casters) {
shadowPass(cs, 0.372 + 0.044 * (ci / Math.max(1, total)), 0.416 + 0.044 * (ci / Math.max(1, total)));
ci++;
}
// generic field undulations: broad soft blue dabs where the drifts pool.
// PLAIN dabs, twice over — routed through the hand they become hard
// polka dots under pointillisme/empatement (iteration-3 lesson).
for (let i = 0; i < 70; i++) {
const yn = 0.15 + rng() * 0.8;
const x = rng() * W;
const y = hzY + yn * (H - hzY);
if (islandK(x, yn) > 0.3) continue;
const col = mixHex(SN.shadow, SN.deep, rng() * 0.4);
const sz = Math.max(2.0, H * (0.010 + rng() * 0.016) * (0.5 + yn));
const a = shK * (0.07 + rng() * 0.10);
stamped(0.400 + rng() * 0.020, (c) => {
dab(c, x, y, sz, col, a, 0, 0.45);
dab(c, x + sz * 0.3, y + sz * 0.2, sz * 1.3, col, a * 0.5, 0, 0.4);
});
}
}
// ---- fence posts marching, each capped ----------------------------------
for (let i = 0; i < posts.length; i++) {
const ps = posts[i];
const col = mixHex('#4a4038', hazeC, misty ? 0.30 : (1 - ps.d) * 0.25);
stamped(0.428 + 0.036 * (i / Math.max(1, posts.length)) + rng() * 0.004, (c, jr) => {
rigger(c, jr, {
x: ps.x, y: ps.y, length: ps.h, color: col, alpha: 0.55 + rng() * 0.25,
angle: -Math.PI / 2 + (rng() - 0.5) * 0.08, width: Math.max(0.8, H * 0.0022 * (0.4 + ps.d * 1.4)),
bow: (rng() - 0.5) * ps.h * 0.08,
});
});
const capK = thaw ? 0.5 : 1;
stamped(0.430 + 0.036 * (i / Math.max(1, posts.length)) + rng() * 0.004, (c, jr) => {
touch(c, jr, ps.x, ps.y - ps.h, Math.max(0.9, H * 0.0028 * (0.5 + ps.d) * capK),
mixHex(SN.light, '#ffffff', 0.3), 0.55 * capK, 0, 0.8);
});
}
// the rails: thin lines stringing the posts, one dice-rolled side
if (rng() < 0.55) {
const railSide = pick([-1, 1]);
const railPosts = posts.filter((p) => p.side === railSide).sort((a, b) => a.d - b.d);
for (let i = 1; i < railPosts.length; i++) {
if (rng() < 0.5) continue; // broken rails: a fence, not a ladder
const p0 = railPosts[i - 1], p1 = railPosts[i];
if (p1.y - p0.y < H * 0.012) continue; // too far: the rail reads as noise
const col = mixHex('#4a4038', hazeC, 0.30);
stamped(0.462 + rng() * 0.006, (c, jr) => {
stroke(c, jr, {
path: [[p0.x, p0.y - p0.h * 0.72], [p1.x, p1.y - p1.h * 0.72]],
brush: 'round', width: Math.max(0.7, H * 0.0018 * (0.4 + p1.d * 1.2)),
color: col, alpha: 0.16 + 0.20 * p1.d, taper: 'none', spacing: 0.4, // fades with distance
});
});
}
}
// ---- THE VILLAGE: humble cottages, far to near --------------------------
for (let hi = 0; hi < houses.length; hi++) {
const hs = houses[hi];
const baseY = hs.yBase, wallTop = baseY - hs.wallH;
const left = hs.bx - hs.wallW / 2, right = hs.bx + hs.wallW / 2;
const litSide = (lightX >= hs.bx) ? 1 : -1;
// the wall: FEW FAT slabs covering the full face (soft brush cores must
// merge — narrow courses stripe like pickets), broken between neighbours
const nWall = Math.max(3, Math.round(5 * (0.5 + hs.g)));
const wallMidY = (baseY + wallTop) / 2;
for (let k = 0; k < nWall; k++) {
const x = left + (k + 0.5) / nWall * hs.wallW + (rng() - 0.5) * hs.wallW * 0.04;
const lit = (x - hs.bx) * litSide > 0;
const cols = brokenColor(null, rng, hs.wc, 0.45);
let col = cols[Math.floor(rng() * cols.length)];
if (!lit) col = mixHex(col, night ? '#1a1e30' : SN.deep, 0.16 + 0.14 * lowSunF);
stamped(0.470 + 0.180 * (hi / houses.length) + rng() * 0.008, (c, jr) => {
band(c, jr, x, wallMidY, hs.wallH * 1.3, -Math.PI / 2 + (rng() - 0.5) * 0.05,
Math.max(2.2, hs.wallW / nWall * 2.1), col, 0.80 + rng() * 0.18);
});
}
// one broken texture pass over the whole face, then the value split:
// the shaded side deepens, the lit side lifts
for (let k = 0; k < 4; k++) {
const x = left + rng() * hs.wallW;
const lit = (x - hs.bx) * litSide > 0;
const col = lit ? mixHex(hs.wc, '#f0e4d4', 0.20) : mixHex(hs.wc, night ? '#1a1e30' : SN.deep, 0.30);
stamped(0.478 + 0.180 * (hi / houses.length) + rng() * 0.008, (c, jr) => {
band(c, jr, x, wallMidY + (rng() - 0.5) * hs.wallH * 0.5, hs.wallH * 0.8,
-Math.PI / 2 + (rng() - 0.5) * 0.10, Math.max(1.6, hs.wallW * 0.16), col, 0.30 + rng() * 0.22);
});
}
// snow packed against the wall's foot: a soft loaded drift, not a plank
stamped(0.480 + 0.180 * (hi / houses.length) + rng() * 0.010, (c, jr) => {
band(c, jr, hs.bx, baseY - hs.wallH * 0.02, hs.wallW * 1.2, 0.03 * (rng() - 0.5),
Math.max(1.4, hs.wallH * 0.10), mixHex(SN.light, '#ffffff', 0.20), 0.34);
band(c, jr, hs.bx + (rng() - 0.5) * hs.wallW * 0.4, baseY + hs.wallH * 0.05,
hs.wallW * 0.9, 0.04 * (rng() - 0.5), Math.max(1.2, hs.wallH * 0.07),
mixHex(SN.light, '#ffffff', 0.30), 0.26);
});
// windows and door: the only heat in the picture
for (const wn of hs.wins) {
if (wn.lit) {
const amber = pick(['#f2b95c', '#e8a848', '#f6cc7a']);
stamped(0.500 + 0.180 * (hi / houses.length) + rng() * 0.010, (c, jr) => {
touchHero(c, jr, wn.x, wn.y, wn.s, amber, night ? 0.95 : dusk ? 0.85 : 0.7);
dab(c, wn.x, wn.y, wn.s * 1.6, amber, 0.18);
});
} else {
stamped(0.500 + 0.180 * (hi / houses.length) + rng() * 0.010, (c, jr) => {
touch(c, jr, wn.x, wn.y, wn.s, mixHex('#3a3f4a', hs.wc, 0.25), 0.6, 0, 1.3);
});
}
}
if (rng() < 0.7) { // the door
const dx = hs.bx + (rng() - 0.5) * hs.wallW * 0.5;
stamped(0.505 + 0.180 * (hi / houses.length) + rng() * 0.008, (c, jr) => {
band(c, jr, dx, baseY - hs.wallH * 0.22, hs.wallH * 0.42, -Math.PI / 2,
Math.max(1.2, hs.wallW * 0.13), mixHex(hs.rc, '#241c16', 0.4), 0.7);
});
}
// the roof: a SOLID pitched triangle — horizontal dark courses filling
// from the ridge down to the eaves (a roof must read as one plane; long
// slope bands overshoot their ends and grow horns)
const apexY = wallTop - hs.ridgeH;
const eaveX = hs.wallW * 0.62;
const nRows = Math.max(3, Math.round(5 * (0.5 + hs.g)) + 2);
for (let k = 0; k < nRows; k++) {
const t = (k + 0.6) / nRows; // 0 at the ridge, 1 at the eaves
const y = apexY + t * hs.ridgeH;
const halfW = Math.max(1.5, eaveX * t);
stamped(0.520 + 0.180 * (hi / houses.length) + 0.012 * (k / nRows) + rng() * 0.008, (c, jr) => {
band(c, jr, hs.bx + (rng() - 0.5) * eaveX * 0.10, y, halfW * 2.15, (rng() - 0.5) * 0.05,
Math.max(2.0, hs.ridgeH / nRows * 1.7), hs.rc, 0.85);
});
if (k % 2 === 0) { // a broken slate note inside the plane
stamped(0.524 + 0.180 * (hi / houses.length) + 0.012 * (k / nRows) + rng() * 0.008, (c, jr) => {
band(c, jr, hs.bx + (rng() - 0.5) * halfW, y + hs.ridgeH * 0.06, halfW * 1.2,
(rng() - 0.5) * 0.08, Math.max(1.4, hs.ridgeH / nRows * 0.9),
brokenColor(null, rng, hs.rc, 0.4)[0], 0.40);
});
}
}
// THE SNOW CAP: white riding both slopes' upper edges (heavier at the
// ridge) + the crest line + the eave overhang lip. The band that sells
// winter; kept narrow so the dark slate shows beneath.
const capN = Math.max(2, Math.round(3 * (0.5 + hs.g) * hs.roofK) + 1);
for (const slope of [-1, 1]) {
const ang = Math.atan2(-hs.ridgeH, eaveX) * slope;
for (let k = 0; k < capN; k++) {
const t = (k + 0.5) / capN;
const ridgeBias = Math.pow(1 - t, 1.4) * 0.5 + 0.5;
const x = hs.bx + slope * eaveX * t + (drifting && slope === driftSide ? eaveX * 0.03 : 0);
const y = apexY + t * hs.ridgeH - Math.max(0.8, hs.ridgeH * (0.10 + ridgeBias * 0.14));
const capW = Math.max(1.2, (Math.hypot(eaveX, hs.ridgeH) / capN) * (0.26 + ridgeBias * 0.30) * hs.roofK * 0.85);
stamped(0.545 + 0.180 * (hi / houses.length) + rng() * 0.012, (c, jr) => {
touch(c, jr, x, y, capW, mixHex(SN.light, '#ffffff', 0.30), (0.45 + rng() * 0.25) * hs.roofK, ang, 0.55);
});
}
}
// the eave overhang: a horizontal lip of loaded snow past the wall face
stamped(0.560 + 0.180 * (hi / houses.length) + rng() * 0.010, (c, jr) => {
band(c, jr, hs.bx, wallTop + hs.ridgeH * 0.04, hs.wallW * 1.35, 0.02 * (rng() - 0.5),
Math.max(1.4, hs.ridgeH * 0.30) * hs.roofK, mixHex(SN.light, '#ffffff', 0.3), 0.55 * hs.roofK);
});
// the ridge: the cap's crest line, sitting ON the apex
stamped(0.570 + 0.180 * (hi / houses.length) + rng() * 0.008, (c, jr) => {
band(c, jr, hs.bx, apexY - Math.max(0.6, hs.ridgeH * 0.05), Math.max(2, hs.wallW * 0.42),
0, Math.max(1.2, hs.ridgeH * 0.20) * hs.roofK, mixHex(SN.light, '#ffffff', 0.4), 0.7 * hs.roofK);
});
// the chimney: a dark stub with its own snow cap
if (hs.hasChimney) {
const chBaseY = lerp(wallTop, apexY, Math.abs(hs.chX - hs.bx) / Math.max(1, eaveX));
stamped(0.576 + 0.180 * (hi / houses.length) + rng() * 0.006, (c, jr) => {
band(c, jr, hs.chX, chBaseY - hs.chH * 0.5, hs.chH, -Math.PI / 2, hs.chW,
mixHex('#3a322c', hazeC, (1 - hs.g) * 0.25), 0.75);
touch(c, jr, hs.chX, chBaseY - hs.chH, hs.chW * 0.7, mixHex(SN.light, '#ffffff', 0.3), 0.6, 0, 0.7);
});
}
casters.push({ x: hs.bx, y: baseY, span: hs.wallW * 0.4, len: hs.wallH }); // the roof casts too
}
// ---- THE TREES: black-brown fans, snow dusted on the upper limbs --------
for (let ti = 0; ti < trees.length; ti++) {
const tr = trees[ti];
const br = mulberry32((0x7EE + tr.seed * 31) >>> 0); // the tree's own wrist
let trunkCol = mixHex('#3a2e24', hazeC, misty ? 0.30 : (1 - tr.g) * 0.28);
if (dusk || night) trunkCol = mixHex(trunkCol, '#1a1a26', 0.30);
const dustCol = mixHex(SN.light, '#ffffff', 0.3);
// the trunk
stamped(0.690 + 0.082 * (ti / Math.max(1, trees.length)) + rng() * 0.004, (c, jr) => {
rigger(c, jr, {
x: tr.tx, y: tr.ty, length: tr.hgt, color: trunkCol, alpha: 0.72,
angle: -Math.PI / 2 + tr.lean, width: Math.max(1.0, tr.hgt * 0.055), bow: tr.lean * tr.hgt * 0.35,
});
});
stamped(0.691 + 0.082 * (ti / Math.max(1, trees.length)) + rng() * 0.004, (c, jr) => {
rigger(c, jr, { // the trunk's shaded flank
x: tr.tx + tr.hgt * 0.02, y: tr.ty, length: tr.hgt * 0.85, color: mixHex(trunkCol, '#141210', 0.4),
alpha: 0.5, angle: -Math.PI / 2 + tr.lean, width: Math.max(0.6, tr.hgt * 0.02), bow: tr.lean * tr.hgt * 0.4,
});
});
if (tr.kind === 'fan') {
// branch fans radiating from the upper trunk
for (let b = 0; b < tr.nBr; b++) {
const t = 0.45 + 0.55 * (b / Math.max(1, tr.nBr - 1));
const bxx = tr.tx + Math.sin(tr.lean) * tr.hgt * t;
const byy = tr.ty - tr.hgt * t;
const side = b % 2 === 0 ? -1 : 1;
const ang = -Math.PI / 2 + side * (0.45 + br() * 0.75) + tr.lean * 0.5;
const bl = tr.hgt * (0.22 + br() * 0.30) * (1.15 - t * 0.35);
stamped(0.700 + 0.082 * (ti / Math.max(1, trees.length)) + 0.010 * (b / tr.nBr) + rng() * 0.004, (c, jr) => {
rigger(c, jr, { x: bxx, y: byy, length: bl, color: trunkCol, alpha: 0.62,
angle: ang, width: Math.max(0.7, tr.hgt * 0.016), bow: side * bl * 0.22 });
// a twig, sometimes
if (br() < 0.6) {
rigger(c, jr, {
x: bxx + Math.cos(ang) * bl * 0.7, y: byy + Math.sin(ang) * bl * 0.7,
length: bl * 0.45, color: mixHex(trunkCol, '#141210', 0.3), alpha: 0.5,
angle: ang + side * (0.3 + br() * 0.4), width: Math.max(0.5, tr.hgt * 0.009),
bow: side * bl * 0.1,
});
}
});
// snow dusting on the upward face of the upper branches
if (t > 0.6 && ang < -Math.PI / 2 + 0.6 && !thaw) {
const nD = 2 + Math.floor(br() * 3);
for (let k = 0; k < nD; k++) {
const u = 0.3 + br() * 0.6;
stamped(0.706 + 0.082 * (ti / Math.max(1, trees.length)) + 0.010 * (b / tr.nBr) + rng() * 0.004, (c, jr) => {
touch(c, jr, bxx + Math.cos(ang) * bl * u, byy + Math.sin(ang) * bl * u - tr.hgt * 0.006,
Math.max(0.8, tr.hgt * 0.014), dustCol, 0.35 + br() * 0.3, ang, 0.55);
});
}
}
}
} else { // pollarded pop: a straight trunk under a knobby head
const headR = tr.hgt * 0.14;
const headY = tr.ty - tr.hgt;
for (let b = 0; b < 5; b++) {
const ang = -Math.PI / 2 + (b / 4 - 0.5) * 2.2 + tr.lean;
stamped(0.700 + 0.082 * (ti / Math.max(1, trees.length)) + rng() * 0.006, (c, jr) => {
rigger(c, jr, {
x: tr.tx, y: headY + headR * 0.4, length: headR * (1.1 + br() * 0.5),
color: trunkCol, alpha: 0.62, angle: ang, width: Math.max(0.8, headR * 0.28), bow: 0,
});
});
}
stamped(0.704 + 0.082 * (ti / Math.max(1, trees.length)) + rng() * 0.006, (c, jr) => {
touch(c, jr, tr.tx, headY, headR * 1.4, mixHex(trunkCol, '#141210', 0.3), 0.55, 0, 0.8);
if (!thaw) touch(c, jr, tr.tx, headY - headR * 0.5, headR * 0.9, dustCol, 0.5, 0, 0.5);
});
}
// the trunk's dark pool at its foot
stamped(0.712 + 0.082 * (ti / Math.max(1, trees.length)) + rng() * 0.004, (c, jr) => {
touch(c, jr, tr.tx, tr.ty, Math.max(1.4, tr.hgt * 0.09), mixHex(trunkCol, '#101010', 0.3), 0.30, 0, 0.4);
});
}
// ---- FIGURES: tiny accents, footprints dotted behind --------------------
for (let f = 0; f < figures.length; f++) {
const fg = figures[f];
const coatC = fg.shawl ? mixHex(fg.coat, '#5a6a9a', 0.55) : fg.coat;
stamped(0.786 + f * 0.014 + rng() * 0.004, (c, jr) => {
// the coat: one dark vertical, the head a warm-dark dot
band(c, jr, fg.fx, fg.fy - fg.hh * 0.48, fg.hh * 0.55, -Math.PI / 2 + (rng() - 0.5) * 0.12,
Math.max(1.1, fg.hh * 0.30), coatC, 0.82);
dab(c, fg.fx, fg.fy - fg.hh * 0.86, Math.max(0.7, fg.hh * 0.15), mixHex(coatC, '#c8a88a', 0.35), 0.75);
if (fg.shawl) { // the blue shawl across the shoulders
touch(c, jr, fg.fx, fg.fy - fg.hh * 0.66, Math.max(0.9, fg.hh * 0.24), '#6a7aa8', 0.6, 0.2, 0.6);
}
// legs
band(c, jr, fg.fx - fg.hh * 0.08, fg.fy - fg.hh * 0.16, fg.hh * 0.22, -Math.PI / 2,
Math.max(0.6, fg.hh * 0.11), mixHex(coatC, '#101014', 0.4), 0.7);
band(c, jr, fg.fx + fg.hh * 0.10, fg.fy - fg.hh * 0.14, fg.hh * 0.20, -Math.PI / 2 + fg.dir * 0.25,
Math.max(0.6, fg.hh * 0.11), mixHex(coatC, '#101014', 0.4), 0.7);
});
// the footprints: a dotted line of dents trailing up the road
const nPrint = 6 + Math.floor(rng() * 6);
for (let k = 0; k < nPrint; k++) {
const dK = fg.d - (k + 1) * 0.014;
if (dK < 0.1) break;
const px = roadCX(dK) + fg.fx - roadCX(fg.d);
const gK = groundK(dK) / Math.max(0.05, fg.g);
stamped(0.792 + f * 0.014 + rng() * 0.004, (c, jr) => {
touch(c, jr, px, yOf(dK), Math.max(0.7, fg.hh * 0.10 * gK),
mixHex(SN.shadow, SN.deep, 0.4), 0.30 + rng() * 0.20, 0, 0.8);
});
}
}
// ---- SMOKE THREADS: the chimneys' thin grey plumes ----------------------
for (let s = 0; s < smokes.length; s++) {
const sm = smokes[s];
const segs = 2 + Math.floor(rng() * 2);
const bend = (drifting ? 0.5 : 0.15) * (rng() < 0.5 ? -1 : 1);
for (let k = 0; k < segs; k++) {
const u = k / segs;
stamped(0.844 + s * 0.008 + rng() * 0.004, (c, jr) => {
rigger(c, jr, {
x: sm.x + bend * sm.d * W * 0.06 * u * u, y: sm.y - u * H * 0.045,
length: H * 0.035, color: mixHex('#b8b4ac', hazeC, 0.3), alpha: 0.16 + rng() * 0.10,
angle: -Math.PI / 2 + bend * (0.15 + u * 0.5), width: Math.max(0.7, H * 0.0022 * (1 + u)),
bow: bend * H * 0.010 * (0.3 + u),
});
});
}
}
// ---- GLOW POOLS: the lit window's warmth spilling onto the snow ---------
if (dusk || night) {
for (let g = 0; g < glows.length; g++) {
const gl = glows[g];
const toward = (roadCX(gl.d) >= gl.x) ? 1 : -1;
for (let k = 0; k < 5; k++) {
const t = k / 5;
stamped(0.876 + 0.020 * (g / Math.max(1, glows.length)) + rng() * 0.006, (c, jr) => {
touch(c, jr, gl.x + toward * gl.s * (1 + t * 3.4), gl.y + gl.s * (0.8 + t * 2.8),
Math.max(2.0, gl.s * (2.2 - t * 0.9)), mixHex('#f2b95c', SN.light, 0.30),
(night ? 0.30 : 0.20) * (1 - t * 0.72), 0, 0.5);
});
}
}
}
// ---- the hand's last pass: the snow catching the light ------------------
for (let i = 0, nSpark = Math.round(110 * covK); i < nSpark; i++) {
const yn = 0.15 + rng() * 0.85;
const x = rng() * W;
const y = hzY + yn * (H - hzY);
if (islandK(x, yn) > 0.35) continue;
const nearLight = 1 - Math.min(1, Math.abs(x - lightX) / (W * 0.6));
if (nearLight < 0.30 && rng() < 0.6) continue; // the lit column sparkles most
const col = crisp || golden || dawn
? mixHex('#ffffff', SN.warm, 0.25)
: mixHex(SN.light, '#ffffff', 0.5);
stamped(0.900 + rng() * 0.022, (c, jr) => {
touch(c, jr, x, y, Math.max(0.8, H * 0.0025 * (0.5 + yn)), col, 0.14 + rng() * 0.22, 0, 0.6);
});
}
// ---- the final veils: weather softens every edge one last time ----------
if (misty || snowing || overcast) {
const veilCol = mixHex('#e2e6e4', skyLow, night ? 0.55 : (dusk || dawn) ? 0.35 : 0.15);
for (let i = 0; i < 34; i++) {
const x = rng() * W;
const y = rng() * H;
stamped(0.936 + rng() * 0.040, veil(x, y, W * (0.15 + rng() * 0.30), (rng() - 0.5) * 0.04,
H * (0.02 + rng() * 0.04), veilCol, 0.03 + rng() * 0.05));
}
}
// ---- the falling snow: the storm's own pass, over everything ------------
if (snowing) {
for (let i = 0; i < 130; i++) {
const x = rng() * W;
const y = rng() * H;
const sz = Math.max(0.7, H * (0.0012 + rng() * 0.0026));
const near = 0.35 + rng() * 0.65; // nearer flakes read denser
stamped(0.966 + rng() * 0.028, (c, jr) => {
dab(c, x, y, sz, '#f2f6fa', 0.30 + near * 0.40);
if (near > 0.75) dab(c, x + driftSide * sz * 1.6, y + sz * 2.2, sz * 0.7, '#f2f6fa', 0.22);
});
}
}
strokes = list.sort((a, b) => a.p - b.p);
if (typeof console !== 'undefined' && typeof location !== 'undefined'
&& location.search.indexOf('debug=1') >= 0) {
console.log('[neige] strokes:', strokes.length, 'tod:', tod, 'season:', season,
'weather:', weather, 'hand:', cond.technique, 'houses:', houses.length,
'trees:', trees.length, 'posts:', posts.length, 'figs:', figures.length,
'glows:', glows.length, 'casters:', casters.length);
}
},
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);
}
},
};
// Nymphéas — a homage to Monet's late water-lily decorations, Giverny
// 1914-1926: the pond as a world WITHOUT horizon. No bridge, no banks, no
// garden — the canvas is ALL POND, the viewer floats on the surface itself,
// and what we see of the sky exists only as REFLECTION. Colour floats on
// colour; this is the most abstract piece in the atelier.
//
// THE WATER IS THE WORLD. Three registers share one tonal spine: the
// reflection-clouds of the upper third are the palest register (the sky,
// as it exists here — soft horizontal masses whose edges wobble on the
// surface), the water's ground is the mid register (layered broken
// horizontal bands of violet-green, emerald, indigo and rose-grey, graded
// by a slow "breathing" field so the surface swells and sinks in value —
// never one flat green), and the lily pads are the mid-dark register
// floating on top. Blossoms and glints are the brightest accents.
//
// THE CURRENT IS THE SPINE. One dice-rolled curve — a loose diagonal or
// meander, sometimes arcing like the start of a spiral — is shared by
// everything that drifts: the pad clusters gather along it, denser on the
// line, the water bands lean with it, the pads align their long axis with
// it. Nothing scatters at random; the pond flows.
//
// THE PADS are the stars: 12-30 of them in clusters, each built from a few
// angled dabs with a violet-blue underside shadow, a lighter rim on the
// light side (the slightly upturned look) and the classic darker notch.
// Far pads are smaller and hazier, near pads broader and richer. BLOSSOMS
// are rare jewels (2-7): white, pink, a rare red, each with a yellow
// centre and a trembling inverted smudge of a reflection beneath.
// Accents, dice-gated: willow strands trailing from a corner and melting
// into the water, a sun- or moon-glint path of shimmering pale dashes,
// iris blades reflected at an edge, rain rings breaking the reflections,
// autumn leaves afloat, fireflies at night.
//
// HOUSE BUG WORKAROUND (mouille): brushes.bleed() costs ~25ms/stroke
// proportional to canvas area — a full-canvas freeze under the mouille
// hand can hang chrome. This scene therefore NEVER routes its ~5000
// routine touches through handDab when hand.mode === 'wet': wetTouch /
// wetSmear realize the wet hand by soft multi-dab layering (core dab +
// wider faint halo, no ctx.filter). bleed() is reserved for a capped
// handful of HERO elements only (≤14: the largest pads, the glint core),
// each a single bleed call, so the mouille freeze stays interactive.
//
// Contract: identical to scenes/bled.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; nothing consumes
// randomness at draw time. Routine touches route through
// brushes.handFor(conditions.technique) + handDab (wetLite under mouille).
import {
mulberry32, makeConditions, weatherOverlay, skyGradient,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, bleed, dab, roundDab, stroke,
} from '../brushes.js';
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
let strokes = null;
export default {
id: 'nympheas',
title: 'Nymphéas',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Nymphéas, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1926));
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', noon = tod === 'noon';
const morning = tod === 'morning';
const spring = season === 'spring', autumn = season === 'autumn';
const winter = season === 'winter';
const misty = weather === 'mist', rainy = weather === 'rain';
const stormy = weather === 'overcast' || rainy;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
// one dedicated jitter stream per stroke, consumed lazily exactly once;
// weather touches ride on ~12% of strokes so rain and mist stay airy.
const stamped = (p, fn) => {
const jr = mulberry32((0x71BED + Math.floor(rng() * 0xffffff) * 617) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; scene keeps painting */ }
}
});
};
// -- THE HAND: the rolled technique decides how every touch sits -------
const hand = handFor(cond.technique);
// small hands (sec, pointillisme) cover less area per touch; the big
// water masses compensate so the pond closes over in every hand — the
// wrist changes how the paint sits, never what the composition says.
const covK = Math.min(1.6, Math.max(1, 1 / Math.max(0.25,
((hand.strokeLength[0] + hand.strokeLength[1]) / 2) *
((hand.dabSize[0] + hand.dabSize[1]) / 2))));
// MOUILLE WORKAROUND: the routine touches skip handDab's wet mode (its
// bleed() ctx.filter is the house bug) and realize the hand with soft
// multi-dab layering instead — same size band and ink curve as the hand
// table asks, no filter. Only hero elements spend the bleed budget.
const isWet = hand.mode === 'wet';
const wetTouch = (g, jr, x, y, w, c, a, ang, squash) => {
const ds = hand.dabSize;
const k = ds[0] + (ds[1] - ds[0]) * jr();
const size = Math.max(0.5, w * 0.5 * k);
const ab = hand.alphaBase;
dab(g, x, y, size, c, a * 0.85 * ab, ang, squash);
dab(g, x, y, size * 1.35, c, a * 0.28 * ab, ang, squash * 0.92); // the bleed halo
if (jr() < 0.5) dab(g, x + (jr() - 0.5) * size, y + (jr() - 0.5) * size * 0.7,
size * 0.6, c, a * 0.38 * ab, ang, squash);
};
const wetSmear = (g, jr, x, y, len, w, c, a, ang) => {
const ds = hand.dabSize, sl = hand.strokeLength;
const L = len * (sl[0] + (sl[1] - sl[0]) * jr());
const hl = Math.max(1, L * 0.5 * (ds[0] + (ds[1] - ds[0]) * jr()));
const sq = clamp((w / hl) * 0.9, 0.10, 1);
const ab = hand.alphaBase;
dab(g, x, y, hl, c, a * 0.8 * ab, ang, sq);
dab(g, x, y, hl * 1.22, c, a * 0.26 * ab, ang, Math.min(1, sq * 1.3)); // the halo
if (jr() < 0.6) dab(g, x + Math.cos(ang) * L * 0.18 * (jr() - 0.5) * 2,
y + Math.sin(ang) * L * 0.18 * (jr() - 0.5) * 2, hl * 0.55, c, a * 0.4 * ab, ang, sq);
};
const touch = (x, y, w, c, a, ang = 0, squash = 1) => (cc, jr) => {
if (isWet) return wetTouch(cc, jr, x, y, w, c, a, ang, squash);
handDab(cc, jr, hand, {
x, y, len: 0, ang, w, c, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: a, angle: ang, squash }),
});
};
const smear = (x, y, len, ang, w, c, a, squash = 1) => (cc, jr) => {
if (isWet) return wetSmear(cc, jr, x, y, len, w, c, a, ang);
handDab(cc, jr, hand, {
x, y, len, ang, w, c, a, squash,
plain: (g) => stroke(g, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: c, alpha: a, taper: 'none', spacing: 0.55,
}),
});
};
// the soft air — mist, halos, glints — stays translucent in every hand
const veil = (x, y, len, ang, w, c, a) => (cc) => {
const half = Math.max(1, len * 0.5);
const alpha = a * hand.alphaBase;
dab(cc, x, y, half, c, alpha, ang, Math.min(1, w / half));
dab(cc, x, y, half * 1.45, c, alpha * 0.35, ang, Math.min(1, (w * 1.5) / (half * 1.45)));
};
// HERO bleed budget: only the mouille hand spends it (≤14 single calls).
let heroBudget = isWet ? 14 : 0;
const heroPad = (x, y, rx, ry, c, a, ang) => {
if (heroBudget <= 0) return null;
heroBudget--;
return (cc) => bleed(cc, clamp(rx * 0.22, 1.2, 4), (g) => {
dab(g, x, y, rx * 0.9, c, a, ang, clamp(ry / rx, 0.15, 1));
dab(g, x + rx * 0.25, y + ry * 0.2, rx * 0.55, c, a * 0.6, ang, clamp(ry / rx, 0.15, 1));
});
};
// -- THE CURRENT: one dice-rolled curve the whole pond drifts with -----
const curDir = rng() < 0.5 ? -1 : 1;
const curF = 1.1 + rng() * 1.6, curP = rng() * Math.PI * 2;
const arcAmp = rng() < 0.35 ? (0.10 + rng() * 0.16) : 0; // a loose spiral's first breath
const arcSign = rng() < 0.5 ? -1 : 1;
const curLean = curDir * (0.06 + rng() * 0.10); // the bands' lean, radians
const curBand = 0.14 + rng() * 0.10; // the current's half-width
// the current's centre line: a meandering diagonal, optionally arcing
// (pure function of xn — never consumes the rng stream)
const curY = (xn) => clamp01(0.44 + curDir * (0.14 + 0.20 * Math.sin(xn * curF * Math.PI + curP))
+ arcSign * arcAmp * (2 * (xn - 0.5) * (xn - 0.5) - 0.15));
const curPull = (xn, yn) => Math.exp(-Math.pow((yn - curY(xn)) / curBand, 2));
// -- THE BREATHING: one slow value field over the whole surface --------
const brF1 = 1.5 + rng() * 2, brP1 = rng() * Math.PI * 2;
const brF2 = 2.5 + rng() * 2.5, brP2 = rng() * Math.PI * 2;
const breath = (xn, yn) => Math.sin(xn * brF1 * TAU + yn * 2.0 + brP1) * 0.6
+ Math.sin(xn * brF2 * TAU - yn * 3.0 + brP2) * 0.4; // -1 .. 1
// -- palettes: the pond at every hour ----------------------------------
const sg = skyGradient(cond); // sky stops become the REFLECTED sky
const skyTop = sg[0].color, skyMid = sg[1].color, skyHor = sg[2].color;
const WATER = ['#3f6a50', '#3a5a70', '#4e7a58', '#3c4a72', '#5e5680', '#44706a', '#3f6a5e'];
const TOD_WATER = {
dawn: ['#d89878', 0.30], morning: ['#c9d2c9', 0.06], noon: ['#2e9a68', 0.10],
goldenHour: ['#d89050', 0.32], dusk: ['#6a5488', 0.26], night: ['#182640', 0.55],
};
const WX_WATER = {
clear: [null, 0], mist: ['#dfe4e0', 0.14], overcast: ['#8a949c', 0.18],
rain: ['#76848e', 0.20], snow: ['#d8e2e4', 0.24],
};
const SEASON_WATER = { spring: [null, 0], summer: [null, 0], autumn: ['#8a6a40', 0.10], winter: ['#c8d4d4', 0.22] };
const gradeWater = (col) => {
let out = col;
const [tc, ta] = TOD_WATER[tod]; if (tc) out = mixHex(out, tc, ta);
const [wc, wa] = WX_WATER[weather]; if (wc) out = mixHex(out, wc, night ? wa * 0.4 : wa);
const [sc, sa] = SEASON_WATER[season]; if (sc) out = mixHex(out, sc, sa);
return out;
};
const breatheLit = gradeWater(mixHex(skyMid, '#f2eede', 0.40));
const breatheDeep = gradeWater('#16283a');
// the reflection-clouds, the sky as it exists on this canvas
const RCL = {
lit: { dawn: '#f2d8c0', morning: '#f4f0e0', noon: '#f8f6e6', goldenHour: '#f6d8a2', dusk: '#c8a888', night: '#4a5878' }[tod],
shade: { dawn: '#a89ab0', morning: '#b8c0c8', noon: '#c8ccc8', goldenHour: '#b88a70', dusk: '#685a80', night: '#2a3650' }[tod],
};
const gradeCloud = (col) => {
let out = col;
if (stormy) out = mixHex(out, '#7c868c', 0.30);
if (misty) out = mixHex(out, '#e2e6e2', 0.14);
if (night && !stormy && !misty) out = mixHex(out, '#26324c', 0.20);
return out;
};
// the sky between the masses: a deeper register of the reflection
const skyRef = gradeWater(mixHex(skyTop, skyMid, 0.45));
const PAD_SEASON = {
spring: ['#6a9a4e', '#7aa856', '#5c8c48', '#86b060'],
summer: ['#4e7e40', '#5e8c48', '#427038', '#6a9450'],
autumn: ['#8a7038', '#9a7c42', '#7a6232', '#5e6c38'],
winter: ['#5e7a6c', '#6c8878', '#50685e', '#7a9284'],
};
const gradePad = (col) => {
let out = col;
if (night) out = mixHex(out, '#131e2a', 0.55);
else if (dusk) out = mixHex(out, '#2a2c48', 0.30);
else if (golden) out = mixHex(out, '#c89048', 0.16);
if (autumn && !night) out = mixHex(out, '#8a5a28', 0.14);
return out;
};
const PAD_SHADOW = gradeWater('#3a4470');
const lightCol = night ? '#8a94b0' : golden ? '#f0d090' : noon ? '#f4f0d8' : dawn ? '#f2d8b8' : '#f0ecd4';
const rimSide = Math.cos(cond.lightAngle) >= 0 ? 1 : -1;
const BLOSSOM = night
? ['#d8d4d0', '#c8c4c8', '#d0c2c8']
: ['#f6f2ea', '#f2e6e0', '#e8b8c4', '#f0d8d8'];
const BLOSSOM_RED = '#c85868';
const BLOSSOM_HEART = '#e6c34a';
// -- THE PRIME: the pond as a ground, painted once (no horizon: the
// gradient only eases from the palest reflected sky into deep water)
{
const pale = gradeCloud(mixHex(skyMid, '#f4f0e2', 0.30));
const upMid = gradeWater(mixHex(skyHor, WATER[0], 0.45));
const mid = gradeWater(WATER[2]);
const deep = gradeWater(mixHex(WATER[3], '#182832', 0.42));
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, mixHex(pale, upMid, 0.20));
g.addColorStop(0.32, upMid);
g.addColorStop(0.62, mid);
g.addColorStop(1, deep);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// -- broad unifying veils so the prime never reads as a flat wash ------
for (let i = 0; i < 30; i++) {
const y = rng() * H;
const col = y < H * 0.34
? mixHex(skyRef, '#f4f0e4', 0.15)
: gradeWater(pick(WATER));
stamped(0.012 + 0.030 * (y / H) + rng() * 0.008, veil(rng() * W, y, W * (0.12 + rng() * 0.24),
(rng() - 0.5) * 0.08, Math.max(2, H * (0.02 + rng() * 0.05)), col, 0.05 + rng() * 0.06));
}
// -- THE REFLECTION-CLOUDS: the sky, floating on the surface ------------
// soft horizontal masses in the upper third; their wobbling bottom edges
// are where sky becomes water — there IS no other edge in this painting.
{
const nMass = 3 + Math.floor(rng() * 3);
const masses = [];
for (let i = 0; i < nMass; i++) {
masses.push({
cx: rng(), cy: 0.04 + rng() * 0.24,
rx: 0.14 + rng() * 0.26, ry: 0.05 + rng() * 0.10, wgt: 0.8 + rng() * 0.9,
});
}
const wobF = 2.5 + rng() * 3, wobP = rng() * TAU, wobA = H * 0.012;
const field = (xn, yn) => masses.reduce((s, m) => {
const dx = (xn - m.cx) / m.rx, dy = (yn - m.cy) / m.ry;
return s + m.wgt * Math.exp(-(dx * dx + dy * dy));
}, 0);
const nSky = Math.round(950 * covK);
for (let i = 0; i < nSky; i++) {
const xn = rng();
const ynn = Math.pow(rng(), 0.95) * 0.345; // spread through the upper third
const x = xn * W;
const y = ynn * H + Math.sin(xn * wobF * TAU + wobP) * wobA; // the wobbled edge
const v = field(xn, ynn);
let col, a;
if (v > 0.55) { // the cloud's body: lit tops, shaded bellies
col = gradeCloud(mixHex(RCL.lit, RCL.shade, 0.10 + rng() * 0.34));
if (v > 0.95) col = mixHex(col, '#fffdf2', 0.24); // the densest folds catch the light
a = 0.38 + rng() * 0.32;
} else if (v > 0.28) { // the torn edge where cloud dissolves into water
col = gradeCloud(mixHex(RCL.lit, skyRef, 0.40 + rng() * 0.30));
a = 0.28 + rng() * 0.26;
} else { // the reflected sky between the masses — deeper, cooler
col = mixHex(skyRef, breatheLit, 0.10 + rng() * 0.18);
a = 0.30 + rng() * 0.26;
}
const band = rng() < 0.14; // a few long horizontal gestures carry the drift
const len = W * (band ? 0.10 + rng() * 0.16 : 0.024 + rng() * 0.055);
const wd = Math.max(1.4, H * (0.007 + rng() * 0.012)) * (band ? 1.4 : 1);
const ang = curLean * 0.35 + (rng() - 0.5) * 0.10;
stamped(0.055 + 0.20 * (ynn / 0.345) + rng() * 0.012, smear(x, y, len, ang, wd, col, a, 0.42));
}
// the cloud bodies as a few big soft masses, so they read as REFLECTED
// sky and not as texture: each mass one pale veil pair
for (const m of masses) {
const mx = m.cx * W, my = m.cy * H;
stamped(0.062 + rng() * 0.006, veil(mx, my, W * m.rx * 1.7, 0, H * m.ry * 1.5,
gradeCloud(mixHex(RCL.lit, '#fdfbf0', 0.25)), 0.10 + m.wgt * 0.05));
stamped(0.063 + rng() * 0.006, veil(mx + W * m.rx * 0.2, my + H * m.ry * 0.5,
W * m.rx * 1.4, 0, H * m.ry * 1.1, gradeCloud(RCL.shade), 0.07));
}
}
// -- THE WATER'S GROUND: broken horizontal bands, breathing -------------
for (let i = 0, nWater = Math.round(2150 * covK); i < nWater; i++) {
const xn = rng(), yn = Math.pow(rng(), 0.92);
const x = xn * W, y = yn * H;
const v = breath(xn, yn); // the surface swells and sinks in value
let col = gradeWater(pick(WATER));
col = v > 0 ? mixHex(col, breatheLit, Math.min(0.50, 0.14 + v * 0.30))
: mixHex(col, breatheDeep, Math.min(0.50, 0.14 - v * 0.30));
if (yn < 0.22) col = mixHex(col, skyHor, 0.30 * (1 - yn / 0.22)); // melts into the reflected sky
if (yn > 0.80) col = mixHex(col, '#20323a', 0.10 + (yn - 0.80) * 0.45); // the near water deepens
if (yn > 0.55 && rng() < 0.14) col = mixHex(col, breatheDeep, 0.30); // a deep drift crossing
const len = W * (0.030 + rng() * 0.070) * (0.6 + yn * 0.6);
const wd = Math.max(1.2, H * (0.006 + rng() * 0.012));
const ang = (rng() - 0.5) * 0.22 + curLean * 0.15; // the bands lean with the current
stamped(0.275 + 0.295 * yn + rng() * 0.012, smear(x, y, len, ang, wd, col, 0.34 + rng() * 0.32, 0.45));
}
// -- dice-gated reflections: willow, glint path, iris blades ------------
if (rng() < 0.62) { // willow strands trailing from a corner, melting
const side = rng() < 0.5 ? -1 : 1;
const WILLOW = gradePad('#4a5e38');
const WILLOW2 = gradePad('#5a6e40');
const nStrand = 9 + Math.floor(rng() * 7);
for (let s = 0; s < nStrand; s++) {
const x0 = side < 0 ? W * (0.01 + rng() * 0.11) : W * (0.89 + rng() * 0.10);
const drop = H * (0.22 + rng() * 0.30);
const sway = (rng() - 0.5) * W * 0.05;
const steps = 7 + Math.floor(rng() * 5);
for (let k = 0; k < steps; k++) {
const t = k / (steps - 1);
const x = x0 + sway * t * t + (rng() - 0.5) * W * 0.012 * (0.4 + t);
const y = H * 0.015 + t * drop;
const col = rng() < 0.5 ? WILLOW : WILLOW2;
const fade = 1 - t; // the strand lets go of itself into the water
stamped(0.505 + rng() * 0.085, veil(x, y, W * (0.008 + rng() * 0.014),
Math.PI / 2 + (rng() - 0.5) * 0.2, Math.max(1.5, H * (0.006 + rng() * 0.010)),
col, 0.16 * fade + 0.02));
}
}
}
{ // the glint path: a shimmering column of pale dashes (sun by day, moon
// by night), bound by a soft vertical sheen so it reads as ONE path
const glintP = rng() < (night ? 0.80 : 0.55);
if (glintP) {
const gx = (night ? 0.25 + rng() * 0.5 : cond.sunX) * W;
const GLINT = night ? '#c8d4e0' : golden ? '#f8d898' : '#f6e8c4';
stamped(0.522, veil(gx, H * 0.52, H * 0.55, Math.PI / 2, H * 0.05,
mixHex(GLINT, breatheLit, 0.4), night ? 0.05 : 0.07));
for (let i = 0, nG = Math.round(90 * covK); i < nG; i++) {
const yn = 0.06 + Math.pow(rng(), 1.2) * 0.84;
const x = gx + (rng() - 0.5) * W * (0.02 + yn * 0.15);
const y = yn * H;
const len = W * (0.010 + rng() * 0.026) * (0.5 + yn);
const mid = Math.abs(yn - 0.55) < 0.28;
stamped(0.525 + rng() * 0.070, smear(x, y, len, (rng() - 0.5) * 0.16,
Math.max(1.0, H * 0.004), GLINT, (0.12 + rng() * 0.22) * (mid ? 1.25 : 1), 0.5));
}
}
}
if (rng() < 0.45) { // iris blades reflected at an edge
const side = rng() < 0.5 ? -1 : 1;
const IRIS = [gradePad('#3e5044'), gradePad('#46486a'), gradePad('#38504a')];
const nBlade = 11 + Math.floor(rng() * 7);
for (let i = 0; i < nBlade; i++) {
const x = side < 0 ? W * (rng() * 0.09) : W * (0.91 + rng() * 0.09);
const y = H * (0.30 + rng() * 0.62);
const len = H * (0.035 + rng() * 0.055);
stamped(0.560 + rng() * 0.060, smear(x, y, len, -Math.PI / 2 + (rng() - 0.5) * 0.22,
Math.max(1.2, H * 0.005), pick(IRIS), 0.20 + rng() * 0.22, 0.5));
}
}
// -- THE PADS: the stars, gathered along the current ---------------------
const pads = [];
{
const nPad = 14 + Math.floor(rng() * 17); // 14-30
const nCl = 3 + Math.floor(rng() * 4); // 3-6 clusters
const clusters = [];
for (let i = 0; i < nCl; i++) {
let cxn = rng(), cyn = curY(cxn);
for (let t = 0; t < 6 && curPull(cxn, cyn) < 0.45; t++) { // the current gathers its clusters
cxn = rng(); cyn = curY(cxn) + (rng() - 0.5) * curBand;
}
// some clusters drift into the near water, so the foreground never empties
if (rng() < 0.40) cyn += 0.22 + rng() * 0.20;
clusters.push({
cx: cxn, cy: clamp01(cyn),
spread: 0.05 + rng() * 0.09, share: 0.4 + rng() * 0.8,
});
}
for (let i = 0; i < nPad; i++) {
// pads follow the current: most ride its line, a few stray
let cxn, cyn;
if (rng() < 0.82 && clusters.length) {
const cl = clusters[Math.floor(rng() * clusters.length)];
cxn = cl.cx + (rng() + rng() - 1) * cl.spread * 1.6;
cyn = cl.cy + (rng() + rng() - 1) * cl.spread * 0.9;
} else { cxn = rng(); cyn = rng(); }
const yn = clamp01(cyn), xn = clamp01(cxn);
const x = xn * W + (rng() - 0.5) * W * 0.01, y = yn * H;
const depth = yn; // 0 far, 1 near
const sizeK = 0.5 + depth * 0.9;
const rx = Math.max(6, W * (0.026 + rng() * 0.028) * sizeK * (misty ? 1.12 : 1));
const ry = rx * (0.36 + rng() * 0.14);
const padAng = (rng() - 0.5) * 0.5 + curLean * 0.5; // the long axis rides the current
let col = mixHex(gradePad(pick(PAD_SEASON[season])), '#1f3a26', 0.12 + rng() * 0.10);
if (depth < 0.30) col = mixHex(col, skyRef, 0.28 * (1 - depth / 0.30)); // far pads haze out
pads.push({ x, y, yn, depth, rx, ry, padAng, col });
}
pads.sort((a, b) => a.yn - b.yn); // far to near, like the brush arriving
for (const pd of pads) {
const { x, y, depth, rx, ry, padAng, col } = pd;
const pBase = 0.615 + 0.235 * depth;
// the base mass: one soft ellipse so the pad reads as a SHAPE in
// every hand, then broken colour over it
stamped(pBase + rng() * 0.004, touch(x, y, Math.max(6, ry * 2.4), col,
(0.55 + depth * 0.25) * 0.9, padAng, clamp(ry / rx, 0.2, 1)));
// the violet-blue underside shadow, pushed away from the light
stamped(pBase + rng() * 0.010, touch(x - rimSide * rx * 0.30, y + ry * 0.60,
Math.max(3, ry * 1.6), PAD_SHADOW, 0.30 + depth * 0.18, padAng, 0.55));
// the body: a few angled dabs, broken colour, filling the ellipse
const nBody = 4 + Math.floor(rng() * 3);
for (let k = 0; k < nBody; k++) {
const bx = x + (rng() * 2 - 1) * rx * 0.50;
const by = y + (rng() * 2 - 1) * ry * 0.45;
const bcol = mixHex(col, rng() < 0.5 ? lightCol : PAD_SHADOW, rng() * 0.20);
stamped(pBase + 0.004 + rng() * 0.012, smear(bx, by, rx * (0.75 + rng() * 0.50),
padAng + (rng() - 0.5) * 0.30, Math.max(2, ry * 1.0), bcol,
(0.62 + rng() * 0.28) * (0.55 + depth * 0.45), 0.5));
}
// the upturned rim: a lighter edge on the light side
const rimCol = mixHex(col, lightCol, night ? 0.32 : 0.50);
stamped(pBase + 0.014 + rng() * 0.008, smear(x + rimSide * rx * 0.45, y - ry * 0.30,
rx * 0.65, padAng, Math.max(1.5, ry * 0.65), rimCol, (0.48 + depth * 0.22) * (night ? 0.7 : 1), 0.4));
// the notch: the classic slit, present but quiet — kept well inside
// the ellipse (the hand's lengthening must not push it past the rim)
stamped(pBase + 0.018 + rng() * 0.006, smear(x, y, rx * 0.50,
padAng + Math.PI / 2 + (rng() - 0.5) * 0.4, Math.max(1.0, ry * 0.22),
mixHex(col, '#141c14', 0.32), 0.15, 0.4));
}
// a HERO pad or two ride the real bleed under the mouille hand only
if (isWet && pads.length) {
const heroes = pads.slice().sort((a, b) => b.rx - a.rx).slice(0, 6);
for (const pd of heroes) {
const fn = heroPad(pd.x, pd.y, pd.rx * 1.05, pd.ry * 1.05, gradePad(pd.col), 0.5, pd.padAng);
if (fn) stamped(0.869 + rng() * 0.006, fn);
}
}
}
// -- THE BLOSSOMS: rare jewels, with trembling reflections --------------
{
const nBlos = Math.min(pads.length, 2 + Math.floor(rng() * 6)); // 2-7
const bCol = pick(BLOSSOM);
for (let i = 0; i < nBlos; i++) {
const pd = pads[Math.floor(rng() * pads.length)];
if (!pd) break;
const bx = pd.x + (rng() - 0.5) * pd.rx * 0.5;
const by = pd.y - pd.ry * 0.15;
const bR = Math.max(2.2, H * 0.0105 * (0.6 + pd.depth * 0.6));
const petal = rng() < 0.14 ? BLOSSOM_RED : bCol;
const p0 = 0.885 + rng() * 0.020;
// the flower: layered petals (each its own broken neighbour) around
// a small yellow heart — no fried-egg discs
stamped(p0, touch(bx, by, bR * 2.4, petal, night ? 0.5 : 0.68, 0, 0.8));
for (let k = 0; k < 4; k++) {
const a = rng() * TAU;
const pcol = k === 0 ? petal : pick(night ? BLOSSOM : BLOSSOM.concat(petal));
stamped(p0 + 0.002 + rng() * 0.004, touch(bx + Math.cos(a) * bR * 0.6,
by + Math.sin(a) * bR * 0.45, bR * 1.5, pcol, night ? 0.55 : 0.82));
}
stamped(p0 + 0.006, touch(bx, by, bR * 0.55, BLOSSOM_HEART, night ? 0.6 : 0.9));
// the trembling inverted smudge beneath
const drop = pd.ry * 2.2 + H * 0.012 * (0.5 + pd.depth);
for (let k = 0; k < 3; k++) {
const t = k / 2;
stamped(p0 + 0.008 + k * 0.002, smear(bx + (rng() - 0.5) * bR * (1 + t * 2.2), by + drop * (0.4 + t * 0.6),
bR * (1.1 - t * 0.3), (rng() - 0.5) * 0.2, Math.max(0.9, bR * 0.35),
mixHex(petal, breatheDeep, 0.35), (0.22 - t * 0.07) * (night ? 0.6 : 1), 0.5));
}
}
}
// -- weather touches the pond in its own way ----------------------------
if (rainy) { // stipple rings breaking the reflections
const nR = 90;
for (let i = 0; i < nR; i++) {
const x = rng() * W, y = rng() * H;
const rx = 2 + rng() * 8, ry2 = rx * (0.28 + rng() * 0.16);
const al = 0.08 + rng() * 0.14, lw = Math.max(0.6, rx * 0.10);
stamped(0.915 + rng() * 0.030, (c) => {
c.save();
c.globalAlpha = al;
c.strokeStyle = '#dce4e6';
c.lineWidth = lw;
c.beginPath();
c.ellipse(x, y, rx, ry2, 0, 0, TAU);
c.stroke();
c.restore();
});
}
}
if (misty || dawn) { // mist breathing over the water, edges dissolve
const nM = misty ? 44 : 26;
for (let i = 0; i < nM; i++) {
const y = H * Math.pow(rng(), 1.4) * 0.7;
stamped(0.918 + rng() * 0.024, veil(rng() * W, y, W * (0.08 + rng() * 0.20),
(rng() - 0.5) * 0.06, H * (0.012 + rng() * 0.030),
mixHex(misty ? '#e2e6e2' : '#ece0d4', '#f0ece0', 0.3),
(night ? 0.03 : 0.05) + rng() * (night ? 0.05 : 0.08)));
}
}
if (autumn) { // fallen leaves afloat, each with its shadow twin
const nLeaf = 8 + Math.floor(rng() * 8);
for (let i = 0; i < nLeaf; i++) {
const x = rng() * W, y = rng() * H;
const s = Math.max(1.4, H * 0.005 * (0.6 + y / H));
const lc = pick(['#a8682e', '#8a5224', '#b87a3a']);
stamped(0.922 + rng() * 0.020, touch(x, y, s, lc, 0.65, 0, 0.7));
stamped(0.924 + rng() * 0.020, smear(x, y + s * 1.6, s * 2.2, (rng() - 0.5) * 0.2,
Math.max(0.8, s * 0.35), mixHex(lc, breatheDeep, 0.55), 0.18, 0.5));
}
}
if (night) { // a firefly or two over the dark water
const nFly = 2 + Math.floor(rng() * 3);
for (let i = 0; i < nFly; i++) {
const pd = pads.length && rng() < 0.8 ? pads[Math.floor(rng() * pads.length)] : null;
const x = pd ? pd.x + (rng() - 0.5) * W * 0.06 : rng() * W;
const y = pd ? pd.y - H * (0.01 + rng() * 0.03) : rng() * H;
const fc = rng() < 0.5 ? '#e8d878' : '#b8d86a';
stamped(0.930 + rng() * 0.014, (c) => {
dab(c, x, y, Math.max(2.5, H * 0.006), fc, 0.22);
dab(c, x, y, Math.max(1.0, H * 0.0022), '#f6ecc0', 0.85);
});
}
}
// -- the last notes: light catching the pad rims ------------------------
for (let i = 0, nGlint = Math.round(100 * covK); i < nGlint; i++) {
const pd = pads[Math.floor(rng() * pads.length)];
if (!pd) break;
const gx = pd.x + rimSide * pd.rx * (0.3 + rng() * 0.5);
const gy = pd.y + (rng() - 0.5) * pd.ry;
const col = mixHex(pd.col, lightCol, 0.55);
stamped(0.950 + rng() * 0.040, smear(gx, gy, pd.rx * (0.2 + rng() * 0.35),
pd.padAng + (rng() - 0.5) * 0.3, Math.max(0.8, pd.ry * 0.35), col,
0.12 + rng() * 0.20, 0.5));
}
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);
}
},
};
// Paris sous la pluie — a homage to Caillebotte's "Paris Street; Rainy Day"
// (1877), impressionized. The soul of the piece is GREY — but grey as broken
// colour, never flat: every grey is a mix of the scene's ambient hues
// (blue-grey, mauve-grey, green-grey dabs laid side by side), and the seasons
// tilt the grey's temperature (winter blue, autumn mauve, summer green). The
// tonal spine, daylight: pale sky > wet pavement reflections > buildings >
// umbrellas and figures — the exact inverse of a night scene, and the reason
// the picture reads Caillebotte and not nocturne.
//
// Perspective is the skeleton: one Horizon with a deep fov, the boulevard
// receding to a dice-gated vanishing point, receding façades in hazed rows on
// both flanks (the house's skyline() department returns the masses as DATA),
// a far parapet knitting the horizon, and ONE lamppost — dice-gated left,
// right or dead centre — dividing the composition like the gas lamp of the
// original. The umbrellas are the signature: 8-25 grey blooms at rational
// depths (computeGroundPlane for scale), each a soft ellipse of 2-3 broken
// greys with a darker rim and a wet sheen, a hinted figure below, and an
// inverted smudge of a reflection in the streaming street.
//
// SKELETON AND HAND: the composition's load-bearing strokes — the sky's light,
// the pavement's plane, the façade masses, the umbrella blooms — are laid with
// the house stroke directly (skyline.js precedent: masses plain, accents
// through the hand), because a pointillisme or vernis wrist would starve the
// tonal spine into dust. The painter's HAND (conditions.technique) holds every
// expressive touch: cloud banks, façade and roof accents, pavement shimmer,
// canopy texture, mist bands, glints.
//
// CONDITIONS VARIATION — yes, even a rain scene varies. The scene's home
// weather IS wet: rain, mist, overcast are weighted heavily; clear and snow
// are re-rolled toward the wet states (Caillebotte's Paris rains in any
// season, even the winter the season table denies it — the override is
// documented right here, nuit-style). A clear survivor is welcome once in a
// while: it paints the clearing AFTER the rain, a pale gold band low in the
// sky and warm reflections on the still-wet street. weather 'rain' splits
// (init dice) into drizzle (light streaks, fewer umbrellas) and downpour
// (heavy streaks, umbrella density up); mist ghosts the façades; night
// doubles the lamp reflections; dawn/dusk tint the sky band warm.
//
// Contract: identical to scenes/skyline.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: EVERY random value a closure uses is
// computed here in init.
import {
mulberry32, makeConditions, weatherOverlay,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, Horizon, skyline, aerialFade, brokenColor,
dab, roundDab, knife, rigger, stroke,
} from '../brushes.js';
let strokes = null;
// The rain paints any hour, but its home weather is wet: rain, mist,
// overcast are weighted heavily; clear and snow are re-rolled toward the wet
// states (it rains in Paris in any season, even the winter the season table
// denies). The dedicated conditions stream is consumed identically every
// pass, so the override stays deterministic. Ten tries; a stubborn survivor
// paints as-is — a user-locked weather is always honoured.
const WET = ['rain', 'mist', 'overcast'];
export default {
id: 'pluie',
title: 'Paris sous la pluie',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => {
let c = makeConditions(rng, constraints);
const forced = !!(constraints && constraints.weather);
for (let i = 0; i < 10; i++) {
if (WET.includes(c.weather)) return c; // rain, mist, overcast: home
if (c.weather === 'clear' && rng() < 0.15) return c; // the rare clearing
c = makeConditions(rng, constraints); // clear/snow re-roll toward wet
}
if (WET.includes(c.weather) || forced || c.weather === 'clear') return c;
// a winter snow survivor is marched indoors: it rains in Paris, any season
return makeConditions(rng, { ...(constraints || {}), weather: 'rain' });
},
subtitle: (c) => 'Paris sous la pluie, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1877));
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk';
const dawn = tod === 'dawn', golden = tod === 'goldenHour';
const winter = season === 'winter';
const mirror = cond.motifSide === 'right';
const MX = (xn) => (mirror ? 1 - xn : xn);
// -- the weather's inner dice: drizzle vs downpour, wetness of the street
const drizzle = weather === 'rain' && rng() < 0.38;
const downpour = weather === 'rain' && !drizzle;
const mistMode = weather === 'mist';
const clearing = weather === 'clear'; // the clearing after the rain
const wetK = downpour ? 1.0 : drizzle ? 0.45 : mistMode ? 0.60
: weather === 'overcast' ? 0.55 : weather === 'snow' ? 0.30 : 0.90;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
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 so the rain stays airy.
const stamped = (p, fn) => {
const jr = mulberry32((0x91141 + Math.floor(rng() * 0xffffff) * 607) >>> 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; the scene keeps painting */ }
}
});
};
// -- THE HAND: the rolled technique decides how every accent sits ------
const hand = handFor(cond.technique);
const touch = (c, jr, x, y, w, col, a, ang = 0, squash = 1) => handDab(c, jr, hand, {
x, y, len: 0, ang, w, c: col, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: col, alpha: a, angle: ang, squash }),
});
const smear = (c, jr, x, y, len, ang, w, col, a, squash = 1) => handDab(c, jr, hand, {
x, y, len, ang, w, c: col, a, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, col, a),
});
const slab = (c, jr, x, y, len, ang, w, col, a) => handDab(c, jr, hand, {
x, y, len, ang, w, c: col, a, squash: 0.4,
plain: (g) => knife(g, jr, { x, y, size: Math.max(1, len * 0.5), color: col, alpha: a, angle: ang, squash: 0.35 }),
});
// the skeleton stroke, hand-independent: the composition must survive
// every wrist (skyline.js precedent — masses plain, accents through the hand)
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.35,
});
// -- the grey engine: the scene's soul ---------------------------------
// Every grey is a neutral at lightness l tinted toward one of the season's
// ambient hues; brokenColor() splits each into 2-3 neighbours so adjacent
// dabs shimmer instead of flattening. The hour pulls the whole spine down.
const AMBIENT = {
spring: ['#8aa4a8', '#93a2b4', '#9db0a6'],
summer: ['#94a48e', '#9aa694', '#8ea4a2'],
autumn: ['#9a8a9e', '#a094a4', '#8e98a0'],
winter: ['#8ea2c2', '#96a2bc', '#a2aec4'],
}[season] || ['#93a2b4', '#9aa0a6', '#8ea4a2'];
const DARK = '#23272f', PALE = '#e2e5e7';
const LV = (l) => night ? l * 0.42 : (dawn || dusk) ? l * 0.80 : l;
const grey = (l, k = 0) =>
mixHex(mixHex(DARK, PALE, clamp01(LV(l))), AMBIENT[((k % AMBIENT.length) + AMBIENT.length) % AMBIENT.length], 0.16);
// a warm band low in the sky: the clearing's pale gold, dawn/dusk tints
const warmBand = clearing ? '#e3c894' : golden ? '#e6bd84'
: dawn ? '#d8b48c' : dusk ? '#c08a6e' : null;
const warmK = clearing ? 0.55 : golden ? 0.45 : 0.30;
const skyAt = (band) => { // band 0 top .. 1 at the horizon
let c = grey(0.90 - band * 0.20, Math.floor(band * 3));
c = mixHex(c, AMBIENT[0], 0.10);
if (warmBand && band > 0.55) c = mixHex(c, warmBand, ((band - 0.55) / 0.45) * warmK);
if (mistMode) c = mixHex(c, '#dfe3e1', 0.12);
return c;
};
const skyLow = skyAt(1);
// the wet street mirrors the sky: always lighter than the façades —
// the spine holds. sheenAt is resolved at the call site in init.
const paveAt = (scale) => mixHex(grey(0.68, 1), skyLow, (1 - scale) * 0.50);
const sheenAt = () => clearing ? mixHex(skyLow, warmBand, 0.22)
: mixHex(skyLow, grey(0.85, 2), 0.35 + rng() * 0.3);
const umbBase = () => grey(0.15 + rng() * 0.08, Math.floor(rng() * 3));
// -- geometry: the one-point boulevard ----------------------------------
const vx = MX(0.34 + rng() * 0.32); // vanishing x, 0.34-0.68
const hzY = H * Math.min(0.62, Math.max(0.40, cond.horizonY));
const horizon = new Horizon(hzY, vx * W, 1.7, H); // fov 1.7: deep, wide-angle
const gp = (xn, d) => horizon.computeGroundPlane(xn * W, d, H);
// -- prime: the wet air itself ------------------------------------------
{
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, skyAt(0));
g.addColorStop(0.45, skyAt(0.5));
g.addColorStop(clamp01(hzY / H), skyLow);
g.addColorStop(clamp01(hzY / H + 0.03), mixHex(skyLow, grey(0.60, 1), 0.45));
g.addColorStop(1, grey(0.62, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// ---- sky: broad horizontal grey courses, broken colour ----------------
for (let i = 0; i < 620; i++) {
const x = -W * 0.03 + rng() * W * 1.06;
const bandN = Math.pow(rng(), 0.9);
const y = H * 0.01 + bandN * Math.max(4, hzY - H * 0.02);
const cols = brokenColor(null, rng, skyAt(bandN), 0.5);
const col = cols[Math.floor(rng() * cols.length)];
const ang = (rng() - 0.5) * 0.09;
const len = W * (0.06 + rng() * 0.16) * (0.7 + bandN * 0.5);
const wd = H * (0.008 + rng() * 0.018) * (0.75 + bandN * 0.6);
const a = 0.10 + rng() * 0.18;
stamped(0.010 + 0.095 * bandN + rng() * 0.012, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// the hand over the sky: the technique shivers the grey courses — kept
// FAINT: an impasto wrist must lift a whisper, not slab the sky
for (let i = 0; i < 140; i++) {
const x = -W * 0.03 + rng() * W * 1.06;
const bandN = Math.pow(rng(), 0.9);
const y = H * 0.01 + bandN * Math.max(4, hzY - H * 0.02);
const cols = brokenColor(null, rng, skyAt(bandN), 0.6);
const col = cols[Math.floor(rng() * cols.length)];
const wd = H * (0.006 + rng() * 0.010);
const a = 0.04 + rng() * 0.08;
const ang = (rng() - 0.5) * 0.10;
const len = W * (0.02 + rng() * 0.06);
stamped(0.110 + 0.018 * bandN + rng() * 0.008, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.6);
});
}
// the sky's weather: the clearing's gold band, or overcast cloud banks
if (warmBand) {
for (let i = 0; i < 90; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY - Math.pow(rng(), 1.6) * H * 0.13;
const cols = brokenColor(null, rng, mixHex(warmBand, skyLow, 0.45), 0.55);
const col = cols[Math.floor(rng() * cols.length)];
const len = W * (0.04 + rng() * 0.11);
const ang = (rng() - 0.5) * 0.07;
const wd = H * (0.006 + rng() * 0.014);
const a = 0.09 + rng() * 0.14;
stamped(0.132 + rng() * 0.020, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
if (cond.sunVisible && !night) { // a pale disc through the thinning rain
const sx = cond.sunX * W, sy = Math.max(H * 0.05, Math.min(cond.sunY * H, hzY - H * 0.03));
for (let i = 0; i < 30; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.8) * H * 0.016;
const x = sx + Math.cos(a) * r, y = sy + Math.sin(a) * r;
const size = H * (0.005 + rng() * 0.008);
const col = i < 16 ? mixHex('#f6e6c2', warmBand, 0.3) : skyAt(0.8);
const al = 0.10 + rng() * 0.16;
stamped(0.150 + rng() * 0.008, (c) => dab(c, x, y, size, col, al));
}
}
} else { // cloud banks: flat watery grey masses drifting across
const nClouds = 2 + Math.floor(rng() * 3);
for (let ci = 0; ci < nClouds; ci++) {
const cx = 0.12 + rng() * 0.76, cy = 0.05 + rng() * 0.5;
const sx = 0.10 + rng() * 0.13, sy = 0.016 + rng() * 0.022;
const n = 10 + Math.floor(rng() * 14);
for (let i = 0; i < n; i++) {
const x = (cx + gauss() * sx) * W;
const y = (cy + gauss() * sy) * H;
const col = skyAt(0.2 + rng() * 0.5);
const isKnife = rng() < 0.5;
const len = W * (0.03 + rng() * 0.07);
const ang = (rng() - 0.5) * 0.08;
const wd = H * (0.010 + rng() * 0.018);
const a = 0.05 + rng() * 0.08;
stamped(0.132 + rng() * 0.020, (c, jr) => {
if (isKnife) {
slab(c, jr, x, y, len, ang, wd, col, a);
} else {
touch(c, jr, x, y, wd, col, a, ang, 0.35);
}
});
}
}
}
// ---- the wet pavement: the largest canvas area ------------------------
// horizontal broken courses mirroring the sky above them; far street
// hazes into the horizon, near street carries the sheen
for (let i = 0; i < 900; i++) {
const d = 1 - Math.pow(rng(), 1.5) * 0.96; // biased toward the near plane
const g = gp(-0.04 + rng() * 1.08, d);
const sc = g.scale;
const isSheen = rng() < 0.24 + wetK * 0.20;
let col = isSheen ? sheenAt() : paveAt(sc);
if (winter || weather === 'snow') col = mixHex(col, '#dfe6ee', 0.10 + rng() * 0.15);
if (clearing && isSheen) col = mixHex(col, warmBand, 0.10 + rng() * 0.14);
col = mixHex(col, skyLow, (1 - sc) * 0.30); // the far street dissolves up
const len = W * (0.025 + rng() * 0.065) * (0.35 + sc * 1.2);
const wd = Math.max(1.2, H * (0.006 + rng() * 0.012) * (0.35 + sc * 1.4));
const ang = (rng() - 0.5) * 0.09;
const a = (isSheen ? 0.20 + rng() * 0.20 : 0.30 + rng() * 0.28) * (0.55 + sc * 0.6);
stamped(0.205 + 0.27 * sc + rng() * 0.008, (c, jr) => {
band(c, jr, g.x, g.y, len, ang, wd, col, a);
});
}
// the hand over the street: the technique's shimmer on the wet stones
for (let i = 0; i < 110; i++) {
const d = 1 - Math.pow(rng(), 1.4) * 0.95;
const g = gp(-0.04 + rng() * 1.08, d);
const sc = g.scale;
const isSheen = rng() < 0.42;
const col = isSheen ? sheenAt() : paveAt(sc);
const len = W * (0.015 + rng() * 0.05) * (0.4 + sc);
const wd = Math.max(1.4, H * 0.006 * (0.4 + sc * 1.3));
const ang = (rng() - 0.5) * 0.12;
const a = 0.20 + rng() * 0.22; // faint: the hand shimmers, not slabs
stamped(0.480 + 0.018 * sc + rng() * 0.006, (c, jr) => {
smear(c, jr, g.x, g.y, len, ang, wd, col, a, 0.55);
});
}
// the cobbles' darker courses: the street is not one flat sheen — subtle
// horizontal value steps give the plane its depth
for (let i = 0; i < 220; i++) {
const d = 1 - Math.pow(rng(), 1.4) * 0.96;
const g = gp(-0.04 + rng() * 1.08, d);
const sc = g.scale;
const col = mixHex(grey(0.55 - sc * 0.06, Math.floor(rng() * 3)), skyLow, (1 - sc) * 0.35);
const len = W * (0.02 + rng() * 0.06) * (0.35 + sc * 1.2);
const wd = Math.max(1.2, H * (0.005 + rng() * 0.010) * (0.35 + sc * 1.4));
const ang = (rng() - 0.5) * 0.08;
const a = 0.16 + rng() * 0.20;
stamped(0.215 + 0.25 * sc + rng() * 0.008, (c, jr) => {
band(c, jr, g.x, g.y, len, ang, wd, col, a);
});
}
// the street drinks the lamplight toward the vanishing point at low hours
// — spread wide and faint, or it pools into a hot blob
if (night || dusk || golden) {
const glowCol = night ? '#e8c46a' : mixHex('#f2c47e', skyLow, 0.35);
for (let i = 0; i < 60; i++) {
const x = vx * W + gauss() * W * 0.13;
const y = hzY + Math.abs(gauss()) * H * 0.10;
const size = H * (0.014 + rng() * 0.026);
const al = 0.025 + rng() * 0.040;
stamped(0.502 + rng() * 0.010, (c) => {
dab(c, x, y, size, mixHex(glowCol, skyLow, 0.30), al);
});
}
}
// ---- the far parapet: the endless city knitting the horizon -----------
for (let i = 0; i < 150; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY + H * (0.004 + rng() * 0.028);
const h2 = H * (0.008 + rng() * 0.020);
const wd = Math.max(1.4, W * (0.012 + rng() * 0.034) * 0.35);
const col = mixHex(grey(0.48, Math.floor(rng() * 3)), skyAt(0.85), 0.45);
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.2;
const a = 0.26 + rng() * 0.22;
stamped(0.505 + rng() * 0.016, (c, jr) => {
band(c, jr, x, y, h2, ang, wd, col, a);
});
}
// ---- the façades: receding rows on both flanks ------------------------
const city = skyline(null, rng, {
horizon, frameW: W, frameH: H,
side: 'both',
density: 0.32 + rng() * 0.10, // the boulevard's centre must stay open
heightVariance: 0.28 + rng() * 0.30,
style: 'parisian',
night: true, // always roll the window grid; the glass is ours
maxDepth: 0.88 + rng() * 0.10,
});
const maxDepth = 0.95;
const rowOf = (depth) => {
const f = clamp01((depth - 0.04) / Math.max(0.05, maxDepth));
return f < 0.34 ? 0 : f < 0.7 ? 1 : 2;
};
const nearnessOf = (depth) => clamp01((depth - 0.04) / Math.max(0.05, maxDepth));
const rowBuildings = [[], [], []];
for (const b of city.buildings) rowBuildings[rowOf(b.depth)].push(b);
const rowWins = [[], [], []];
for (const w of city.windows) rowWins[rowOf(w.depth)].push(w);
const ROW_P = [
{ mass: [0.520, 0.560], roof: [0.560, 0.572], win: [0.572, 0.600] },
{ mass: [0.600, 0.650], roof: [0.650, 0.662], win: [0.662, 0.690] },
{ mass: [0.690, 0.745], roof: [0.745, 0.757], win: [0.757, 0.785] },
];
const nearLit = []; // lit near-row windows, for their wet reflections
for (let ri = 0; ri < 3; ri++) {
const [m0, m1] = ROW_P[ri].mass;
const [r0, r1] = ROW_P[ri].roof;
const [w0, w1] = ROW_P[ri].win;
for (const b of rowBuildings[ri]) {
const nearness = nearnessOf(b.depth);
const fade = aerialFade(0.15 + nearness * 0.85);
fade.alpha = Math.max(0.62, fade.alpha); // distant blocks stay present, only paler
const hazeC = skyAt(0.85 - nearness * 0.25);
const hazeMix = Math.min(0.80, (1 - nearness)
* (0.42 + (mistMode ? 0.32 : 0) + (downpour ? 0.10 : 0)) + 0.06);
const base = mixHex(grey(0.45 - nearness * 0.09, Math.floor(rng() * 3)), hazeC, hazeMix);
const cols = brokenColor(null, rng, base, 0.45);
// block in the mass first: full-height columns
const nBlock = Math.max(3, Math.min(12, Math.round(b.w / Math.max(2, W * 0.012))));
for (let i = 0; i < nBlock; i++) {
const u = (i + 0.5) / nBlock;
const x = b.x + b.w * u + (rng() - 0.5) * b.w * 0.06;
const col = mixHex(base, cols[i % cols.length], 0.35);
const wd = Math.max(2, (b.w / nBlock) * (1.5 + rng() * 0.6));
const len = b.h * (1.02 + rng() * 0.05);
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.05;
const a = Math.min(1, (0.80 + rng() * 0.15) * fade.alpha);
stamped(m0 + (m1 - m0) * (i / Math.max(1, nBlock)) + rng() * 0.006, (c, jr) => {
band(c, jr, x, b.yBase, len, ang, wd, col, a);
});
}
// broken-colour hatching over the plane
const n = Math.max(6, Math.min(70, Math.round((b.w * b.h) / (W * H) * 3400)));
for (let i = 0; i < n; i++) {
const x = b.x + rng() * b.w;
const y = b.yBase - rng() * b.h;
const floorB = rng() < 0.22 && nearness > 0.25; // a floor course reads horizontal
const col = cols[Math.floor(rng() * cols.length)];
const len = floorB ? b.w * (0.2 + rng() * 0.5) : b.h * (0.12 + rng() * 0.30);
const wd = Math.max(2.0, Math.min(b.h * 0.18, b.w * 0.18,
H * 0.011 * (0.5 + nearness * 1.2)) * (0.8 + rng() * 0.6));
const ang = floorB ? (rng() - 0.5) * 0.12 : -Math.PI / 2 + (rng() - 0.5) * 0.24;
const a = (0.58 + rng() * 0.25) * fade.alpha * (floorB ? 0.6 : 1);
stamped(m0 + (m1 - m0) * (0.4 + 0.56 * (i / Math.max(1, n))) + rng() * 0.005, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// the hand's accents: the technique leaves its trace on the stone
const nHand = Math.max(3, Math.min(12, Math.round(n * 0.18)));
for (let i = 0; i < nHand; i++) {
const x = b.x + rng() * b.w;
const y = b.yBase - rng() * b.h;
const col = cols[Math.floor(rng() * cols.length)];
const wd = Math.max(1.4, Math.min(b.h, b.w, H * 0.010 * (0.5 + nearness * 1.2)) * 0.5);
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.5;
const a = 0.45 + rng() * 0.30;
stamped(m0 + (m1 - m0) * (0.5 + 0.45 * rng()), (c, jr) => {
touch(c, jr, x, y, wd, col, a, ang, 0.8);
});
}
// the top edge drowns in the wet sky
for (let i = 0; i < 4; i++) {
const x = b.x + rng() * b.w;
const y = b.yBase - b.h + (rng() - 0.5) * 2;
const size = Math.max(1.6, b.w * 0.15);
const a = 0.08 + rng() * 0.10;
stamped(m0 + (m1 - m0) * 0.92 + rng() * 0.01, (c) => {
dab(c, x, y, size, hazeC, a);
});
}
// -- roof: the mansard cap, a darker band lying along the ridge ---
const yTop = b.yBase - b.h;
const roofBase = mixHex(grey(0.32, Math.floor(rng() * 3)), hazeC, hazeMix * 0.8);
const nR = Math.max(3, Math.round(b.w / Math.max(2, W * 0.009)) + 2);
for (let i = 0; i < nR; i++) {
const u = i / Math.max(1, nR - 1);
const x = b.x + b.w * (0.02 + u * 0.96);
const hip = -Math.abs(u - 0.5) * 2 * b.h * 0.05 + b.h * 0.025;
const wd = Math.max(1.2, b.h * 0.07);
const ang = (rng() - 0.5) * 0.16; // along the ridge, not across it
const a = (0.50 + rng() * 0.22) * fade.alpha;
stamped(r0 + (r1 - r0) * (0.2 + 0.7 * u) + rng() * 0.004, (c, jr) => {
band(c, jr, x, yTop + hip, b.w * (0.16 + rng() * 0.22), ang, wd, roofBase, a);
});
}
for (const ch of b.chimneys) {
const chX = ch.x, chH = ch.h, chW = Math.max(1.0, ch.w);
const chAng = -Math.PI / 2 + (rng() - 0.5) * 0.08;
const chCol = mixHex(roofBase, '#2a2630', 0.3);
stamped(r0 + (r1 - r0) * 0.55, (c, jr) => {
rigger(c, jr, { x: chX, y: yTop + 1, length: chH, color: chCol,
alpha: 0.55 * fade.alpha, angle: chAng, width: chW });
});
}
// -- windows: dark glass, a few kindled at night -------------------
const litFrac = night ? 0.34 : dusk ? 0.18 : 0.02;
for (const w of rowWins[ri]) {
if (w.x < b.x || w.x > b.x + b.w) continue;
const lit = rng() < litFrac;
const col = lit ? (night ? '#e2b96a' : mixHex('#e2b96a', skyLow, 0.40))
: grey(0.20 + rng() * 0.07, 1);
const a = lit ? (night ? 0.55 + rng() * 0.25 : 0.40 + rng() * 0.15)
: (0.38 + rng() * 0.22) * fade.alpha;
const size = Math.max(1.1, w.w * 0.55);
stamped(w0 + (w1 - w0) * rng(), (c) => {
dab(c, w.x, w.y, size, col, a, 0, 1.4);
});
if (lit && ri === 2) nearLit.push({ x: w.x, yBase: b.yBase });
}
}
}
// ---- mist bands: the façades go to ghosts ------------------------------
// atmosphere, not accent: laid with the plain stroke — an impasto wrist
// would slab the fog into opaque pills
if (mistMode || downpour || dawn) {
const nBands = mistMode ? 90 : downpour ? 34 : 24;
const mistCol = mixHex('#e2e6e4', skyLow, night ? 0.55 : (dusk || dawn) ? 0.35 : 0.15);
for (let i = 0; i < nBands; i++) {
const hugHorizon = rng() < 0.6;
const y = hugHorizon ? hzY - H * (0.01 + rng() * 0.14) : hzY + (rng() - 0.4) * H * 0.10;
const x = rng() * W;
const len = W * (0.12 + rng() * 0.30);
const ang = (rng() - 0.5) * 0.05;
const wd = H * (0.014 + rng() * 0.035);
const a = 0.04 + rng() * 0.07;
stamped(0.79 + rng() * 0.04, (c, jr) => {
band(c, jr, x, y, len, ang, wd, mistCol, a);
});
}
}
// ---- the lamppost: the composition's divider ---------------------------
// dice-gated position — left, right, or dead centre, like Caillebotte's
// gas lamp holding its third. One receding twin marches toward the
// vanishing point on the same line.
const lampsOn = night || dusk || dawn || downpour || mistMode;
const lampSide = wpick([['left', 4], ['right', 4], ['center', 2]]);
const lampXn = lampSide === 'left' ? MX(0.22 + rng() * 0.07)
: lampSide === 'right' ? MX(0.71 + rng() * 0.07)
: 0.5 + (rng() - 0.5) * 0.04;
const lampD = 0.64 + rng() * 0.16;
// a trembling reflection: broken vertical dashes stepping downward; the
// night lays twice as many — the lamps double themselves in the street
const tremble = (p, x, y0, len, col, a, wk, nightK) => {
const n = (3 + Math.floor(rng() * 3)) * (nightK ? 2 : 1);
const segs = [];
for (let k = 0; k < n; k++) {
segs.push({
dx: (rng() - 0.5) * (2 + wk * 6),
dx2: (rng() - 0.5) * 2.5,
w: 0.9 + rng() * 1.5,
frac: 0.65 + rng() * 0.5,
aK: 1 - k / (n + 1),
});
}
stamped(p, (c, jr) => {
const step = len / n;
for (let k = 0; k < n; k++) {
const s = segs[k];
stroke(c, jr, {
path: { x0: x + s.dx, y0: y0 + k * step, x1: x + s.dx + s.dx2, y1: y0 + (k + 1) * step * s.frac },
brush: 'round', width: s.w, color: col,
alpha: a * s.aK * (0.45 + wk * 0.7), taper: 'both', spacing: 0.5,
});
}
});
};
const lampLine = (xn, d, p) => {
const g = gp(xn, d);
const sc = g.scale;
const poleH = H * 0.30 * (0.38 + sc * 1.15);
const poleW = Math.max(0.9, W * 0.0016 * (0.4 + sc));
const bow = (rng() - 0.5) * poleH * 0.04;
const poleAng = -Math.PI / 2 + (rng() - 0.5) * 0.04;
const poleCol = mixHex('#34343c', skyLow, 0.15);
stamped(p, (c, jr) => {
rigger(c, jr, { x: g.x, y: g.y, length: poleH, color: poleCol,
alpha: 0.72, angle: poleAng, width: poleW, bow });
});
if (lampsOn) {
const lampY = g.y - poleH;
const gs = Math.max(2.2, poleW * 4) * (night ? 1.6 : 1) * (mistMode ? 1.4 : 1);
const ga = (night ? 0.20 : 0.13) + rng() * 0.07;
const ca = 0.6 + rng() * 0.3;
stamped(p + 0.004, (c) => {
dab(c, g.x, lampY, gs, '#f5d98a', ga);
dab(c, g.x, lampY, Math.max(1.2, poleW * 1.7), '#f8e6ab', ca);
});
tremble(p + 0.008, g.x, g.y + 1, poleH * (1.1 + wetK * 1.5), '#e8c46a',
night ? 0.34 : 0.24, wetK, night);
}
return g;
};
lampLine(lampXn, lampD, 0.835);
lampLine(lampXn + (vx - lampXn) * 0.45, 0.32, 0.83);
// ---- UMBRELLAS: the signature ------------------------------------------
// 8-25 grey blooms at rational depths, near ones painting last; each a
// soft ellipse of 2-3 broken greys with a darker rim and a wet sheen, a
// hinted figure below, and an inverted smudge of a reflection. Each
// umbrella owns a sliver of the [0.855, 0.897) band so a far reflection
// can never land on a near canopy.
const nUmb = Math.min(25, Math.max(8, Math.round(9 + wetK * 14 + (downpour ? 3 : 0))));
const umbSpots = [];
for (let i = 0; i < nUmb; i++) {
umbSpots.push({ d: 0.22 + Math.pow(rng(), 0.85) * 0.74, xn: 0.06 + rng() * 0.88 });
}
umbSpots.sort((a, b) => a.d - b.d); // far first, near last
// the nearest walker is pinned to the front plane, on the composition's
// third — Caillebotte's foreground couple anchoring the perspective
umbSpots[umbSpots.length - 1] = { d: 0.93 + rng() * 0.05, xn: MX(0.26 + rng() * 0.18) };
const UMB_P = 0.855, UMB_W = 0.042;
for (let k = 0; k < umbSpots.length; k++) {
const s = umbSpots[k];
const g = gp(s.xn, s.d);
const sc = g.scale;
const fh = H * 0.085 * (0.30 + sc * 1.15); // figure height at this depth
const R = fh * 0.60; // canopy radius
const tilt = (rng() - 0.5) * 0.35;
const base = UMB_P + UMB_W * (k / umbSpots.length);
const step = UMB_W / umbSpots.length;
let slot = 0;
const at = () => base + step * (slot++ / 12);
const cy = g.y - fh * 1.04;
const cols = brokenColor(null, rng, umbBase(), 0.55);
const rimC = grey(0.07, 1);
const sheenC = grey(0.46, 2);
// the canopy: a coherent bloom — firm ellipses of broken greys
{
const c1 = cols[0], c2 = cols[1 % cols.length], c3 = cols[cols.length - 1];
const p0 = at();
stamped(p0, (c) => {
dab(c, g.x, cy, R * 1.05, c1, 0.78, tilt, 0.52);
dab(c, g.x, cy + R * 0.06, R * 0.85, c2, 0.62, tilt, 0.5);
dab(c, g.x, cy - R * 0.10, R * 0.55, c3, 0.40, tilt, 0.5);
});
}
// the hand over the canopy: broken texture on the wet silk — except
// under the impasto hand, whose ridge highlight would streak white
// across the dark silk; that wrist lays plain broken dabs instead
const nC = 4 + Math.floor(rng() * 3);
const impastoCanopy = hand.mode === 'impasto';
for (let i = 0; i < nC; i++) {
const u = ((i + rng()) / nC) * 2 - 1;
const ex = g.x + u * R * 0.60;
const ey = cy + Math.abs(u) * R * 0.18 + (rng() - 0.5) * R * 0.08;
const ew = R * (0.18 + rng() * 0.13);
const ec = cols[(i + Math.floor(rng() * 2)) % cols.length];
const ea = 0.18 + rng() * 0.14; // faint: texture, not slabs
stamped(at(), (c, jr) => {
if (impastoCanopy) dab(c, ex, ey, ew * 0.6, ec, ea + 0.15, tilt, 0.5);
else touch(c, jr, ex, ey, ew, ec, ea, tilt, 0.5);
});
}
// the rim: a darker edge under the skirt, two firm dabs
{
const r1x = g.x + (rng() - 0.5) * R * 1.2;
const r2x = g.x + (rng() - 0.5) * R * 1.2;
const rw = R * (0.18 + rng() * 0.10);
stamped(at(), (c) => {
dab(c, r1x, cy + R * 0.34, rw, rimC, 0.55, tilt, 0.4);
dab(c, r2x, cy + R * 0.30, rw * 0.8, rimC, 0.45, tilt, 0.4);
});
}
// the wet sheen: the crown catches the pale sky
{
const shX = g.x + (rng() - 0.5) * R * 0.4;
stamped(at(), (c) => {
dab(c, shX, cy - R * 0.24, R * 0.30, sheenC, clearing ? 0.34 : 0.22, tilt, 0.5);
});
}
// the figure: a dark vertical, 2-4 strokes
const coatC = mixHex(grey(0.12 + rng() * 0.05, Math.floor(rng() * 3)),
rng() < 0.3 ? '#6a4a3a' : '#2a2e3a', 0.15);
const bodyX = g.x + (rng() - 0.5) * fh * 0.06;
const bodyAng = -Math.PI / 2 + (rng() - 0.5) * 0.14;
const headX = g.x + (rng() - 0.5) * fh * 0.08;
stamped(at(), (c, jr) => {
band(c, jr, bodyX, g.y - fh * 0.42, fh * 0.58, bodyAng, fh * 0.20, coatC, 0.80);
band(c, jr, bodyX + (rng() - 0.5) * fh * 0.05, g.y - fh * 0.14, fh * 0.24,
bodyAng + (rng() - 0.5) * 0.2, fh * 0.14, mixHex(coatC, '#1c1e26', 0.3), 0.58);
});
stamped(at(), (c, jr) => {
touch(c, jr, headX, g.y - fh * 0.88, fh * 0.13, grey(0.16, 1), 0.6);
});
// the puddle reflection: an inverted smudge, blurrier and darker
const reflC = mixHex(coatC, skyLow, 0.35);
const refl = [];
for (let i = 0; i < 3; i++) {
refl.push({
x: g.x + (rng() - 0.5) * R * 0.5,
y: g.y + R * (0.10 + i * 0.16),
w: R * (0.28 - i * 0.05),
a: 0.10 + rng() * 0.08,
});
}
stamped(at(), (c) => {
for (const rf of refl) dab(c, rf.x, rf.y, rf.w, reflC, rf.a, 0, 0.4);
});
}
// ---- the street carries the city: façade and window reflections --------
for (const b of rowBuildings[2]) { // the pale façades lean into the wet street
for (let i = 0; i < 4; i++) {
const x = b.x + rng() * b.w;
const len = H * (0.02 + rng() * 0.045);
const ang = Math.PI / 2 + (rng() - 0.5) * 0.2;
const wd = Math.max(1.4, b.w * 0.12);
const a = 0.07 + rng() * 0.09;
stamped(0.90 + rng() * 0.018, (c, jr) => {
band(c, jr, x, b.yBase + 1, len, ang, wd, mixHex(skyLow, grey(0.55, 1), 0.4), a);
});
}
}
if (lampsOn) {
for (const w of nearLit) { // the kindled windows double themselves
tremble(0.905 + rng() * 0.018, w.x, w.yBase + 2,
H * (0.02 + rng() * 0.04), '#d8a850', 0.10 + rng() * 0.10, wetK, false);
}
}
// ---- glints: the brightest notes, struck last ---------------------------
for (let i = 0; i < 40; i++) {
const y = hzY + H * 0.03 + Math.pow(rng(), 0.8) * (H - hzY) * 0.9;
const x = rng() * W;
const dx = (rng() - 0.5) * W * 0.02;
const dy = (rng() - 0.5) * 2;
const col = lampsOn && rng() < 0.4 ? '#f0d78a' : mixHex(sheenAt(), '#ffffff', 0.15);
const w = 0.9 + rng() * 1.5;
const a = 0.20 + rng() * 0.25;
stamped(0.92 + rng() * 0.015, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + dx, y1: y + dy },
brush: 'round', width: w, color: col, alpha: a, taper: 'both' });
});
}
// ---- the rain itself: fine diagonal streaks ------------------------------
{
const lean = (rng() < 0.5 ? -1 : 1) * (0.10 + rng() * 0.08);
const streakN = downpour ? 520 : weather === 'rain' ? 380 : drizzle ? 200
: clearing ? 140 : weather === 'overcast' ? 110 : mistMode ? 70 : 60;
const rainCol = night ? grey(0.78, 2) : '#c6ccd4';
for (let i = 0; i < streakN; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = rng() * H;
const len = H * (0.010 + rng() * 0.022) * (downpour ? 1.5 : 1);
const wd = 0.7 + rng() * 1.1;
const a = (0.06 + rng() * 0.10) * (night ? 0.9 : 1);
stamped(0.935 + rng() * 0.038, (c, jr) => {
stroke(c, jr, {
path: { x0: x, y0: y, x1: x + Math.sin(lean) * len, y1: y + Math.cos(lean) * len },
brush: 'round', width: wd, color: rainCol, alpha: a, taper: 'none', spacing: 0.6,
});
});
}
// the final veil: downpour and mist soften every edge one last time
if (downpour || mistMode) {
const veilCol = mixHex('#e2e6e4', skyLow, night ? 0.55 : (dusk || dawn) ? 0.35 : 0.15);
for (let i = 0; i < 30; i++) {
const x = rng() * W;
const y = rng() * H;
const len = W * (0.15 + rng() * 0.30);
const ang = (rng() - 0.5) * 0.04;
const wd = H * (0.02 + rng() * 0.04);
const a = 0.03 + rng() * 0.05;
stamped(0.973 + rng() * 0.022, (c, jr) => {
band(c, jr, x, y, len, ang, wd, veilCol, a);
});
}
}
}
strokes = list.sort((a, b) => a.p - b.p);
if (typeof console !== 'undefined' && typeof window !== 'undefined' && window.__PLUIE_DEBUG) {
console.log('[pluie] strokes:', strokes.length, 'weather:', weather,
'drizzle:', drizzle, 'downpour:', downpour, 'lampsOn:', lampsOn, 'umbrellas:', umbSpots.length);
}
},
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);
}
},
};
// Les Toits de Paris — Caillebotte's rooftops hung as a nocturne. The city is
// an impression, not a blueprint: building masses generated by the house's
// skyline() department (which returns DATA and never paints), laid as broken
// colour — each block a few planes of shifted hue, edges eaten by the
// atmosphere — under a deep cool sky burning gold at the horizon. The soul of
// the piece is the windows: warm amber dabs that kindle bottom-up as the sky
// darkens, multiply at night, go out at dawn, and tremble as wet reflections
// in the street below, lampposts standing their watch. Planes persist; light,
// season and weather change the city — and the city is never the same city
// twice: each replay re-rolls its architecture (parisian mansards, industrial
// sawtooths and stacks, or abstract steps), its density, its side, its hand.
//
// Contract: identical to scenes/vallee.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. 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: every random value a closure uses is computed in init.
import {
clamp01, mulberry32, makeConditions, weatherOverlay,
mix as mixHex, subtitle as subtitleFr,
} from '../scene-api.js';
import {
Horizon, aerialFade, brokenColor, temperatureShift, handFor, handDab, skyline,
dab, fanDab, knife, rigger, stroke,
} from '../brushes.js';
let strokes = null;
// tiny local rng helper for inside stamped closures (jr is a mulberry32)
function rngJ(jr) { return jr(); }
export default {
id: 'skyline',
title: 'Les Toits de Paris',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'la ville, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1877));
const season = cond.season, weather = cond.weather;
const winter = season === 'winter', summer = season === 'summer';
const night = cond.timeOfDay === 'night', dusk = cond.timeOfDay === 'dusk';
const dawn = cond.timeOfDay === 'dawn', golden = cond.timeOfDay === 'goldenHour';
const mirror = cond.motifSide === 'right';
const MX = (xn) => (mirror ? 1 - xn : xn);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// primed canvas: a warm charcoal ground, lighter for daylight hours
ctx.fillStyle = night ? '#1a1e2e' : dusk ? '#3c3844'
: (dawn || golden) ? '#5a5462' : '#6a6674';
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 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; weather touches ride on ~16%
// of strokes, guarded locally so a scene-api polish never drops the city
const stamped = (p, fn) => {
const jr = mulberry32((0xB1CE5 + Math.floor(rng() * 0xffffff) * 607) >>> 0);
const wr = (cond.weather !== 'clear' && rng() < 0.16)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; the scene keeps painting */ }
}
});
};
// -- the hand: the technique axis holds the brush ----------------------
const hand = handFor(cond.technique);
// the workhorse touch: a short hatch, routed through the painter's hand
const touch = (c, jr, o) => handDab(c, jr, hand, {
x: o.x, y: o.y, len: o.len, ang: o.ang, w: o.w, c: o.c, a: o.a,
squash: o.squash,
plain: (c2) => stroke(c2, jr, {
path: { x0: o.x, y0: o.y, x1: o.x + Math.cos(o.ang) * o.len, y1: o.y + Math.sin(o.ang) * o.len },
brush: 'round', width: o.w, color: o.c, alpha: o.a, taper: 'none', spacing: 0.55,
}),
});
// a trembling reflection: broken vertical dashes stepping downward.
// All dice precomputed here in init; the closure only lays paint.
const addReflection = (p, x, y0, len, col, a, wetness) => {
const n = 2 + Math.floor(rng() * 3);
const segs = [];
for (let k = 0; k < n; k++) {
segs.push({
dx: (rng() - 0.5) * (2 + wetness * 5),
dx2: (rng() - 0.5) * 2.5,
w: 1.0 + rng() * 1.4,
frac: 0.7 + rng() * 0.5,
aK: 1 - k / (n + 1),
});
}
stamped(p, (c, jr) => {
const step = len / n;
for (let k = 0; k < n; k++) {
const s = segs[k];
stroke(c, jr, {
path: { x0: x + s.dx, y0: y0 + k * step, x1: x + s.dx + s.dx2, y1: y0 + (k + 1) * step * s.frac },
brush: 'round', width: s.w, color: col,
alpha: a * s.aK * (0.5 + wetness * 0.7), taper: 'both', spacing: 0.5,
});
}
});
};
// -- palettes: the complementary engine -------------------------------
// sky stops: top -> upper mid -> low band -> horizon glow
const SKY_STOPS = {
dawn: ['#46547a', '#7a6f8e', '#c88a6a', '#eec294'],
morning: ['#8fb2c8', '#b4c8d4', '#d8dcda', '#f0e8d0'],
noon: ['#78a2ca', '#a4c0d8', '#d0dce2', '#eae6d0'],
goldenHour: ['#6a6390', '#a87890', '#dc9a60', '#f2c47e'],
dusk: ['#232c4a', '#454266', '#7c5a70', '#c8875a'],
night: ['#0d1322', '#161f38', '#24304c', '#38455e'],
};
const wxCol = (h) => weather === 'mist' ? mixHex(h, '#dfe4e2', 0.30)
: weather === 'overcast' ? mixHex(h, '#aeb6ba', 0.42)
: weather === 'rain' ? mixHex(h, '#7d8b99', 0.38)
: weather === 'snow' ? mixHex(h, '#e9eef3', 0.40) : h;
const seasonCol = (h) => winter ? mixHex(h, '#c9d6e2', 0.14)
: summer ? mixHex(h, '#e8cf9e', 0.08) : h;
const skyTint = (h) => wxCol(seasonCol(h));
const skyAt = (band) => {
const stops = SKY_STOPS[cond.timeOfDay] || SKY_STOPS.morning;
const keys = [0, 0.45, 0.8, 1];
let out = stops[0];
for (let i = 1; i < keys.length; i++) {
if (band <= keys[i]) {
out = mixHex(stops[i - 1], stops[i], (band - keys[i - 1]) / (keys[i] - keys[i - 1]));
break;
}
}
return skyTint(out);
};
// how strongly warm light strikes the facades
const lightAmt = night ? 0 : clamp01(cond.sunStrength * (cond.warmth + 0.15)
* (dusk || golden ? 0.95 : dawn ? 0.6 : 0.8));
// the tonal spine: sky light, city mid-dark, street darkest — the masses
// must sit as silhouettes against the sky or the city dissolves into it
const DIM_TARGET = night ? '#1d2740' : dusk ? '#2c2c44' : dawn ? '#3a3c52'
: golden ? '#4a4258' : '#333a4c';
const dimF = night ? 0.62 : dusk ? 0.42 : dawn ? 0.36 : golden ? 0.22 : 0.40;
const dim = (h) => mixHex(h, DIM_TARGET, dimF);
const warmLight = dawn ? '#e8b088' : golden ? '#f2bc7c' : '#e09a5e';
const coolShade = night ? '#2a3554' : '#4a5478';
const moonCol = '#aebadb';
// -- geometry: horizon, the architecture department, the dice ---------
const vx = MX(0.38 + rng() * 0.24); // vanishing x, 0.38-0.62
const hzY = H * cond.horizonY;
const horizon = new Horizon(hzY, W * vx, 1.6, H);
const style = wpick([['parisian', 6], ['industrial', 3], ['abstract', 3]]);
const density = 0.55 + rng() * 0.30;
const side = rng() < 0.75 ? 'both' : (rng() < 0.5 ? 'left' : 'right');
// deep blocks: the near row must come DOWN the ground plane toward the
// viewer, or the city stays a necklace of shacks hugging the horizon
const maxDepth = 0.90 + rng() * 0.10;
const city = skyline(null, rng, {
horizon, frameW: W, frameH: H,
side, density,
heightVariance: 0.35 + rng() * 0.40,
style,
night: true, // always roll the window grid; kindling is ours
maxDepth,
});
const buildings = city.buildings;
const windowsAll = city.windows;
const sunPx = cond.sunX * W;
const sunPy = Math.min(Math.max(H * 0.04, cond.sunY * H), hzY - H * 0.02);
// ---- sky: broad hatched slabs -----------------------------------------
for (let i = 0; i < 720; i++) {
const x = -W * 0.03 + rng() * W * 1.04;
const yn = Math.pow(rng(), 0.9);
const band = yn; // 0 high, 1 at the horizon
const y = H * 0.010 + yn * Math.max(4, hzY - H * 0.012);
const col = skyAt(band);
const ang = (rng() - 0.5) * 0.16;
const len = W * (0.06 + rng() * 0.16) * (0.7 + band * 0.5);
const wd = H * (0.008 + rng() * 0.018) * (0.7 + band * 0.7);
const a = 0.10 + rng() * 0.17;
stamped(0.010 + 0.095 * band + rng() * 0.012, (c, jr) => {
stroke(c, jr, {
path: { x0: x, y0: y, x1: x + Math.cos(ang) * len, y1: y + Math.sin(ang) * len },
brush: 'round', width: wd, alpha: a, color: col, taper: 'none', spacing: 0.35,
});
});
}
// stars: night only, faint cold pinpricks high up
if (night) {
for (let i = 0; i < 46; i++) {
const x = rng() * W, y = rng() * hzY * 0.8;
const r = 0.7 + rng() * 1.1, a = 0.20 + rng() * 0.35;
const col = rng() < 0.7 ? '#cfd6e8' : '#e8ecf4';
stamped(0.085 + rng() * 0.02, (c) => {
dab(c, x, y, r, col, a);
});
}
}
// sun or moon: a small disc built from dabs, plus its halo
if (cond.sunVisible) {
const core = dusk ? '#e07840' : golden ? '#f4bc74' : dawn ? '#f2c08c' : '#f6e9c4';
for (let i = 0; i < 64; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 1.3) * H * 0.011;
const x = sunPx + Math.cos(a) * r, y = sunPy + Math.sin(a) * r;
const size = H * (0.004 + rng() * 0.005), al = 0.34 + rng() * 0.28;
const col = i < 16 ? core : mixHex(core, '#ffffff', 0.3);
stamped(0.09 + rng() * 0.02, (c) => {
dab(c, x, y, size, col, al);
});
}
for (let i = 0; i < 12; i++) { // the dense heart of the disc
const a = rng() * 6.2832, r = Math.pow(rng(), 0.9) * H * 0.005;
const x = sunPx + Math.cos(a) * r, y = sunPy + Math.sin(a) * r;
stamped(0.092 + rng() * 0.012, (c) => {
dab(c, x, y, H * (0.003 + rng() * 0.004), core, 0.45 + rng() * 0.25);
});
}
for (let i = 0; i < 14; i++) {
const a = rng() * 6.2832, r = H * (0.03 + rng() * 0.05);
const x = sunPx + Math.cos(a) * r, y = sunPy + Math.sin(a) * r;
const size = H * (0.012 + rng() * 0.02), al = 0.05 + rng() * 0.07;
stamped(0.095 + rng() * 0.02, (c) => {
dab(c, x, y, size, mixHex(core, skyAt(0.8), 0.5), al);
});
}
} else {
for (let i = 0; i < 22; i++) {
const a = rng() * 6.2832, r = Math.pow(rng(), 0.7) * H * 0.012;
const x = sunPx + Math.cos(a) * r, y = sunPy + Math.sin(a) * r;
const size = H * (0.004 + rng() * 0.005), al = 0.25 + rng() * 0.3;
const col = i < 12 ? '#dfe2e0' : '#eef0ea';
stamped(0.09 + rng() * 0.02, (c) => {
dab(c, x, y, size, col, al);
});
}
}
// cloud banks: flat watery masses drifting across the upper sky
const nClouds = 2 + Math.floor(rng() * 3);
for (let ci = 0; ci < nClouds; ci++) {
const cx = 0.12 + rng() * 0.76, cy = 0.05 + rng() * 0.5;
const sx = 0.10 + rng() * 0.13, sy = 0.016 + rng() * 0.022;
const n = 14 + Math.floor(rng() * 14);
const cols = night
? ['#2c3a58', '#24304a', '#36466a']
: wpick([
[['#e2dcea', '#d4cede', '#eee8e2'], 5],
[['#c4ceda', '#b4c0cc', '#d8dfe4'], 4],
[['#eed6b8', '#e4c8a2', '#f4e6d0'], 3],
]);
for (let i = 0; i < n; i++) {
const x = (cx + gauss() * sx) * W;
const y = (cy + gauss() * sy) * H;
const col = skyTint(cols[Math.floor(rng() * cols.length)]);
const ang = (rng() - 0.5) * 0.10;
const len = W * (0.03 + rng() * 0.07);
stamped(0.11 + rng() * 0.08, (c, jr) => {
if (rngJ(jr) < 0.55) {
knife(c, jr, { x, y, size: len * 0.5, color: col, alpha: 0.045 + rngJ(jr) * 0.075, angle: ang, squash: 0.30 });
} else {
fanDab(c, jr, { x, y, size: len * 0.35, color: col, alpha: 0.045 + rngJ(jr) * 0.065, angle: ang, squash: 0.35, spread: 0.8 });
}
});
}
}
// ---- the street plane: laid first, the masses stand on it -------------
// broken horizontal courses, darker near, with the warm corridor glowing
// toward the vanishing point — the street is the sky's wet echo
const streetBase = dim(wpick([['#3a3644', 5], ['#423c48', 4], ['#332e3c', 3]]));
const streetCols = [streetBase, mixHex(streetBase, '#000000', 0.10), mixHex(streetBase, skyAt(0.9), 0.25)];
const sheenCol = skyAt(0.95);
const wet = weather === 'rain' ? 1 : weather === 'overcast' ? 0.5
: weather === 'snow' ? 0.25 : weather === 'mist' ? 0.6 : 0.35;
for (let i = 0; i < 340; i++) {
const near = Math.pow(rng(), 0.75); // 0 at horizon, 1 at the frame bottom
const y = hzY + H * 0.004 + near * (H - hzY) * 0.99;
const x = -W * 0.02 + rng() * W * 1.02;
const len = W * (0.03 + rng() * 0.10) * (0.35 + near * 1.1);
const wd = Math.max(1.2, H * (0.004 + rng() * 0.008) * (0.35 + near * 1.4));
const isSheen = rng() < 0.22 + wet * 0.22;
const col = isSheen
? mixHex(sheenCol, streetBase, 0.35 + rng() * 0.3)
: streetCols[Math.floor(rng() * streetCols.length)];
const a = (isSheen ? 0.24 + rng() * 0.24 : 0.34 + rng() * 0.28) * (0.5 + near * 0.7);
const ang = (rng() - 0.5) * 0.06;
stamped(0.20 + 0.035 * near + rng() * 0.008, (c, jr) => {
stroke(c, jr, {
path: { x0: x, y0: y, x1: x + Math.cos(ang) * len, y1: y + Math.sin(ang) * len },
brush: 'round', width: wd, alpha: a, color: col, taper: 'none', spacing: 0.45,
});
});
}
// the hand's accents over the street: the technique leaves its trace
for (let i = 0; i < 110; i++) {
const near = Math.pow(rng(), 0.8);
const y = hzY + H * 0.01 + near * (H - hzY) * 0.96;
const x = -W * 0.02 + rng() * W * 1.02;
const isSheen = rng() < 0.4;
const col = isSheen ? mixHex(sheenCol, streetBase, 0.3) : streetCols[Math.floor(rng() * streetCols.length)];
const len = W * (0.02 + rng() * 0.07) * (0.4 + near);
const wd = Math.max(1.4, H * 0.006 * (0.4 + near * 1.3));
const a = 0.45 + rng() * 0.35;
const ang = (rng() - 0.5) * 0.10;
stamped(0.215 + 0.03 * near + rng() * 0.008, (c, jr) => {
touch(c, jr, { x, y, ang, len, w: wd, c: col, a, squash: 0.5 });
});
}
// the corridor: warm glow pooling at the vanishing point at low sun
if (dusk || dawn || golden) {
const glowCol = dusk ? '#c8875a' : golden ? '#f2c47e' : '#eec294';
for (let i = 0; i < 70; i++) {
const x = W * vx + gauss() * W * 0.05;
const y = hzY + Math.abs(gauss()) * H * 0.05;
const size = H * (0.008 + rng() * 0.016), al = 0.06 + rng() * 0.09;
stamped(0.225 + rng() * 0.012, (c) => {
dab(c, x, y, size, mixHex(glowCol, sheenCol, 0.3), al);
});
}
}
// ---- building masses: far to near, broken colour planes ---------------
// each block: vertical hatching in 2-3 broken neighbours, the sun-side
// edge warmed, the against-light edge cooled, the top edge eaten by sky
const FACADES = {
parisian: ['#b4a084', '#a89070', '#c0ac8e', '#98826a'],
industrial: ['#8a5a46', '#7c4e3c', '#9a644e', '#5c5864'],
abstract: ['#8c8494', '#7c7486', '#9a928c', '#6e6a7e'],
};
const facadePal = (FACADES[style] || FACADES.abstract).map(dim);
const ROW_WIN = [
{ mass: [0.24, 0.35], roof: [0.350, 0.372], win: [0.372, 0.428] }, // far
{ mass: [0.44, 0.55], roof: [0.550, 0.572], win: [0.572, 0.628] }, // mid
{ mass: [0.64, 0.74], roof: [0.740, 0.762], win: [0.762, 0.818] }, // near
];
const rowOf = (depth) => {
const f = clamp01((depth - 0.04) / Math.max(0.05, maxDepth));
return f < 0.34 ? 0 : f < 0.7 ? 1 : 2;
};
const nearnessOf = (depth) => clamp01((depth - 0.04) / Math.max(0.05, maxDepth));
const rowBuildings = [[], [], []];
for (const b of buildings) rowBuildings[rowOf(b.depth)].push(b);
for (let ri = 0; ri < 3; ri++) {
const [m0, m1] = ROW_WIN[ri].mass;
const [r0, r1] = ROW_WIN[ri].roof;
const [w0, w1] = ROW_WIN[ri].win;
for (const b of rowBuildings[ri]) {
const nearness = nearnessOf(b.depth);
const fade = aerialFade(0.15 + nearness * 0.85);
fade.alpha = Math.max(0.62, fade.alpha); // distant blocks stay present, only paler
const cxm = b.x + b.w / 2;
const sunSide = sunPx >= cxm ? 1 : -1;
const hazeC = skyAt(0.82 - nearness * 0.35);
const hazeMix = Math.min(0.78, (1 - nearness) * (0.42 + (weather === 'mist' ? 0.30 : 0)) + 0.05);
const base = mixHex(facadePal[Math.floor(rng() * facadePal.length)], hazeC, hazeMix);
// daylight value contrast: against a bright sky the city reads darker,
// not paler — only the far rows keep the haze
const shadeK = (night || dusk || dawn || golden) ? 0 : 0.20 * nearness;
const base2 = shadeK > 0 ? mixHex(base, '#565064', shadeK) : base;
const cols = brokenColor(null, rng, base2, 0.5);
const litK = lightAmt * (0.35 + nearness * 0.75);
const moonK = night ? 0.16 * (0.4 + nearness * 0.6) : 0;
// block in the mass first: full-height columns carrying the plane's
// colour — the silhouette must exist before the broken colour breaks it
const nBlock = Math.max(4, Math.min(14, Math.round(b.w / Math.max(2, W * 0.010))));
for (let i = 0; i < nBlock; i++) {
const u = (i + 0.5) / nBlock;
const x = b.x + b.w * u + (rng() - 0.5) * b.w * 0.06;
const litZone = sunSide > 0 ? u > 0.62 : u < 0.38;
const shadeZone = sunSide > 0 ? u < 0.38 : u > 0.62;
let col = mixHex(base2, cols[i % cols.length], 0.35); // a breath of broken colour
if (litZone && litK > 0.03) col = mixHex(col, warmLight, Math.min(0.7, 0.10 + litK * 0.45));
else if (shadeZone) col = mixHex(col, coolShade, 0.14 + 0.22 * rng());
if (moonK > 0) col = mixHex(col, moonCol, moonK * 0.6);
const wd = Math.max(2, (b.w / nBlock) * (1.5 + rng() * 0.6));
const len = b.h * (1.02 + rng() * 0.06); // columns poke past the roofline; the roof knits over
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.05;
const a = Math.min(1, (0.82 + rng() * 0.16) * fade.alpha);
stamped(m0 + (m1 - m0) * 0.52 * (i / Math.max(1, nBlock)) + rng() * 0.006, (c, jr) => {
stroke(c, jr, {
path: { x0: x, y0: b.yBase, x1: x + Math.cos(ang) * len, y1: b.yBase + Math.sin(ang) * len },
brush: 'round', width: wd, alpha: a, color: col, taper: 'none', spacing: 0.4,
});
});
}
const n = Math.max(8, Math.min(64, Math.round((b.w * b.h) / (W * H) * 7000)));
for (let i = 0; i < n; i++) {
const x = b.x + rng() * b.w;
const yf = rng();
const y = b.yBase - yf * b.h;
// three vertical planes: the sun-side face warms, the against-light
// face cools, the core holds the broken base
const u = (x - b.x) / Math.max(1e-3, b.w);
const litZone = sunSide > 0 ? u > 0.62 : u < 0.38;
const shadeZone = sunSide > 0 ? u < 0.38 : u > 0.62;
let col = cols[Math.floor(rng() * cols.length)];
if (litZone && litK > 0.03) col = mixHex(col, warmLight, Math.min(0.8, 0.16 + litK * 0.55 * rng()));
else if (shadeZone) col = mixHex(col, coolShade, 0.16 + 0.30 * rng());
if (moonK > 0 && yf > 0.75) col = mixHex(col, moonCol, moonK); // moonlit tops
if (winter && yf > 0.9) col = mixHex(col, '#e6edf4', 0.14); // frost on the parapets
const floorB = rng() < 0.20 && nearness > 0.3; // a floor course reads horizontal
const ang = floorB ? (rng() - 0.5) * 0.12 : -Math.PI / 2 + (rng() - 0.5) * 0.26;
const len = floorB ? b.w * (0.25 + rng() * 0.55) : b.h * (0.14 + rng() * 0.34);
const wd = Math.max(1.3, Math.min(b.h * 0.14, b.w * 0.14, H * 0.007 * (0.5 + nearness * 1.2)) * (0.8 + rng() * 0.6));
const a = (0.46 + rng() * 0.30) * fade.alpha * (floorB ? 0.65 : 1);
const p = m0 + (m1 - m0) * (0.40 + 0.56 * (i / Math.max(1, n))) + rng() * 0.005;
stamped(p, (c, jr) => {
stroke(c, jr, {
path: { x0: x, y0: y, x1: x + Math.cos(ang) * len, y1: y + Math.sin(ang) * len },
brush: 'round', width: wd, alpha: a, color: col, taper: 'none', spacing: 0.5,
});
});
}
// the hand's accents: a few touches laid in the technique's manner —
// empatement slabs, pointillisme dots, vernis veils — over firm planes
const nHand = Math.max(3, Math.min(14, Math.round(n * 0.22)));
for (let i = 0; i < nHand; i++) {
const x = b.x + rng() * b.w;
const y = b.yBase - rng() * b.h;
const u = (x - b.x) / Math.max(1e-3, b.w);
const litZone = sunSide > 0 ? u > 0.55 : u < 0.45;
let col = cols[Math.floor(rng() * cols.length)];
if (litZone && litK > 0.03) col = mixHex(col, warmLight, Math.min(0.8, 0.20 + litK * 0.40));
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.5;
const len = b.h * (0.10 + rng() * 0.25);
const wd = Math.max(1.4, Math.min(b.h, b.w, H * 0.010 * (0.5 + nearness * 1.2)) * 0.5);
const a = 0.55 + rng() * 0.35;
stamped(m0 + (m1 - m0) * (0.5 + 0.45 * rng()), (c, jr) => {
touch(c, jr, { x, y, ang, len, w: wd, c: col, a, squash: 0.8 });
});
}
// soft rim notes: the lit edge glows, the top edge drowns in sky
for (let i = 0; i < 6; i++) {
const y = b.yBase - rng() * b.h;
const ex = (sunSide > 0 ? b.x + b.w : b.x) + (rng() - 0.5) * 2;
const rs = Math.max(1.4, b.w * 0.10), ra = 0.06 + rng() * 0.09;
const rc = litK > 0.05 ? mixHex(warmLight, hazeC, 0.35) : hazeC;
stamped(m0 + (m1 - m0) * 0.9 + rng() * 0.01, (c) => {
dab(c, ex, y, rs, rc, ra);
});
}
for (let i = 0; i < 5; i++) {
const x = b.x + rng() * b.w;
const rs = Math.max(1.6, b.w * 0.16), ra = 0.08 + rng() * 0.10;
stamped(m0 + (m1 - m0) * 0.92 + rng() * 0.01, (c) => {
dab(c, x, b.yBase - b.h + (rng() - 0.5) * 2, rs, hazeC, ra);
});
}
// -- roof: mansard slopes, sawtooths, stacks, steps ---------------
const yTop = b.yBase - b.h;
const roofBase = mixHex(dim(style === 'industrial' ? '#4e4a52' : '#5c5866'), hazeC, hazeMix * 0.8);
const roofCols = brokenColor(null, rng, roofBase, 0.45);
const roofRim = litK > 0.05 ? mixHex(warmLight, roofBase, 0.45) : mixHex(moonCol, roofBase, night ? 0.35 : 0.7);
const nR = Math.max(3, Math.round(b.w / Math.max(2, W * 0.008)) + 2);
for (let i = 0; i < nR; i++) {
const u = i / Math.max(1, nR - 1);
const x = b.x + b.w * (0.02 + u * 0.96);
const sunF = clamp01(0.5 + sunSide * 0.5 * (sunSide > 0 ? u : 1 - u)); // sun-side slope catches light
const col = mixHex(roofCols[i % roofCols.length], roofRim,
Math.min(0.8, 0.22 * (litK + moonK) * (0.4 + sunF)));
if (weather === 'snow') { // snow-dusted roof line: a pale course on top
const sa = (0.30 + rng() * 0.25) * fade.alpha;
stamped(r0 + (r1 - r0) * (0.2 + 0.7 * u) + rng() * 0.004, (c, jr) => {
dab(c, x, yTop - Math.max(1, b.h * 0.02), Math.max(1.4, b.w * 0.12), '#eef3f8', sa);
});
}
// the hip of the mansard: slope up toward the ridge
const hip = b.roof === 'mansard' ? -Math.abs(u - 0.5) * 2 * b.h * 0.06 + b.h * 0.03 : 0;
const yy = yTop + hip;
const ang = -Math.PI / 2 - 0.35 + (rng() - 0.5) * 0.4;
const len = Math.max(2, b.h * (0.05 + rng() * 0.09));
const wd2 = Math.max(1.0, b.w * 0.10);
const a = (0.30 + rng() * 0.25) * fade.alpha;
stamped(r0 + (r1 - r0) * (0.2 + 0.7 * u) + rng() * 0.004, (c, jr) => {
touch(c, jr, { x, y: yy, ang, len, w: wd2, c: col, a, squash: 0.6 });
});
}
if (b.roof === 'sawtooth') {
const nT = Math.max(2, Math.round(b.w / Math.max(3, W * 0.016)));
for (let i = 0; i < nT; i++) {
const x = b.x + b.w * ((i + 0.5) / nT);
const size = Math.max(2, (b.w / nT) * 0.8);
const ang = -0.45 + (rng() - 0.5) * 0.2;
stamped(r0 + (r1 - r0) * 0.8 + rng() * 0.004, (c, jr) => {
knife(c, jr, { x, y: yTop - Math.max(1, b.h * 0.02), size,
color: mixHex(roofBase, roofRim, 0.25 * litK), alpha: 0.30 * fade.alpha,
angle: ang, squash: 0.35 });
});
}
}
if (b.stack) {
const st = b.stack;
const stW = Math.max(1.2, st.w);
stamped(r0 + (r1 - r0) * 0.5, (c, jr) => {
rigger(c, jr, { x: st.x, y: yTop + 2, length: st.h, color: mixHex(roofBase, '#1e1c22', 0.25),
alpha: 0.55 * fade.alpha, angle: -Math.PI / 2 + (rng() - 0.5) * 0.06, width: stW });
});
// smoke from the stack: soft dabs drifting
for (let i = 0; i < 6; i++) {
const sx = st.x + (0.4 + rng() * 0.8) * st.h * 0.12 * (i + 1);
const sy = yTop - st.h * (0.12 + 0.15 * i);
const ss = Math.max(1.6, st.w * (0.8 + i * 0.5));
stamped(r0 + (r1 - r0) * (0.6 + 0.06 * i), (c) => {
dab(c, sx, sy, ss, mixHex('#b8b4bc', hazeC, 0.4), 0.05 + rng() * 0.07);
});
}
}
for (const ch of b.chimneys) {
const chW = Math.max(1.0, ch.w);
stamped(r0 + (r1 - r0) * 0.55, (c, jr) => {
rigger(c, jr, { x: ch.x, y: yTop + 1, length: ch.h, color: mixHex(roofBase, '#2a2630', 0.3),
alpha: 0.6 * fade.alpha, angle: -Math.PI / 2 + (rng() - 0.5) * 0.08, width: chW });
});
stamped(r0 + (r1 - r0) * 0.7, (c) => {
dab(c, ch.x, yTop - ch.h, Math.max(1.0, ch.w * 0.8), mixHex(roofBase, '#000000', 0.2), 0.5 * fade.alpha);
});
if (winter) { // chimney smoke: the city breathing in the cold
for (let i = 0; i < 5; i++) {
const sx = ch.x + (rng() - 0.5) * 3 + i * 1.1;
const sy = yTop - ch.h * (0.3 + 0.5 * i);
const ss = Math.max(1.5, ch.w * (1 + i * 0.7));
stamped(r0 + (r1 - r0) * (0.72 + 0.05 * i), (c) => {
dab(c, sx, sy, ss, mixHex('#c8c4cc', hazeC, 0.35), 0.05 + rng() * 0.06);
});
}
}
}
// -- windows: the city kindling, bottom-up within the row ---------
const rowWins = [];
for (const w of windowsAll) if (rowOf(w.depth) === ri) rowWins.push(w);
rowWins.sort((a, b2) => b2.y - a.y); // bottom first: the street wakes first
const litFrac = night ? 1 : dusk ? 0.85 : golden ? 0.5 : dawn ? 0.35
: (weather === 'overcast' ? 0.16 : 0.10);
const wAlphaK = night ? 1 : dusk ? 0.85 : dawn ? 0.55 : golden ? 0.7 : 0.4;
const glowK = (night ? 1.6 : 1) * (weather === 'rain' ? 1.4 : weather === 'mist' ? 1.5 : 1);
for (let k = 0; k < rowWins.length; k++) {
const w = rowWins[k];
const rank = rowWins.length > 1 ? k / (rowWins.length - 1) : 0; // 0 bottom, 1 top
if (rng() >= litFrac) continue; // a dark window is data too
const alpha = Math.min(0.85, (0.45 + rng() * 0.45)) * wAlphaK;
const col = dawn ? mixHex(w.color, '#d8b090', 0.3) : w.color;
const p = w0 + (w1 - w0) * Math.pow(rank, 0.85) + rng() * 0.004;
// far rows glow faintly or they smear into one luminous band
const farRow = ri === 0;
const gw = farRow ? Math.max(1.2, w.w * 0.6) : Math.max(1.4, w.w * 0.8) * glowK * 0.85;
const ga = farRow ? 0.04 * wAlphaK : 0.07 * wAlphaK * (weather === 'rain' ? 1.3 : 1);
const cw = Math.max(0.9, w.w * (farRow ? 0.3 : 0.35));
stamped(p, (c) => {
// the glow halo, then the core: a lamp behind a pane of glass
dab(c, w.x, w.y, gw, col, ga);
dab(c, w.x, w.y, cw, col, alpha);
});
}
}
}
// ---- the far parapet: the endless city knitting the horizon ----------
// tiny hazed blocks in a continuous band, so the distance never reads empty
{
const parCols = FACADES[style].map((h) => mixHex(dim(h), skyAt(0.85), 0.35));
for (let i = 0; i < 150; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY + H * (0.004 + rng() * 0.030);
const w2 = W * (0.012 + rng() * 0.035);
const h2 = H * (0.008 + rng() * 0.022);
const col = parCols[Math.floor(rng() * parCols.length)];
const wd = Math.max(1.2, w2 * 0.22); // a thin block, not a balloon
const a = 0.32 + rng() * 0.28;
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.25;
stamped(0.30 + rng() * 0.10, (c, jr) => {
stroke(c, jr, {
path: { x0: x, y0: y, x1: x + Math.cos(ang) * h2, y1: y + Math.sin(ang) * h2 },
brush: 'round', width: wd, alpha: a, color: col, taper: 'none', spacing: 0.45,
});
});
}
}
// ---- lampposts: sentinels along the street ---------------------------
const lampsOn = dusk || night || dawn || weather === 'rain' || weather === 'mist' || weather === 'snow';
const nLamps = 4 + Math.floor(rng() * 4);
for (let i = 0; i < nLamps; i++) {
const dg = 0.50 + rng() * 0.42;
const sideSign = rng() < 0.5 ? -1 : 1;
const xn = vx + sideSign * (0.08 + rng() * 0.22);
const g = horizon.computeGroundPlane(xn * W, dg, H);
const poleH = H * (0.030 + rng() * 0.022) * (0.4 + g.scale * 1.2);
const poleW = Math.max(0.9, W * 0.0016 * (0.4 + g.scale));
const bow = (rng() - 0.5) * poleH * 0.04;
const pBase = 0.855 + 0.03 * dg + rng() * 0.01;
stamped(pBase, (c, jr) => {
rigger(c, jr, { x: g.x, y: g.y, length: poleH, color: night ? '#181c28' : '#2c2830',
alpha: 0.7, angle: -Math.PI / 2 + (rngJ(jr) - 0.5) * 0.04, width: poleW, bow });
});
if (lampsOn) {
const lampY = g.y - poleH;
const gs = Math.max(2.2, poleW * 4) * (night ? 1.5 : 1) * (weather === 'rain' || weather === 'mist' ? 1.4 : 1);
const ga = 0.14 + rng() * 0.08;
const ca = 0.6 + rng() * 0.3;
stamped(pBase + 0.004, (c) => {
dab(c, g.x, lampY, gs, '#f5d98a', ga);
dab(c, g.x, lampY, Math.max(1.2, poleW * 1.6), '#f8e6ab', ca);
});
addReflection(pBase + 0.008, g.x, g.y + 1, poleH * (1.2 + wet * 1.6), '#e8c46a', 0.30, wet);
}
}
// wet reflections of the near-row window lights
const reflProb = weather === 'rain' ? 0.8 : weather === 'mist' ? 0.65
: weather === 'snow' ? 0.3 : 0.5;
for (const b of rowBuildings[2]) {
for (const w of windowsAll) {
if (rowOf(w.depth) !== 2) continue;
if (w.x < b.x || w.x > b.x + b.w) continue;
if (rng() > reflProb) continue;
const yb = b.yBase;
const len = Math.max(3, H * 0.012 + rng() * H * 0.02) * (0.7 + wet * 1.1);
const a = 0.16 + rng() * 0.14;
addReflection(0.905 + rng() * 0.035, w.x, yb + 1, len, w.color, a, wet);
}
}
// ---- mist bands: the far masses dissolve to ghosts -------------------
if (weather === 'mist' || dawn) {
const nBands = (dawn ? 60 : 40) + Math.floor(rng() * 24);
for (let i = 0; i < nBands; i++) {
const hugHorizon = rng() < 0.6;
const y = hugHorizon ? hzY - H * (0.01 + rng() * 0.10) : hzY + (rng() - 0.4) * 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 = 0.04 + rng() * 0.07;
stamped(0.845 + rng() * 0.04, (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: glints and bright cores, struck last ---------------
const glintPal = lampsOn ? [['#f5d98a', 5], ['#e8c46a', 3], ['#f8e6ab', 2]] : [[sheenCol, 9]];
for (let i = 0; i < 40; i++) { // glints where lamps strike the wet street
const near = Math.pow(rng(), 0.8);
const y = hzY + H * 0.05 + near * (H - hzY) * 0.9;
const x = rng() * W;
const dx = (rng() - 0.5) * W * 0.03, dy = (rng() - 0.5) * 2;
const w = 0.9 + rng() * 1.6, a = 0.25 + rng() * 0.3;
let acc = 0, col = glintPal[0][0];
const t = rng() * glintPal.reduce((s, q) => s + q[1], 0);
for (const q of glintPal) { acc += q[1]; if (t <= acc) { col = q[0]; break; } }
stamped(0.945 + rng() * 0.035, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + dx, y1: y + dy },
brush: 'round', width: w, color: col, alpha: a, taper: 'both' });
});
}
if (night) { // the moon's broken column on the street
for (let i = 0; i < 10; i++) {
const y = hzY + ((i + 1) / 11) * (H - hzY) * 0.8;
const x0 = sunPx + (rng() - 0.5) * 6, x1 = sunPx + (rng() - 0.5) * 10;
const w = 1.4 + rng() * 2, a = 0.14 + rng() * 0.12;
stamped(0.96 + rng() * 0.02, (c, jr) => {
stroke(c, jr, { path: { x0, y0: y, x1, y1: y + H * 0.012 },
brush: 'round', width: w, color: '#cfd6e2', alpha: a, taper: 'both' });
});
}
}
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);
}
},
};
// Tournesols — a homage to Van Gogh's Sunflowers (Arles, 1888-89): the atelier's
// first INTERIOR subject. A vase of sunflowers against a wall, close and
// intimate — flowers at every degree of life and decay: young faces turned to
// the light, full golden heads heavy with seed, spent ones drooping and
// curling, a couple gone entirely to brown seed-spirals. Death and life in
// one bouquet.
//
// YELLOW ON YELLOW. The wall behind is pale chrome-yellow (dice may pick pale
// blue-green or pale rose — Vincent tried them all), the flowers in chrome,
// cadmium, ochre, amber. The values are separated by TEMPERATURE and EDGE,
// never by contour: no outline anywhere in this painting, forms turn only by
// colour-temperature shifts and broken colour. Petals are thick single strokes
// (empâtement's home ground) with darker-yellow shadow strokes laid between
// them. The wall's lightest-mid register is darkened softly behind the bouquet
// — a shadow zone, not a halo — so the yellows can sing against themselves.
// TONAL SPINE: wall lightest-mid > flowers brightest (petal highlights) with
// the darkest accents (seed-spirals, shadow folds) > vase mid > table darkest
// band.
//
// THE LIGHT IS THE SPINE. One dice-rolled light direction is shared by
// everything: the wall's vertical grade, the vase's lit and shaded flanks, the
// table's cast shadow, the young faces turned toward it. Morning brings a cool
// north light from the right; noon a bright even glow at maximum colour;
// goldenHour warm raking light from the left with a long soft shadow; dusk and
// night turn to lamplight — the yellows deepen to amber and the flowers glow
// like candles, the most dramatic variants. Weather enters only as light
// quality: overcast/rain give the softest edges, clear the strongest.
//
// THE BOUQUET IS ONE GESTURE. Every stem converges on the vase mouth; the
// heads fan across the upper canvas in a single dice-rolled spread; the
// season dice shifts the stage mix (spring: more young faces; summer: full
// heads; autumn/winter: heavy drooping, seed-spirals dominant). Dice-gated
// signatures: a fallen sunflower head on the table, loose petals drifted on
// the surface, a tiny turquoise fleck bottom-left — like Vincent's 'Vincent'
// alone on the vase.
//
// HOUSE BUG WORKAROUND (mouille): brushes.bleed() costs ~25ms/stroke
// proportional to canvas area — a full-canvas freeze under the mouille hand
// can hang chrome. This scene therefore NEVER routes its ~4000 routine touches
// through handDab when hand.mode === 'wet': wetTouch / wetSmear realize the
// wet hand by soft multi-dab layering (core dab + wider faint halo, no
// ctx.filter — same size band and ink curve the hand table asks for).
// bleed() is reserved for a capped handful of HERO elements only (≤8, the
// lamplight glows at dusk/night), each a single bleed call, so the mouille
// freeze stays interactive.
//
// Contract: identical to scenes/nympheas.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; nothing consumes randomness at
// draw time. Routine touches route through brushes.handFor(conditions.technique)
// + handDab (wetLite under mouille).
import {
mulberry32, makeConditions, weatherOverlay, skyGradient,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, bleed, dab, roundDab, stroke,
} from '../brushes.js';
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const GOLDEN = 2.39996323; // the sunflower's own angle, for the seed spirals
let strokes = null;
export default {
id: 'tournesols',
title: 'Tournesols',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Tournesols, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1888));
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', noon = tod === 'noon', morning = tod === 'morning';
const spring = season === 'spring', summer = season === 'summer';
const autumn = season === 'autumn', winter = season === 'winter';
const stormy = weather === 'overcast' || weather === 'rain';
const misty = weather === 'mist';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
// -- small deterministic helpers -------------------------------------
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
// one dedicated jitter stream per stroke, consumed lazily exactly once;
// weather touches ride on ~12% of strokes (light quality, kept airy).
const stamped = (p, fn) => {
const jr = mulberry32((0x7014 + Math.floor(rng() * 0xffffff) * 691) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; scene keeps painting */ }
}
});
};
// -- THE HAND: the rolled technique decides how every touch sit -------
const hand = handFor(cond.technique);
// small hands (sec, pointillisme) cover less area per touch; the big
// wall and table masses compensate so the room closes in every hand —
// the wrist changes how the paint sits, never what the composition says.
const covK = Math.min(1.35, Math.max(1, 1 / Math.max(0.25,
((hand.strokeLength[0] + hand.strokeLength[1]) / 2) *
((hand.dabSize[0] + hand.dabSize[1]) / 2))));
// MOUILLE WORKAROUND: the routine touches skip handDab's wet mode (its
// bleed() ctx.filter is the house bug) and realize the hand with soft
// multi-dab layering instead — same size band and ink curve as the hand
// table asks, no filter. Only the lamplight glows spend the bleed budget.
const isWet = hand.mode === 'wet';
const wetTouch = (g, jr, x, y, w, c, a, ang, squash) => {
const ds = hand.dabSize;
const k = ds[0] + (ds[1] - ds[0]) * jr();
const size = Math.max(0.5, w * 0.5 * k);
const ab = hand.alphaBase;
dab(g, x, y, size, c, a * 0.85 * ab, ang, squash);
dab(g, x, y, size * 1.35, c, a * 0.28 * ab, ang, squash * 0.92); // the bleed halo
if (jr() < 0.5) dab(g, x + (jr() - 0.5) * size, y + (jr() - 0.5) * size * 0.7,
size * 0.6, c, a * 0.38 * ab, ang, squash);
};
const wetSmear = (g, jr, x, y, len, w, c, a, ang) => {
const ds = hand.dabSize, sl = hand.strokeLength;
const L = len * (sl[0] + (sl[1] - sl[0]) * jr());
const hl = Math.max(1, L * 0.5 * (ds[0] + (ds[1] - ds[0]) * jr()));
const sq = clamp((w / hl) * 0.9, 0.10, 1);
const ab = hand.alphaBase;
dab(g, x, y, hl, c, a * 0.8 * ab, ang, sq);
dab(g, x, y, hl * 1.22, c, a * 0.26 * ab, ang, Math.min(1, sq * 1.3)); // the halo
if (jr() < 0.6) dab(g, x + Math.cos(ang) * L * 0.18 * (jr() - 0.5) * 2,
y + Math.sin(ang) * L * 0.18 * (jr() - 0.5) * 2, hl * 0.55, c, a * 0.4 * ab, ang, sq);
};
const touch = (x, y, w, c, a, ang = 0, squash = 1) => (cc, jr) => {
if (isWet) return wetTouch(cc, jr, x, y, w, c, a, ang, squash);
handDab(cc, jr, hand, {
x, y, len: 0, ang, w, c, a, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: a, angle: ang, squash }),
});
};
const smear = (x, y, len, ang, w, c, a, squash = 1) => (cc, jr) => {
if (isWet) return wetSmear(cc, jr, x, y, len, w, c, a, ang);
handDab(cc, jr, hand, {
x, y, len, ang, w, c, a, squash,
plain: (g) => stroke(g, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: c, alpha: a, taper: 'none', spacing: 0.55,
}),
});
};
// the soft air — shadow zones, lamplight halos, mist — stays translucent
// in every hand
const veil = (x, y, len, ang, w, c, a) => (cc) => {
const half = Math.max(1, len * 0.5);
const alpha = a * hand.alphaBase;
dab(cc, x, y, half, c, alpha, ang, Math.min(1, w / half));
dab(cc, x, y, half * 1.45, c, alpha * 0.35, ang, Math.min(1, (w * 1.5) / (half * 1.45)));
};
// HERO bleed budget: only the mouille hand spends it (≤8 single calls),
// and only on the lamplight — where the wet halo earns its keep.
let heroBudget = isWet && (dusk || night) ? 8 : 0;
const heroGlow = (x, y, r, c, a) => {
if (heroBudget <= 0) return null;
heroBudget--;
return (cc) => bleed(cc, clamp(r * 0.20, 1.2, 4), (g) => {
dab(g, x, y, r * 0.7, c, a, 0, 1);
dab(g, x, y, r, c, a * 0.5, 0, 1);
});
};
// -- THE LIGHT: one dice-rolled direction the whole room obeys ---------
// morning's cool north light enters from the right; goldenHour rakes in
// warm from the left; noon is even; dusk/night the lamp lives IN the
// flowers. dawn borrows the morning side, softer.
const lightDir = morning ? 1 : dawn ? 0.5 : golden ? -1 : 0; // +1 from right
// weather as light quality only: softest edges under overcast, strongest
// under clear sky
const softK = { clear: 1, mist: 0.88, overcast: 0.78, rain: 0.75, snow: 0.85 }[weather] || 1;
const skyMid = skyGradient(cond)[1].color; // ambient light tint of the hour
// fine steering by hand: the impasto wrist's opaque ink curve turns many
// small gestures into uniform rice — so under it the room is painted with
// fewer, broader trowel strokes, and the whisper layers thin out
const impasto = hand.mode === 'impasto';
const gCountK = impasto ? 0.55 : 1; // gesture stroke count
const gLenK = impasto ? 1.8 : 1; // and each one broader, longer
// -- palettes: the room --------------------------------------------------
// the wall: Vincent's dice — pale chrome-yellow, pale blue-green, pale rose
const WALLS = {
chrome: { base: '#e8d484', lit: '#f4e6a6', deep: '#c4a850' },
bluegreen:{ base: '#c4d2c2', lit: '#dce8d4', deep: '#92ac98' },
rose: { base: '#e4c8b2', lit: '#f2dccc', deep: '#bc9884' },
};
const wall = WALLS[['chrome', 'chrome', 'bluegreen', 'rose'][Math.floor(rng() * 4)] || 'chrome'];
const gradeWall = (col) => {
let out = col;
if (night) out = mixHex(out, '#241a10', 0.55); // the lamp owns the room
else if (dusk) out = mixHex(out, '#382c30', 0.28);
else if (golden) out = mixHex(out, '#e0a860', 0.16);
else if (dawn) out = mixHex(out, skyMid, 0.14);
else if (morning) out = mixHex(out, skyMid, 0.08); // cool north light
else out = mixHex(out, '#f8f0d0', 0.10); // noon: bright even glow
if (stormy) out = mixHex(out, '#8a9088', night ? 0.10 : 0.16);
if (misty) out = mixHex(out, '#d8dcd2', 0.10);
return out;
};
const W_B = gradeWall(wall.base), W_L = gradeWall(wall.lit), W_D = gradeWall(wall.deep);
const wallShadow = gradeWall(mixHex(wall.deep, '#5a5a2a', 0.35)); // the bouquet's zone
// the table: the darkest band, a heavy dark ochre edge
const T_TOP = gradeWall(mixHex('#553e16', '#402e10', 0.25));
const T_FRONT = gradeWall(mixHex('#2c2008', '#221a08', 0.30));
const T_LIT = gradeWall(mixHex('#826428', '#8e7030', 0.25));
// the vase: simple rounded ochre-green form, two dark bands
const V_LIT = gradeWall(mixHex('#c8a048', '#d8b858', 0.35));
const V_BODY = gradeWall(mixHex('#a87c28', '#987c34', 0.30));
const V_SHADE = gradeWall(mixHex('#6e521c', '#544c20', 0.35));
const V_BAND = gradeWall(mixHex('#364622', '#2a3620', 0.30)); // the signature bands
// the greens: stems and leaves, the complementary whisper — quiet olive,
// never emerald, so the yellows keep the stage
const STEM = [gradeWall('#4a6a2a'), gradeWall('#567630'), gradeWall('#3e5622'), gradeWall('#6a7a34')]
.map((c) => mixHex(c, '#6e7448', 0.28));
const LEAF = [gradeWall('#5c7a30'), gradeWall('#4a6628'), gradeWall('#6e8038')]
.map((c) => mixHex(c, '#6e7448', 0.22));
if (night) { for (let i = 0; i < STEM.length; i++) STEM[i] = mixHex(STEM[i], '#1c2410', 0.45); }
// the flowers: chrome, cadmium, ochre, amber — the tube of yellows.
// Richer than the wall: the separation is temperature, not outline.
const PET_FULL = ['#f0c028', '#e8a81c', '#d89014', '#f6d868', '#e09c18', '#f2c840'];
const PET_YOUNG = ['#d8d060', '#ccc250', '#e0da78', '#c4bc48'];
const PET_DROOP = ['#d09028', '#c08020', '#b87818', '#e0a838', '#a86814'];
const gradePetal = (col) => {
let out = col;
if (night) out = mixHex(mixHex(col, '#2a1c08', 0.14), '#f0c468', 0.16); // the candle glow
else if (dusk) out = mixHex(col, '#3a2812', 0.10);
else if (golden) out = mixHex(col, '#f0a850', 0.10);
else if (morning) out = mixHex(col, skyMid, 0.05);
else if (noon) out = mixHex(col, '#f8d828', 0.08); // maximum colour
if (stormy) out = mixHex(out, '#8a8a72', 0.10);
return out;
};
// petals carry the brightest register in every hand: lift them a step
const liftPetal = (col) => mixHex(col, '#f8e8a0', 0.10);
// disc + seed-spiral tones, per stage
const DISC = {
full: { base: '#8a5c14', dark: '#452c08', light: '#d2a23a', rim: '#c89830' },
young: { base: '#8a8a38', dark: '#5c6020', light: '#b2b04e', rim: '#a0a444' },
droop: { base: '#7e5614', dark: '#422c0a', light: '#bc9034', rim: '#aa7a1e' },
seed: { base: '#4a3212', dark: '#2c1e0c', light: '#6e5424', rim: '#8a6a2c' },
};
for (const k in DISC) {
DISC[k].base = gradePetal(DISC[k].base); DISC[k].dark = gradePetal(DISC[k].dark);
DISC[k].light = gradePetal(DISC[k].light); DISC[k].rim = gradePetal(DISC[k].rim);
}
// petal highlights, per hour: the brightest register of the painting
const PET_HI = night ? '#f4cc6e' : golden ? '#ffe090' : noon ? '#fcef9e' : dawn ? '#f4e6b0'
: morning ? '#f2e4a0' : '#f8eeb4';
// -- geometry: vase, table, bouquet --------------------------------------
const tableY = H * 0.78; // the table's top edge
const vaseSide = cond.motifCount === 1 ? 0.5 : (cond.motifSide === 'left' ? 0.32 : 0.68);
const vx = W * vaseSide;
const vaseH = H * 0.30, bodyW = W * 0.115, mouthW = W * 0.055, footW = W * 0.075;
const mouthY = tableY - vaseH - H * 0.012; // the mouth sits above the lip
const shadowDx = lightDir === 0 ? 0 : -Math.sign(lightDir); // shadow flees the light
const shadowLen = golden ? 2.1 : morning ? 1.2 : dawn ? 1.0 : dusk || night ? 0.8 : 0.45;
// -- THE PRIME: wall above, table below, painted once --------------------
{
// the sky's own stops grade the ambient light falling down the wall
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, mixHex(W_D, W_B, 0.25)); // the wall deepens near the ceiling
g.addColorStop(0.30, W_B);
g.addColorStop(0.62, W_L); // the light pools at bouquet height
g.addColorStop(tableY / H, mixHex(W_B, '#8a6a2a', 0.10));
g.addColorStop(Math.min(1, tableY / H + 0.001), mixHex(T_TOP, T_LIT, lightDir ? 0.2 : 0.05));
g.addColorStop(1, T_FRONT);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// a few broad unifying veils so the prime never reads as a flat wash
for (let i = 0; i < 30; i++) {
const y = rng() * H;
const col = y < tableY ? mixHex(W_B, W_L, rng() * 0.5) : mixHex(T_TOP, T_FRONT, rng());
stamped(0.010 + 0.020 * (y / H) + rng() * 0.006, veil(rng() * W, y, W * (0.10 + rng() * 0.22),
(rng() - 0.5) * 0.08, Math.max(2, H * (0.02 + rng() * 0.05)), col, (0.05 + rng() * 0.06) * softK));
}
// -- THE WALL: broken yellow on yellow, turned by temperature not line ---
// two layers: a base of soft overlapping dabs that closes the surface in
// EVERY hand (a dry wrist must not turn the wall into wallpaper), then
// short mixed-direction gesture strokes for the brushwork on top
for (let i = 0, nBase = Math.round(620 * covK); i < nBase; i++) {
const xn = rng(), yn = Math.pow(rng(), 0.9);
const x = xn * W, y = yn * tableY;
const edge = Math.pow(Math.abs(xn - 0.5) * 2, 1.6);
let col = edge > 0.45 || rng() < 0.30
? mixHex(W_D, W_B, rng() * 0.45)
: mixHex(W_B, W_L, rng() * 0.55);
col = mixHex(col, W_D, edge * 0.35); // the corners sit back
if (yn > 0.88) col = mixHex(col, '#8a6a2a', 0.06); // a dark seam above the table
// the base rides veil(): exact sizes, translucent in every hand — a dry
// or loaded wrist must not blotch the wall
stamped(0.030 + 0.095 * yn + rng() * 0.008, veil(x, y, Math.max(6, H * (0.020 + rng() * 0.024)),
(rng() - 0.5) * 0.6, Math.max(3, H * (0.010 + rng() * 0.012)), col,
(0.10 + rng() * 0.12) * softK));
}
for (let i = 0, nWall = Math.round(420 * covK * gCountK); i < nWall; i++) {
const xn = rng(), yn = Math.pow(rng(), 0.9);
const x = xn * W, y = yn * tableY;
const edge = Math.pow(Math.abs(xn - 0.5) * 2, 1.6);
let col = edge > 0.45 || rng() < 0.30
? mixHex(W_D, W_B, rng() * 0.45)
: mixHex(W_B, W_L, rng() * 0.55);
col = mixHex(col, W_D, edge * 0.30);
const horiz = rng() < 0.30;
const len = (horiz ? W * (0.008 + rng() * 0.014) : H * (0.010 + rng() * 0.018)) * gLenK;
const ang = horiz ? (rng() - 0.5) * 0.4 : -Math.PI / 2 + (rng() - 0.5) * 0.9;
// under the loaded wrist the wall's texture rides veil(): its opaque
// ink curve would turn every gesture into a hard rice grain
if (impasto) {
stamped(0.128 + 0.055 * yn + rng() * 0.008, veil(x, y, Math.max(8, len * 0.8), ang,
Math.max(3, H * 0.012), col, (0.22 + rng() * 0.22) * softK));
} else {
stamped(0.128 + 0.055 * yn + rng() * 0.008, smear(x, y, len, ang, Math.max(1.4, H * (0.005 + rng() * 0.008)),
col, (0.16 + rng() * 0.20) * softK, 0.55));
}
}
// -- THE TABLE: the darkest band, horizontal strokes ----------------------
for (let i = 0, nTab = Math.round(360 * covK * gCountK); i < nTab; i++) {
const xn = rng(), yn = rng();
const x = xn * W, y = tableY + yn * (H - tableY);
const front = yn > 0.62; // the table's front edge, darkest of all
let col = front ? mixHex(T_FRONT, T_TOP, rng() * 0.35)
: mixHex(T_TOP, T_LIT, rng() * 0.25);
if (!front && lightDir !== 0) { // the lit stretch of the tabletop
const litX = lightDir > 0 ? xn > 0.45 : xn < 0.55;
if (litX && rng() < 0.5) col = mixHex(col, T_LIT, 0.22);
}
if (night && !front && rng() < 0.3) col = mixHex(col, '#b08030', 0.14); // lamplight pools
const len = W * (0.014 + rng() * 0.035) * gLenK;
if (impasto) { // soft broad dabs: no rice grains on the wood
stamped(0.185 + 0.052 * yn + rng() * 0.008, veil(x, y, Math.max(10, len * 0.7),
(rng() - 0.5) * 0.10, Math.max(3, H * 0.011), col, (0.34 + rng() * 0.26) * softK));
} else {
stamped(0.185 + 0.052 * yn + rng() * 0.008, smear(x, y, len, (rng() - 0.5) * 0.10,
Math.max(1.4, H * (0.006 + rng() * 0.009)), col, (0.42 + rng() * 0.32) * softK, 0.4));
}
}
// -- the vase's cast shadow: long and soft at goldenHour -------------------
// a soft mass, not brushwork: it rides veil() so every hand lays it quiet
{
const nSh = Math.round(46 * (shadowLen > 1 ? 1.25 : 1));
for (let i = 0; i < nSh; i++) {
const t = Math.pow(rng(), 0.7); // dense at the foot, trailing out
const sx = vx + shadowDx * W * 0.02 + shadowDx * t * W * 0.16 * shadowLen
+ (rng() - 0.5) * W * 0.03;
const sy = tableY + H * 0.008 + t * H * 0.030 + (rng() - 0.5) * H * 0.012;
if (sy > H) continue;
stamped(0.243 + rng() * 0.012, veil(sx, Math.min(H - 2, sy), W * (0.012 + rng() * 0.022),
(rng() - 0.5) * 0.16 + shadowDx * 0.06, Math.max(2, H * 0.008),
mixHex(T_FRONT, '#141008', 0.30), (0.07 + t * 0.06) * softK));
}
}
// -- THE VASE: a rounded form in ochre-green, two dark bands ---------------
{
const vaseHalf = (t) => // 0 at the mouth, 1 at the foot; narrow lip,
(mouthW * 0.35 * (1 - t) + footW * 0.45 * t) // broad low belly, tucked foot
+ bodyW * 0.46 * Math.pow(Math.max(0, Math.sin(Math.PI * (0.10 + 0.72 * t))), 1.2);
const rows = 40;
for (let r = 0; r < rows; r++) {
const t = r / (rows - 1);
const halfW = vaseHalf(t);
const y = mouthY + t * (tableY - mouthY - H * 0.008);
const band = (t > 0.33 && t < 0.42) || (t > 0.53 && t < 0.62); // the two dark bands
// the row's body: overlapping round dabs — the form closes in every
// hand, a dry wrist cannot coin it
const rowW = Math.max(3, H * 0.017);
const nRow = Math.max(3, Math.round((halfW * 2) / (rowW * 0.62)));
for (let k = 0; k < nRow; k++) {
const u = nRow === 1 ? 0 : ((k + 0.5) / nRow) * 2 - 1; // -1 lit side .. 1 shade side
const x = vx + u * halfW * (0.84 + rng() * 0.08);
// the light falls from the lightDir flank; the far flank turns away
const litU = lightDir === 0 ? -u * 0.3 : lightDir > 0 ? -u : u;
let col;
if (band) col = mixHex(V_BAND, V_SHADE, rng() * 0.30);
else if (litU < -0.35) col = mixHex(V_LIT, V_BODY, rng() * 0.35);
else if (litU > 0.35) col = mixHex(V_SHADE, V_BODY, rng() * 0.40);
else col = mixHex(V_BODY, rng() < 0.5 ? V_LIT : V_SHADE, rng() * 0.35);
if (t < 0.06 && !band) col = mixHex(col, '#4a3a1c', 0.30); // the dark mouth hollow
if (t > 0.94) col = mixHex(col, '#2c220e', 0.25); // the foot sits in the shadow
stamped(0.262 + 0.070 * t + rng() * 0.008, touch(x, y, rowW * (0.75 + rng() * 0.25),
col, (0.78 + rng() * 0.18) * softK));
}
// a few horizontal brushwork accents ride the rows
for (let k = 0; k < 2; k++) {
const x = vx + (rng() * 2 - 1) * halfW * 0.68;
const litU = lightDir === 0 ? 0 : lightDir > 0 ? -1 : 1;
const col = band ? mixHex(V_BAND, V_SHADE, rng() * 0.3)
: litU < 0 ? mixHex(V_LIT, V_BODY, rng() * 0.4) : mixHex(V_SHADE, V_BODY, rng() * 0.4);
stamped(0.266 + 0.070 * t + rng() * 0.008, smear(x, y, W * (0.012 + rng() * 0.016),
(rng() - 0.5) * 0.18, Math.max(2, H * 0.010), col, (0.55 + rng() * 0.25) * softK, 0.35));
}
}
// the lip: a soft ellipse rim, catching the light on one flank
stamped(0.336, touch(vx, mouthY, mouthW * 0.9, mixHex(V_BODY, '#3a2c14', 0.35), 0.70, 0, 0.35));
stamped(0.338, touch(vx + (lightDir || -0.4) * mouthW * 0.25, mouthY - H * 0.004, mouthW * 0.5,
mixHex(V_LIT, PET_HI, 0.25), 0.55, 0, 0.3));
}
// -- the bouquet's shadow zone on the wall: depth, not a halo --------------
// the faintest grey-olive breath hugging the bouquet's silhouette — the
// real separation comes from the flowers' own saturation, never from fog
{
const cxz = vx, cyz = (mouthY + tableY) / 2 - H * 0.06;
for (let i = 0, nZ = Math.round(28 * covK); i < nZ; i++) {
const a = rng() * TAU, rr = Math.pow(rng(), 0.5);
const zx = cxz + Math.cos(a) * W * 0.17 * rr;
const zy = clamp(H * 0.07, cyz + Math.sin(a) * H * 0.20 * rr, tableY - H * 0.01);
const zcol = mixHex(wallShadow, '#6e6e50', 0.45); // desaturated: grey-olive, not orange
stamped(0.348 + rng() * 0.014, veil(zx, zy, W * (0.03 + rng() * 0.06),
(rng() - 0.5) * 0.3, Math.max(3, H * (0.014 + rng() * 0.026)), zcol,
(0.006 + rr * 0.011) * softK));
}
}
// -- THE BOUQUET: every stem converges on one mouth; heads fan above ------
const nF = 8 + Math.floor(rng() * 8); // 8-15 heads
// stage mix shifts with the season dice
const MIX = {
spring: [['young', 0.42], ['full', 0.36], ['droop', 0.16], ['seed', 0.06]],
summer: [['young', 0.12], ['full', 0.54], ['droop', 0.24], ['seed', 0.10]],
autumn: [['young', 0.05], ['full', 0.26], ['droop', 0.34], ['seed', 0.35]],
winter: [['young', 0.05], ['full', 0.20], ['droop', 0.35], ['seed', 0.40]],
}[season];
const stageOf = () => {
const u = rng();
let acc = 0;
for (const [st, w] of MIX) { acc += w; if (u < acc) return st; }
return 'full';
};
const heads = [];
for (let i = 0; i < nF; i++) {
const spread = (nF === 1 ? 0 : (i / (nF - 1)) * 2 - 1); // -1 .. 1 across the fan
const ang = -Math.PI / 2 + spread * 0.85 + (rng() - 0.5) * 0.22;
const dist = H * (0.18 + rng() * 0.20) * (Math.abs(spread) > 0.6 ? 0.88 : 1);
const hx = clamp(vx + Math.cos(ang) * dist, W * 0.07, W * 0.93);
const hy = clamp(mouthY - dist, H * 0.07, mouthY + H * 0.05); // stems reach up
heads.push({ i, hx, hy, stage: stageOf(), r: 0, tilt: (rng() - 0.5) * 0.5 });
}
// radii by stage, with the dice breathing through them
for (const hd of heads) {
const base = W * (hd.stage === 'full' ? 0.054 : hd.stage === 'seed' ? 0.046
: hd.stage === 'droop' ? 0.044 : 0.033);
hd.r = base * (0.84 + rng() * 0.36);
}
heads.sort((a, b) => a.hy - b.hy); // the far heads first, like the brush arriving
for (let fi = 0; fi < heads.length; fi++) {
const hd = heads[fi];
const { hx, hy, stage, r } = hd;
const pBase = 0.372 + 0.322 * (fi / Math.max(1, heads.length - 1));
const sub = 0.300 / Math.max(1, heads.length); // per-flower sub-window
const st = (k) => pBase + sub * clamp(k, 0, 0.98);
// -- the stem: one gesture rising from just above the lip to the head ---
const rootX = vx + (rng() - 0.5) * mouthW * 0.8;
const rootY = mouthY - H * 0.004;
const stemLen = Math.hypot(hx - rootX, hy - rootY);
const stemAng = Math.atan2(hy - rootY, hx - rootX);
const segs = 4;
for (let s = 0; s < segs; s++) {
const t0 = (s + 0.5) / segs; // segment CENTRES: smears are centred, so
// the first segment must not trail back down over the vase's lip
const bow = Math.sin(t0 * Math.PI) * stemLen * 0.05 * hd.tilt; // the living bend
const px = rootX + Math.cos(stemAng) * stemLen * t0 - Math.sin(stemAng) * bow;
const py = rootY + Math.sin(stemAng) * stemLen * t0 + Math.cos(stemAng) * bow;
const segA = stemAng + hd.tilt * 0.12 * (t0 - 0.4);
stamped(st(0.02 + t0 * 0.20) + rng() * 0.002, smear(px, py, stemLen / segs * 1.15, segA,
Math.max(1.3, r * 0.10 * (1.3 - t0 * 0.5)), pick(STEM), (0.58 + rng() * 0.20) * softK, 0.6));
if (rng() < 0.4) { // a broken-colour neighbour rides each stem
stamped(st(0.04 + t0 * 0.20) + rng() * 0.002, smear(px + (rng() - 0.5) * r * 0.2,
py + (rng() - 0.5) * r * 0.2, stemLen / segs * 0.8, segA + (rng() - 0.5) * 0.3,
Math.max(1.0, r * 0.09), pick(STEM), (0.30 + rng() * 0.20) * softK, 0.6));
}
}
// leaves: the green whisper, one or two per stem
const nLeaf = rng() < 0.55 ? 1 : rng() < 0.5 ? 2 : 0;
for (let l = 0; l < nLeaf; l++) {
const lt = 0.25 + rng() * 0.45;
const lx = vx + Math.cos(stemAng) * stemLen * lt;
const ly = mouthY + Math.sin(stemAng) * stemLen * lt;
const leafAng = stemAng + (rng() < 0.5 ? -1 : 1) * (0.9 + rng() * 0.5);
const ll = r * (0.8 + rng() * 0.6);
stamped(st(0.24 + l * 0.04) + rng() * 0.004, smear(lx + Math.cos(leafAng) * ll * 0.45,
ly + Math.sin(leafAng) * ll * 0.45, ll, leafAng, Math.max(2, ll * 0.24),
pick(LEAF), (0.42 + rng() * 0.22) * softK, 0.4));
stamped(st(0.26 + l * 0.04) + rng() * 0.004, smear(lx + Math.cos(leafAng) * ll * 0.55,
ly + Math.sin(leafAng) * ll * 0.55 + ll * 0.08, ll * 0.55, leafAng + 0.25,
Math.max(1, ll * 0.13), mixHex('#2c4018', pick(LEAF), 0.4), (0.26 + rng() * 0.16) * softK, 0.4));
}
// -- the head ------------------------------------------------------------
// drooping heads hang: the disc slides down from the stem's end
const headY = stage === 'droop' ? hy + r * (0.35 + rng() * 0.30) : hy;
const headX = hx + (stage === 'droop' ? hd.tilt * r * 0.5 : 0);
const sq = stage === 'young' ? 0.72 + rng() * 0.16 : stage === 'droop' ? 0.62 + rng() * 0.14 : 0.86 + rng() * 0.14;
const D = DISC[stage];
// petals first: thick single strokes radiating, darker shadows between.
// young faces turn toward the light (petals biased to the lightDir flank);
// drooping heads curl downward; seed-heads wear no petals at all.
if (stage !== 'seed') {
const nP = stage === 'full' ? 20 + Math.floor(rng() * 10)
: stage === 'young' ? 9 + Math.floor(rng() * 4) : 12 + Math.floor(rng() * 6);
const petCols = stage === 'full' ? PET_FULL : stage === 'young' ? PET_YOUNG : PET_DROOP;
const downBias = stage === 'droop' ? 0.55 : 0;
const lightBias = stage === 'young' ? (lightDir || 0) * 0.5 : 0;
for (let k = 0; k < nP; k++) {
const baseA = (k / nP) * TAU + hd.tilt;
// the radial direction, biased: young faces lean to the light,
// drooping heads curl to the floor
let ang = baseA * (1 - downBias) + (downBias ? (Math.sin(baseA) > 0 ? 0.35 : -0.15) : 0)
+ lightBias * 0.4;
const col = liftPetal(gradePetal(pick(petCols)));
const plen = r * (stage === 'full' ? 0.62 + rng() * 0.33
: stage === 'young' ? 0.40 + rng() * 0.20 : 0.48 + rng() * 0.26);
const px0 = headX + Math.cos(ang) * r * 0.78;
const py0 = headY + Math.sin(ang) * r * 0.78 * sq;
const warm = lightDir !== 0 && Math.cos(ang) * lightDir < 0 ? 1 : 0; // the lit flank
const pcol = warm ? mixHex(col, PET_HI, 0.22 + rng() * 0.20) : col;
const pcol2 = warm ? col : mixHex(col, '#8a6a10', 0.18 + rng() * 0.16); // the turned-away flank cools
stamped(st(0.30 + (k / nP) * 0.30) + rng() * 0.002, smear(px0, py0, plen, ang,
Math.max(1.6, plen * (stage === 'young' ? 0.28 : 0.20)), pcol, (0.92 + rng() * 0.08) * softK, 0.32));
// the shadow fold between petals: a shorter, darker stroke, half a
// step around — forms turn by temperature, never by outline.
// (under the loaded wrist the folds thin and thin out: its opaque
// ink curve would otherwise crush the head into a donut)
if (k % (impasto ? 3 : 2) === 0) {
const angB = baseA + TAU / nP * 0.5;
stamped(st(0.31 + (k / nP) * 0.30) + rng() * 0.002, smear(
headX + Math.cos(angB) * r * 0.78, headY + Math.sin(angB) * r * 0.78 * sq,
plen * 0.62, angB, Math.max(1.0, plen * 0.12), pcol2,
(impasto ? 0.10 : 0.26 + rng() * 0.14) * softK, 0.35));
}
// drooping petals curl: a second, steeper segment hooks them down
if (stage === 'droop' && k % 3 === 0) {
stamped(st(0.33 + (k / nP) * 0.30) + rng() * 0.002, smear(
px0 + Math.cos(ang) * plen * 0.8, py0 + Math.sin(ang) * plen * 0.8 + plen * 0.15,
plen * 0.55, ang * 0.35 + Math.PI / 2 * 0.9, Math.max(1.0, plen * 0.16),
mixHex(col, '#8a5a10', 0.25), (0.34 + rng() * 0.16) * softK, 0.4));
}
}
}
// the disc: a base, a lit rim on the light flank, then the seed spiral —
// the sunflower's own golden-angle arithmetic, dots of dark and light
// the disc: a cluster of base touches (a lone round touch would collapse
// to a single dot under the pointillisme wrist), then the lit rim
stamped(st(0.62) + rng() * 0.003, touch(headX, headY, r * 0.78, D.base, (0.86 + rng() * 0.12) * softK, 0, sq));
stamped(st(0.624) + rng() * 0.003, touch(headX - r * 0.38, headY + r * 0.16 * sq, r * 0.60,
D.base, (0.80 + rng() * 0.14) * softK, 0, sq));
stamped(st(0.628) + rng() * 0.003, touch(headX + r * 0.36, headY - r * 0.10 * sq, r * 0.56,
D.base, (0.80 + rng() * 0.14) * softK, 0, sq));
stamped(st(0.635) + rng() * 0.003, touch(headX + (lightDir || -0.4) * r * 0.40,
headY - r * 0.10, r * 0.52, D.rim, (0.36 + rng() * 0.18) * softK, 0, sq));
if (stage === 'seed') { // the dead head wears an ochre rim so it reads
stamped(st(0.645) + rng() * 0.003, touch(headX, headY, r * 1.35, D.rim, 0.30 * softK, 0, sq));
}
const nDots = Math.round(30 + r * 1.0); // bigger heads carry more seed
for (let d = 0; d < nDots; d++) {
const rr = Math.sqrt(d / nDots); // Fermat spiral: the head's own logic
const a = d * GOLDEN + hd.tilt;
const dx = headX + Math.cos(a) * rr * r * 0.50;
const dy = headY + Math.sin(a) * rr * r * 0.50 * sq;
const ring = Math.floor(rr * 4) % 2;
stamped(st(0.65 + (d / nDots) * 0.30) + rng() * 0.002,
touch(dx, dy, Math.max(1.0, r * (ring ? 0.16 : 0.13)), ring ? D.light : D.dark,
(ring ? 0.66 + rng() * 0.20 : 0.84 + rng() * 0.14) * softK));
}
// young faces get a bright eye toward the light — they are the future
if (stage === 'young') {
stamped(st(0.96) + rng() * 0.003, touch(headX + (lightDir || -0.4) * r * 0.30,
headY - r * 0.16, r * 0.30, mixHex(D.light, PET_HI, 0.4), 0.40 * softK));
}
// full heads droop under their own weight: a dark collar under the disc
// (skipped under the loaded wrist: its ink curve would blacken it)
if ((stage === 'full' || stage === 'droop') && !impasto) {
stamped(st(0.95) + rng() * 0.003, smear(headX, headY + r * sq * 0.85, r * 0.9,
0.15 + (rng() - 0.5) * 0.2, Math.max(1.2, r * 0.16),
mixHex(D.dark, '#241a08', 0.35), 0.30 * softK, 0.4));
}
}
// -- dice-gated signatures: the fallen head, the drifted petals -----------
if (rng() < 0.45) { // a fallen sunflower head on the table
const fx = vx + (rng() < 0.5 ? -1 : 1) * W * (0.14 + rng() * 0.10);
const fy = tableY + H * (0.06 + rng() * 0.08);
const fr = W * (0.030 + rng() * 0.012);
const fSq = 0.42 + rng() * 0.10;
stamped(0.700, smear(fx, fy + fr * fSq * 0.9, fr * 2.4, (rng() - 0.5) * 0.2,
Math.max(1.4, fr * 0.4), mixHex(T_FRONT, '#181008', 0.3), 0.16 * softK, 0.4)); // its shadow
for (let k = 0; k < 9; k++) { // curled ochre petals splayed on the wood
const a = (k / 9) * TAU + rng() * 0.4;
stamped(0.704 + k * 0.001 + rng() * 0.002, smear(fx + Math.cos(a) * fr * 0.75,
fy + Math.sin(a) * fr * 0.75 * fSq, fr * (0.38 + rng() * 0.22), a,
Math.max(1.2, fr * 0.24), gradePetal(pick(PET_DROOP)), (0.55 + rng() * 0.25) * softK, 0.35));
}
stamped(0.720, touch(fx, fy, fr * 1.1, mixHex(DISC.seed.base, '#8a6428', 0.30), 0.72 * softK, 0, fSq));
stamped(0.721, touch(fx - fr * 0.3, fy - fr * fSq * 0.3, fr * 0.5, DISC.seed.rim, 0.45 * softK, 0, fSq));
for (let d = 0; d < 14; d++) {
const rr = Math.sqrt(d / 14);
const a = d * GOLDEN;
stamped(0.724 + d * 0.001, touch(fx + Math.cos(a) * rr * fr * 0.5,
fy + Math.sin(a) * rr * fr * 0.5 * fSq, Math.max(0.9, fr * 0.15),
d % 2 ? mixHex(DISC.seed.light, '#c89a40', 0.3) : DISC.seed.dark, (0.55 + rng() * 0.3) * softK));
}
}
if (rng() < 0.55) { // loose petals drifted on the tabletop
const nP = 5 + Math.floor(rng() * 5);
for (let i = 0; i < nP; i++) {
const px = vx + (rng() * 2 - 1) * W * (0.12 + rng() * 0.14);
const py = tableY + H * (0.03 + rng() * 0.14);
if (py > H - 2) continue;
const pl = W * (0.012 + rng() * 0.014);
stamped(0.738 + rng() * 0.020, smear(px, py, pl, (rng() - 0.5) * 1.2 - 0.3,
Math.max(1.0, pl * 0.30), gradePetal(pick(PET_DROOP)), (0.45 + rng() * 0.25) * softK, 0.35));
stamped(0.742 + rng() * 0.018, smear(px + pl * 0.15, py + H * 0.004, pl * 0.5,
(rng() - 0.5) * 0.3, Math.max(0.8, pl * 0.14),
mixHex(T_FRONT, '#181008', 0.25), 0.20 * softK, 0.4)); // each petal its shadow twin
}
}
// -- LAMPLIGHT: dusk and night, the flowers glow like candles -------------
if (dusk || night) {
// one wide amber halo breathing behind the whole bouquet (hero bleed
// under the wet hand only; a plain veil everywhere else)
const glowFn = heroBudget > 0 ? heroGlow(vx, mouthY - H * 0.10, W * 0.30, '#e8b45a', night ? 0.10 : 0.07)
: veil(vx, mouthY - H * 0.10, W * 0.55, 0, W * 0.26, '#e8b45a', night ? 0.06 : 0.04);
if (glowFn) stamped(0.770, glowFn);
for (const hd of heads) {
if (hd.stage === 'seed') continue; // the dead do not glow
const headY2 = hd.stage === 'droop' ? hd.hy + hd.r * 0.5 : hd.hy;
const gcol = night ? '#f0c060' : '#e8b45a';
stamped(0.776 + rng() * 0.020, veil(hd.hx, headY2, hd.r * 3.4, 0, hd.r * 2.0, gcol,
(night ? 0.10 : 0.06) + rng() * 0.04));
stamped(0.782 + rng() * 0.020, touch(hd.hx, headY2, hd.r * 0.5, PET_HI,
(night ? 0.30 : 0.18) * softK)); // the wick
}
// the tabletop catches the lamp near the vase foot
for (let i = 0; i < 24; i++) {
stamped(0.806 + rng() * 0.014, veil(vx + (rng() - 0.5) * W * 0.16,
tableY + rng() * H * 0.10, W * (0.02 + rng() * 0.05), (rng() - 0.5) * 0.2,
Math.max(2, H * 0.008), '#c89040', (0.05 + rng() * 0.06) * softK));
}
}
if (golden && !night) { // the raking warm light pools on the wall's lit flank
for (let i = 0; i < 30; i++) {
const gx = lightDir > 0 ? W * (0.60 + rng() * 0.36) : W * (0.04 + rng() * 0.36);
stamped(0.806 + rng() * 0.016, veil(gx, rng() * tableY, W * (0.04 + rng() * 0.09),
(rng() - 0.5) * 0.2, Math.max(3, H * 0.014), '#f0c070', (0.04 + rng() * 0.05) * softK));
}
}
if (misty || dawn) { // the room's air thickens: a breath of mist on the wall
for (let i = 0; i < 34; i++) {
stamped(0.812 + rng() * 0.020, veil(rng() * W, rng() * tableY, W * (0.06 + rng() * 0.16),
(rng() - 0.5) * 0.1, H * (0.010 + rng() * 0.026), '#e8e6da',
(night ? 0.03 : 0.05) + rng() * (night ? 0.04 : 0.07)));
}
}
// -- the signature: 'Vincent', alone --------------------------------------
if (rng() < 0.62) {
const sx = W * (0.05 + rng() * 0.03), sy = H * (0.90 + rng() * 0.04);
stamped(0.852, touch(sx, sy, Math.max(1.6, H * 0.006), '#3e8a8a', 0.55 * softK));
stamped(0.855, touch(sx + 2, sy - 1, Math.max(0.9, H * 0.0025), '#7ac0bc', 0.45 * softK));
}
// unifying breath: a last pass of veils ties the registers together
// (thinned under the loaded wrist, whose alphaBase would print bars)
for (let i = 0; i < 22; i++) {
const y = rng() * H;
const col = y < tableY ? mixHex(W_B, W_L, rng() * 0.4) : mixHex(T_TOP, T_FRONT, rng() * 0.5);
stamped(0.858 + rng() * 0.020, veil(rng() * W, y, W * (0.08 + rng() * 0.18),
(rng() - 0.5) * 0.08, Math.max(2, H * (0.015 + rng() * 0.035)), col,
(0.03 + rng() * 0.045) * softK * (impasto ? 0.45 : 1)));
}
// -- the last notes: light catching the vase flank and the petal tips ------
for (let i = 0, nG = Math.round(130 * covK); i < nG; i++) {
const what = rng();
if (what < 0.35) { // the vase's lit flank
const t = rng();
const prof = Math.sin(Math.PI * (0.18 + 0.68 * t));
const halfW = (mouthW * (1 - t) + footW * t) * 0.5 + prof * bodyW * 0.42;
stamped(0.890 + rng() * 0.030, smear(vx - Math.sign(lightDir || -0.4) * halfW * 0.72,
mouthY + t * (tableY - mouthY), H * (0.006 + rng() * 0.012), (rng() - 0.5) * 0.4,
Math.max(1.0, H * 0.004), mixHex(V_LIT, PET_HI, 0.5), (0.14 + rng() * 0.16) * softK, 0.5));
} else { // the petal tips: the brightest yellow in the room
const hd = heads[Math.floor(rng() * heads.length)];
if (!hd || hd.stage === 'seed') continue;
const a = rng() * TAU;
const headY2 = hd.stage === 'droop' ? hd.hy + hd.r * 0.5 : hd.hy;
stamped(0.900 + rng() * 0.040, smear(hd.hx + Math.cos(a) * hd.r * 1.15,
headY2 + Math.sin(a) * hd.r * 1.15 * 0.9, hd.r * (0.18 + rng() * 0.24), a,
Math.max(0.8, hd.r * 0.14), PET_HI, (0.16 + rng() * 0.20) * softK, 0.5));
}
}
strokes = list.sort((a, b) => a.p - b.p);
},
draw(ctx, W, H, _rng, from, to, _conditions) {
if (!strokes) return;
for (let i = 0; i < strokes.length; i++) {
const s = strokes[i];
if (s.p >= from && s.p < to) s.fn(ctx);
}
},
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment