Commit 758057fc by PLN (Algolia)

impression: sixteen canvases, and they breathe

parent bb4d0806
......@@ -4,6 +4,7 @@
import scenes from './scenes/manifest.js';
import { mulberry32 } from './scene-api.js';
import { dab, brokenColor } from './brushes.js';
const params = new URLSearchParams(location.search);
const HOLD_MS = 7000;
......@@ -14,6 +15,8 @@ const REPLAY_STEPS = 480;
const baseSeed = params.has('seed') ? (parseInt(params.get('seed'), 10) || 0) : 0;
const freeze = params.has('freeze') ? Math.min(1, Math.max(0, parseFloat(params.get('freeze')) || 0)) : null;
const sceneParam = params.get('scene');
// ?still=1 — verification escape hatch: the hold stays a dead stop, no mutation
const still = params.has('still');
// --- the director's chair: conditions, speed, auto-advance -------------------
const LS_KEY = 'atelier-constraints';
......@@ -113,6 +116,7 @@ function startScene(i, replayTo = 0) {
offA = o.c; octx = o.ctx; offB = null;
callInit(scene, octx, rect.w, rect.h);
if (replayTo > 0) replay(0, replayTo); else lastTo = 0;
mutation = null; // a fresh canvas gets no afterlife from the previous show
phase = 'painting';
t0 = performance.now();
pausedAt = null;
......@@ -177,6 +181,136 @@ function archivePainting() {
.catch(() => {});
}
// --- PERPETUAL MUTATION -------------------------------------------------------
// After Eno's 77 Million Paintings: the finished canvas keeps living. Every
// ~400-700ms of the hold, a small "breath" of 2-6 soft, low-alpha dabs lands
// near busy regions of the painting, in the painting's own colours, drifting a
// dab at a time. Subtle by contract: alpha <= 0.15, worst-case touched area
// ~0.2% of the canvas per breath (6 dabs x radius ~rect.w*0.009 on a 4:3
// frame), a few pixels of total drift over the whole hold. The painting is
// never done, only evolving slower.
//
// ORDERING GUARANTEE: archivePainting() snapshots offA at the final stroke
// (painting phase), BEFORE the hold begins and before the first breath — the
// artwork of record is archived pristine; the mutation is its afterlife.
//
// Anchor honesty: scenes keep their stroke lists inside their own draw()
// closures, so the frame cannot read the scene's stroke data. The fallback
// heuristic works on the RENDERED pixels: the finished canvas is downsampled
// once at completion, per-cell luminance contrast is measured, and a jittered
// 4x3 anchor grid picks the busiest cell in each bucket, weighted toward the
// upper half (skies breathe first) — water bands get their share through the
// buckets that fall below the horizon. It is a heuristic over pixels, not
// scene knowledge; it errs toward texture, which is what drift reads best on.
//
// Determinism: each breath rolls its own dedicated mulberry32 stream seeded
// from the current seed + breath index, so breath #k is byte-identical for a
// given show; which breaths fire depends on hold timing, which is fine — the
// mutation is live performance, and freeze/?still=1 (and never-freeze mode)
// never run it at all.
let mutation = null; // per-show state, rebuilt at completion, cleared on any restart
function buildMutation() {
mutation = null;
if (still || freeze != null) return;
try {
// downsample the finished painting once; all further maths reads this grid
const SW = 96, SH = 72;
const small = document.createElement('canvas');
small.width = SW;
small.height = SH;
const sctx = small.getContext('2d');
sctx.drawImage(offA, 0, 0, SW, SH);
const px = sctx.getImageData(0, 0, SW, SH).data;
const lum = new Float32Array(SW * SH);
for (let i = 0; i < SW * SH; i++) {
lum[i] = 0.299 * px[i * 4] + 0.587 * px[i * 4 + 1] + 0.114 * px[i * 4 + 2];
}
const contrast = new Float32Array(SW * SH);
for (let y = 0; y < SH; y++) {
for (let x = 0; x < SW; x++) {
const i = y * SW + x;
contrast[i] = (x + 1 < SW ? Math.abs(lum[i] - lum[i + 1]) : 0)
+ (y + 1 < SH ? Math.abs(lum[i] - lum[i + SW]) : 0);
}
}
// dedicated anchor stream, distinct from init/stroke/conditions streams
const rng = mulberry32(((seedFor() * 31) ^ 0x6D2B79F5) >>> 0);
const anchors = [];
const GX = 4, GY = 3;
for (let gy = 0; gy < GY; gy++) {
for (let gx = 0; gx < GX; gx++) {
const x0 = Math.floor((gx * SW) / GX), x1 = Math.floor(((gx + 1) * SW) / GX);
const y0 = Math.floor((gy * SH) / GY), y1 = Math.floor(((gy + 1) * SH) / GY);
let best = y0 * SW + x0, bestC = -1;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const i = y * SW + x;
// upper-half bias (skies and light drift first), rng-jittered so
// the pick isn't a deterministic argmax of the same cell forever
const c = contrast[i] * (1 + 0.6 * (1 - y / SH)) * (0.5 + rng());
if (c > bestC) { bestC = c; best = i; }
}
}
const bx = best % SW, by = (best / SW) | 0;
anchors.push({
x: ((bx + 0.5) / SW) * rect.w + (rng() - 0.5) * rect.w * 0.06,
y: ((by + 0.5) / SH) * rect.h + (rng() - 0.5) * rect.h * 0.06,
r: px[best * 4], g: px[best * 4 + 1], b: px[best * 4 + 2],
dir: rng() * Math.PI * 2, // slow personal drift heading, dabs follow it
speed: rect.w * (0.0006 + rng() * 0.0012), // px/s: ~5-15px over a hold
});
}
}
mutation = { anchors, tick: 0, nextAt: performance.now() + 700 };
} catch {
mutation = null; // a tainted or vanished canvas must never break the hold
}
}
// one breath: advance the anchors along their drift, lay 2-6 soft dabs in the
// painting's own colours near them, schedule the next breath (longer as the
// crossfade approaches — the painting settles before it dissolves)
function breathe(now, gate) {
const rng = mulberry32(((seedFor() * 69621) ^ Math.imul(mutation.tick + 1, 0x9E3779B1)) >>> 0);
mutation.tick++;
const dt = Math.min(1, (now - mutation.lastAt) / 1000); // a long pause must not lurch the drift
mutation.lastAt = now;
const n = 2 + Math.floor(rng() * 5); // 2-6 dabs
for (let k = 0; k < n; k++) {
const a = mutation.anchors[Math.floor(rng() * mutation.anchors.length)];
const x = Math.min(rect.w * 0.97, Math.max(rect.w * 0.03, a.x + (rng() - 0.5) * rect.w * 0.05));
const y = Math.min(rect.h * 0.97, Math.max(rect.h * 0.03, a.y + (rng() - 0.5) * rect.h * 0.05));
// broken color around the pixel the painting itself holds there
const [c1, c2] = brokenColor(octx, rng, [a.r, a.g, a.b], 0.35);
const size = Math.max(1.5, rect.w * (0.003 + rng() * 0.006));
const ang = a.dir + (rng() - 0.5) * 0.6;
dab(octx, x, y, size, rng() < 0.5 ? c1 : c2, 0.03 + rng() * 0.08, ang, 0.55);
}
for (const a of mutation.anchors) {
a.x += Math.cos(a.dir) * a.speed * dt;
a.y += Math.sin(a.dir) * a.speed * dt * 0.5; // drift prefers horizontal: clouds, water
}
mutation.nextAt = now + (420 + rng() * 260) / Math.max(0.35, gate);
}
function stepMutation(now) {
if (!mutation) return;
if (mutation.lastAt == null) mutation.lastAt = now;
// Feather the end: in the last ~30% of the hold before an auto-crossfade the
// breathing slows and stops. An indefinite hold (?autoplay=0) never feathers
// — there is no crossfade to settle for, the painting just keeps living.
let gate = 1;
if (autoplay) {
const p = (now - t0) / HOLD_MS;
if (p >= 0.92) return;
if (p > 0.7) gate = 1 - (p - 0.7) / 0.22;
}
if (now < mutation.nextAt) return;
breathe(now, gate);
}
// --- the menu overlay: segmented conditions, speed, auto-advance, download ---
// Built entirely here (DOM + style) so the frame owns its own furniture; the
// look follows the house: serif small-caps, warm charcoal, hairline rules.
......@@ -452,17 +586,19 @@ function tick(now) {
}
blit(offA);
fillEl.style.width = (lastTo * 100).toFixed(2) + '%';
if (lastTo >= 1 && pausedAt == null) { phase = 'hold'; t0 = now; }
if (lastTo >= 1 && pausedAt == null) { phase = 'hold'; t0 = now; buildMutation(); }
} else if (phase === 'hold') {
if (pausedAt == null) stepMutation(now); // the painting keeps living while unpaused
blit(offA);
fillEl.style.width = '100%';
if (now - t0 >= HOLD_MS) {
if (pausedAt == null && now - t0 >= HOLD_MS) {
if (autoplay) {
pendingIdx = (idx + 1) % scenes.length;
if (pendingIdx === 0) showNumber++; // each replay of the show gets a fresh seed
const o = makeOffscreen();
offB = o.c;
callInit(scenes[pendingIdx], o.ctx, rect.w, rect.h);
mutation = null; // the afterlife ends with the crossfade
phase = 'fade';
t0 = now;
} else {
......@@ -513,7 +649,9 @@ addEventListener('keydown', (e) => {
else if (e.key === 'ArrowLeft') jump(idx - 1);
else if (e.key === ' ') {
e.preventDefault();
if (freeze == null && phase === 'painting') {
if (freeze == null && (phase === 'painting' || phase === 'hold')) {
// pausing the hold also stills the mutation: the breath is part of the
// living performance, and a held breath is no breath at all
if (pausedAt == null) pausedAt = performance.now();
else { t0 += performance.now() - pausedAt; pausedAt = null; }
}
......
......@@ -12,5 +12,8 @@ import bled from './bled.js';
import nympheas from './nympheas.js';
import neige from './neige.js';
import tournesols from './tournesols.js';
import verger from './verger.js';
import port from './port.js';
import moulin from './moulin.js';
export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols];
export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols, verger, port, moulin];
// Le moulin — a windmill on a Dutch polder dyke, painted the way the Hague
// school taught the Impressionists: flat reclaimed land holding its breath
// under three-quarters of sky. THE SKY IS THE HERO — enormous layered cloud
// masses with silver linings, sweeping horizontally, the light breaking
// through where the dice let it. Everything else lives low in the frame: the
// polder field in muted greens and ochres with drainage ditches glinting, a
// low dark dyke band crossing the picture, cattle dots, a canal holding the
// sky flat. And the vertical exclamation: the mill, a truncated cone with its
// great cross of sails, dark against the light like a Rembrandt subject seen
// through Jongkind's eyes.
//
// DICE (init stream, after the conditions stream). The dyke sits low or
// lower; the mill anchors a dice-rolled x; the sails sit at a dice-rolled
// angle — and at ~2 seeds in 10 the mill is WORKING: sails caught mid-turn,
// a ghost edge implying the motion. Mostly it stands still in the light.
// Cattle (0-5), drainage ditches, the canal, a small boat: each its own gate.
//
// CONDITIONS. makeConditions rolls hour/season/weather/hand as everywhere.
// Dawn lays mist over the polder and greys the mills to towers; noon is wide
// glare with cloud shadows racing the fields; goldenHour drops shafts of
// light through the clouds onto the dyke — the money shot; dusk and night
// silhouette the mill and light one window under the stars. Weather: mist
// dissolves the mills until only the sails show, rain streaks and sheens,
// snow whitens the flats, overcast boils the clouds. The hand realizes every
// texture touch its own way; structure rides veil() so forms close in every
// hand; the sails ride smear chains, the rigging-adjacent lines the rigger.
//
// Contract: every stroke precomputed in init() with a fixed progress stamp,
// draw() paints exactly the stamps in [from, to), the persistent canvas
// accumulates, same seed -> same painting. Per-stroke jitter and weather
// touches ride their own mulberry32 streams; nothing consumes randomness at
// draw time.
// TONAL SPINE: sky values high > polder mid > dyke and mills darkest >
// glints and the lit window brightest.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import { handFor, handDab, dab, roundDab, rigger, aerialFade } 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: 'moulin',
title: 'Le moulin',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Le moulin, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1886), null);
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', noon = tod === 'noon';
const misty = weather === 'mist', rain = weather === 'rain', snow = weather === 'snow';
const heavy = weather === 'overcast' || weather === 'rain';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
const pickA = (arr) => arr[Math.floor(rng() * arr.length)];
// one jitter stream per stroke; weather touches ride ~12% of strokes
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 ----------------------------------------------------------
const hand = handFor(cond.technique);
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean;
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(1.5, Math.max(1, 1 / Math.max(0.25, slMean * kMean)));
// compensate the hand's ink curve so values survive every wrist: the glaze
// hand lays one whisper per layer — the scene asks for more whispers.
const inkK = hand.alphaCurve === 'soft' ? 2.3 : hand.alphaCurve === 'bleed' ? 1.7
: hand.alphaCurve === 'matte' ? 1.25 : 1;
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);
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));
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) => {
const aa = a * inkK;
if (isWet) return wetTouch(cc, jr, x, y, handW(w), c, aa, ang, squash);
handDab(cc, jr, hand, {
x, y, len: 0, ang, w: handW(w), c, a: aa, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: aa, angle: ang, squash }),
});
};
const smear = (x, y, len, ang, w, c, a, squash = 1) => (cc, jr) => {
const aa = a * inkK;
if (isWet) return wetSmear(cc, jr, x, y, handL(len), handW(w), c, aa, ang);
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(w), c, a: aa, squash,
plain: (g) => {
g.strokeStyle = c;
g.globalAlpha = clamp(aa, 0, 1);
g.lineWidth = Math.max(0.8, w);
g.lineCap = 'round';
const dx = (Math.cos(ang) * len) / 2, dy = (Math.sin(ang) * len) / 2;
g.beginPath();
g.moveTo(x - dx, y - dy);
g.lineTo(x + dx, y + dy);
g.stroke();
g.globalAlpha = 1;
},
});
};
// the soft air stays translucent in every hand
const veil = (x, y, len, ang, w, c, a, squash = 0.5) => (cc) => {
const half = Math.max(1, len * 0.5);
const ab = hand.alphaBase;
dab(cc, x, y, half, c, a * ab, ang, clamp(squash, 0.06, 8));
dab(cc, x, y, half * 1.45, c, a * 0.34 * ab, ang, clamp(squash * 0.92, 0.06, 8));
};
// -- palettes ------------------------------------------------------------
const softK = { clear: 1, mist: 0.88, overcast: 0.78, rain: 0.75, snow: 0.85 }[weather] || 1;
const sg = skyGradient(cond);
const skyTop = sg[0].color, skyMid = sg[1].color, skyHor = sg[2].color;
const hzC = sg[3].color, deepC = sg[4].color;
const grade = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#232a3c', 0.40 * k);
else if (dusk) o = mixHex(o, '#4c3e52', 0.20 * k);
else if (dawn) o = mixHex(o, '#d09880', 0.12 * k);
else if (golden) o = mixHex(o, '#d8a860', 0.16 * k);
else if (noon) o = mixHex(o, '#f2ead8', 0.05 * k);
if (misty) o = mixHex(o, '#e2e6e2', 0.10 * k);
if (heavy) o = mixHex(o, '#9aa0a2', 0.10 * k);
if (snow) o = mixHex(o, '#dfe7ee', 0.12 * k);
return o;
};
// the polder field: groundPalette broken, graded by the hour
const gp = groundPalette(season, cond.warmth);
const FLD_L = gp.light.map((h) => grade(h));
const FLD_M = gp.mid.map((h) => grade(h));
const FLD_D = gp.shade.map((h) => grade(h, 1.15));
if (night) {
const f = (h) => mixHex(h, '#2c3444', 0.45);
for (const arr of [FLD_L, FLD_M, FLD_D]) for (let i = 0; i < arr.length; i++) arr[i] = f(arr[i]);
}
// the dyke: the darkest horizontal of the painting
const DYKE = [mixHex(FLD_D[0], '#2c2a24', 0.60), mixHex(FLD_D[2], '#33302a', 0.62),
mixHex(FLD_D[1], '#26241f', 0.68)].map((h) => grade(h, 0.9));
const DYKE_GRASS = [mixHex(FLD_L[0], '#e8e4b8', 0.30), mixHex(FLD_L[1], '#d8d8a8', 0.26)]
.map((h) => grade(h));
const WAT = [mixHex(skyMid, hzC, 0.45), mixHex(hzC, deepC, 0.40),
mixHex(hzC, deepC, 0.72), mixHex(deepC, '#1c2430', 0.25)];
const GLINT = night ? '#dce4f2' : '#fff4d8';
const LANTERN = '#f2c268';
const SIL = night ? '#181c26' : dusk || golden ? '#221e22' : '#2a2626'; // mill silhouette
const MILL_SIL = grade(mixHex(SIL, '#3a342c', 0.25), 0.85);
// -- geometry --------------------------------------------------------------
const hzF = cond.horizonY;
const hzY = H * hzF;
// THE DICE: dyke low or lower, mill anchor, sails, life
const dykeYF = 0.72 + rng() * 0.14; // 0.72-0.86
const dykeY = H * dykeYF;
const dykeH = H * (0.040 + rng() * 0.030);
const ditchesOn = rng() < 0.72;
const canalOn = rng() < 0.75;
const canalYF = 0.88 + rng() * 0.05;
const canalH = H * (0.045 + rng() * 0.035);
const boatOn = canalOn && rng() < 0.40;
const nCattle = Math.floor(rng() * 6); // 0-5
const nDistant = (rng() < 0.5 ? 1 : 0) + (rng() < 0.22 ? 1 : 0);
const sunLeft = cond.sunX < 0.5;
const shDx = sunLeft ? 1 : -1; // shadows flee the light
const longK = golden ? 2.2 : (dawn || dusk) ? 1.5 : night ? 0.8 : noon ? 0.5 : 0.75;
const working = rng() < 0.30; // the mill is turning
const sailAngle = rng() * TAU;
const windK = rng(); // 0 becalmed .. 1 blowing
const galleryOn = rng() < 0.42;
const mistMill = rng() < 0.8; // mist eats the body first
// the dominant mill: anchor of the whole sky, off-centre, clear of the sun
let mx = (0.20 + rng() * 0.60) * W;
const sxp = cond.sunX * W;
if (Math.abs(mx - sxp) < W * 0.10) mx = sxp + (mx < sxp ? -1 : 1) * W * 0.16;
mx = clamp(mx, W * 0.16, W * 0.84);
const millH = H * (0.30 + rng() * 0.14); // the vertical exclamation
const millTop = dykeY - millH;
const wB = millH * (0.20 + rng() * 0.05); // base width
const wT = wB * (0.50 + rng() * 0.12); // cap width
const sailL = Math.min(millH * (0.90 + rng() * 0.25), millTop - H * 0.03);
const hub = { x: mx, y: millTop + wT * 0.25 };
// -- THE PRIME -------------------------------------------------------------
{
const g = ctx.createLinearGradient(0, 0, 0, hzY + 1);
g.addColorStop(0, sg[0].color);
g.addColorStop(Math.min(0.97, hzF * 0.6), sg[1].color);
g.addColorStop(1, sg[2].color);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, hzY + 1);
const g2 = ctx.createLinearGradient(0, hzY, 0, dykeY + 1);
g2.addColorStop(0, mixHex(skyHor, FLD_M[1], 0.28));
g2.addColorStop(0.45, FLD_M[1]);
g2.addColorStop(1, mixHex(FLD_M[0], FLD_D[1], 0.35));
ctx.fillStyle = g2;
ctx.fillRect(0, hzY, W, dykeY - hzY + 1);
const g3 = ctx.createLinearGradient(0, dykeY - 1, 0, H);
g3.addColorStop(0, mixHex(DYKE[0], '#20241c', 0.35));
g3.addColorStop(Math.min(1, (dykeY + dykeH) / H), mixHex(DYKE[1], '#262a20', 0.35));
g3.addColorStop(1, mixHex(FLD_D[1], DYKE[2], 0.35));
ctx.fillStyle = g3;
ctx.fillRect(0, dykeY - 1, W, H - dykeY + 1);
if (canalOn) {
ctx.fillStyle = mixHex(WAT[3], deepC, 0.35);
ctx.fillRect(0, canalYF * H, W, canalH);
}
// the horizon breathes: sky and field bleed into each other
ctx.fillStyle = (() => {
const c = mixHex(skyHor, FLD_M[1], 0.45);
const n = parseInt(c.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},0.55)`;
})();
ctx.fillRect(0, hzY - H * 0.018, W, H * 0.036);
}
// -- THE SKY: the hero. Enormous, layered, moving. -------------------------
for (let i = 0, n = Math.round(720 * covK); i < n; i++) {
const y = Math.pow(rng(), 1.05) * hzY * 1.02;
const col = y < hzY * 0.45
? mixHex(skyTop, skyMid, rng())
: mixHex(skyMid, skyHor, rng() * 0.8);
stamped(0.010 + 0.055 * (y / Math.max(1, hzY)) + rng() * 0.005,
smear(rng() * W, y, W * (0.04 + rng() * 0.11), (rng() - 0.5) * 0.22,
Math.max(2, H * (0.013 + rng() * 0.024)), col, (0.08 + rng() * 0.10) * softK, 0.35));
}
// high cirrus: the sweeping movement, long flat streaks
for (let i = 0, n = Math.round(80 * covK); i < n; i++) {
const y = H * (0.02 + rng() * 0.16);
stamped(0.068 + rng() * 0.014,
smear(rng() * W, y, W * (0.10 + rng() * 0.22), (rng() - 0.5) * 0.06,
Math.max(1.2, H * 0.005), mixHex(skyTop, '#ffffff', 0.25 + rng() * 0.2),
(0.04 + rng() * 0.06) * softK, 0.18));
}
// cloud banks: layered masses, dark belly, silver lining on the lit edge
const nBank = (heavy ? 6 : 4) + Math.floor(rng() * 4);
for (let i = 0; i < nBank; i++) {
const cy0 = H * (0.05 + rng() * Math.max(0.10, hzF * 0.62));
const cx0 = rng() * W;
const bw = W * (0.22 + rng() * 0.34);
const nW = 30 + Math.floor(rng() * 20);
const base = mixHex(skyMid, night ? '#8a94ac' : '#ffffff',
night ? 0.10 : (heavy ? 0.12 : 0.36 + rng() * 0.16));
const belly = mixHex(base, night ? '#2c344c' : '#5c5670', night ? 0.42 : 0.50);
const pB = 0.135 + (i / nBank) * 0.085;
// pass one: the dark under-layer, the mass's weight
for (let k = 0; k < nW; k++) {
const t = k / (nW - 1);
const arc = Math.sin(t * Math.PI);
const px = cx0 + (t - 0.5) * bw + (rng() - 0.5) * W * 0.035;
const py = cy0 + arc * H * 0.030 * (rng() * 0.5 + 0.5) + (rng() - 0.5) * H * 0.016 + H * 0.006;
stamped(pB + 0.030 * t + rng() * 0.002,
veil(px, py, W * (0.030 + rng() * 0.065), (rng() - 0.5) * 0.06,
Math.max(2, H * (0.012 + rng() * 0.024)), belly, (0.18 + rng() * 0.14) * softK, 0.3));
}
// pass two: the lit body heaped above it
for (let k = 0; k < nW; k++) {
const t = k / (nW - 1);
const arc = Math.sin(t * Math.PI);
const px = cx0 + (t - 0.5) * bw + (rng() - 0.5) * W * 0.035;
const py = cy0 + arc * H * 0.030 * (rng() * 0.5 + 0.5) + (rng() - 0.5) * H * 0.016 - H * 0.007;
stamped(pB + 0.030 * t + rng() * 0.002,
veil(px, py, W * (0.030 + rng() * 0.060), (rng() - 0.5) * 0.06,
Math.max(2, H * (0.012 + rng() * 0.022)), base, (0.19 + rng() * 0.15) * softK, 0.3));
if (rng() < 0.4) { // the stacked second storey
stamped(pB + 0.030 * t + rng() * 0.002,
veil(px + (rng() - 0.5) * W * 0.02, py - H * (0.012 + rng() * 0.020),
W * (0.02 + rng() * 0.05), (rng() - 0.5) * 0.06,
Math.max(2, H * (0.010 + rng() * 0.018)), base, (0.14 + rng() * 0.12) * softK, 0.3));
}
}
// the belly: weight under the mass
for (let k = 0, nB = 12 + Math.floor(rng() * 8); k < nB; k++) {
const t = rng();
stamped(pB + 0.031 + rng() * 0.002,
veil(cx0 + (t - 0.5) * bw * 0.9, cy0 + Math.sin(t * Math.PI) * H * 0.018 + H * 0.012,
W * (0.025 + rng() * 0.055), (rng() - 0.5) * 0.05,
Math.max(1.5, H * (0.007 + rng() * 0.012)), belly, (0.15 + rng() * 0.15) * softK, 0.3));
}
// the silver lining: the lit rim, sunward side, brightest near the light
if (!night && rng() < 0.88) {
const litSide = sunLeft ? -1 : 1;
for (let k = 0, nL = 10 + Math.floor(rng() * 8); k < nL; k++) {
const t = rng();
const px = cx0 + (t - 0.5) * bw + litSide * W * 0.006;
stamped(pB + 0.032 + rng() * 0.002,
veil(px, cy0 + Math.sin(t * Math.PI) * H * 0.020 - H * 0.009,
W * (0.018 + rng() * 0.045), (rng() - 0.5) * 0.04,
Math.max(1.0, H * (0.0035 + rng() * 0.007)),
mixHex(base, GLINT, 0.60 + rng() * 0.25), (0.24 + rng() * 0.16) * softK, 0.25));
}
}
}
// the big sweeping masses: two or three towering cumulus, Rembrandt-weight
for (let i = 0, nM = 2 + Math.floor(rng() * 2); i < nM; i++) {
const cy0 = H * (0.04 + rng() * 0.14);
const cx0 = W * (0.10 + rng() * 0.80);
const mw = W * (0.16 + rng() * 0.16);
const mh = H * (0.07 + rng() * 0.10);
const lightBase = mixHex(skyMid, '#ffffff', 0.42 + rng() * 0.14);
const darkBase = mixHex(skyMid, night ? '#232c44' : '#5c586c', 0.50);
const pM = 0.230 + (i / nM) * 0.030;
for (let k = 0, nK = 44 + Math.floor(rng() * 18); k < nK; k++) {
const t = rng(), u = rng();
const px = cx0 + (t - 0.5) * mw + (rng() - 0.5) * W * 0.02;
const py = cy0 + (u - 0.3) * mh + Math.sin(t * Math.PI) * mh * 0.2;
const up = 1 - clamp((py - cy0) / mh + 0.35, 0, 1); // lit from above
stamped(pM + 0.022 * u + rng() * 0.002,
veil(px, py, W * (0.025 + rng() * 0.05), (rng() - 0.5) * 0.05,
Math.max(2, H * (0.012 + rng() * 0.022)),
rng() < up * 0.85 ? lightBase : darkBase,
(0.15 + rng() * 0.16) * softK, 0.3));
}
if (!night) { // the lit crown
for (let k = 0, nK = 12 + Math.floor(rng() * 6); k < nK; k++) {
const t = rng();
stamped(pM + 0.026 + rng() * 0.002,
veil(cx0 + (t - 0.5) * mw * 0.8, cy0 - mh * 0.32 + (rng() - 0.5) * H * 0.010,
W * (0.015 + rng() * 0.035), (rng() - 0.5) * 0.04,
Math.max(1.2, H * (0.004 + rng() * 0.008)),
mixHex(lightBase, GLINT, 0.55), (0.18 + rng() * 0.16) * softK, 0.25));
}
}
}
// the Rembrandt corners: dark boiling masses high in one upper corner,
// so the bright break near the light reads as the sky's whole drama
if (!night && rng() < 0.85) {
const cornerX = sunLeft ? W * (0.78 + rng() * 0.2) : W * (0.02 + rng() * 0.2);
const cornerY = H * (0.03 + rng() * 0.10);
const cDark = mixHex(skyMid, night ? '#1c2438' : '#4a4458', 0.62);
const pC2 = 0.252;
for (let k = 0, nK = 56 + Math.floor(rng() * 20); k < nK; k++) {
const t = rng(), u = rng();
const px = cornerX + (t - 0.5) * W * 0.36 * (1 - u * 0.4);
const py = cornerY + u * H * 0.16 + Math.sin(t * Math.PI) * H * 0.02;
stamped(pC2 + 0.020 * u + rng() * 0.002,
veil(px, py, W * (0.03 + rng() * 0.06), (rng() - 0.5) * 0.05,
Math.max(2, H * (0.012 + rng() * 0.024)),
rng() < 0.25 ? mixHex(cDark, skyMid, 0.4) : cDark,
(0.20 + rng() * 0.16) * softK, 0.3));
}
// one silver break in the boiling dark
for (let k = 0, nK = 8; k < nK; k++) {
const t = rng();
stamped(pC2 + 0.024 + rng() * 0.002,
veil(cornerX + (t - 0.5) * W * 0.20, cornerY + H * (0.05 + rng() * 0.06),
W * (0.015 + rng() * 0.03), (rng() - 0.5) * 0.05,
Math.max(1.0, H * (0.003 + rng() * 0.006)),
mixHex(GLINT, skyMid, 0.25), (0.18 + rng() * 0.14) * softK, 0.25));
}
}
// -- SUN or MOON ------------------------------------------------------------
{
const sx = sxp, sy = cond.sunY * H;
if (cond.sunVisible) {
stamped(0.235, veil(sx, sy, W * 0.15, 0, W * 0.085, '#f6e0a8', 0.13 * softK, 1));
stamped(0.236, veil(sx, sy, W * 0.09, 0, W * 0.05, '#f8e6b0', 0.16 * softK, 1));
for (let k = 0; k < 3; k++) {
stamped(0.240 + k * 0.001, touch(sx + (rng() - 0.5) * W * 0.004, sy + (rng() - 0.5) * W * 0.004,
W * (0.013 + rng() * 0.006), '#f8e8b4', (0.55 + rng() * 0.25) * softK));
}
} else {
stamped(0.235, veil(sx, sy, W * 0.11, 0, W * 0.06, '#c8d2e2', 0.12 * softK, 1));
stamped(0.236, veil(sx, sy, W * 0.06, 0, W * 0.038, '#dde4ee', 0.15 * softK, 1));
for (let k = 0; k < 2; k++) {
stamped(0.240 + k * 0.001, touch(sx, sy, W * (0.009 + rng() * 0.003), '#e8edf4', 0.75 * softK));
}
// stars over the flats
for (let k = 0, nS = 70; k < nS; k++) {
const sxx = rng() * W, syy = rng() * hzY * 0.92;
stamped(0.242 + rng() * 0.010, touch(sxx, syy, Math.max(0.5, W * (0.0008 + rng() * 0.0014)),
rng() < 0.25 ? GLINT : mixHex('#e8edf4', skyMid, rng() * 0.4), (0.3 + rng() * 0.5) * softK));
}
}
}
// -- LIGHT BREAKING THROUGH: dice-rolled shafts onto the land ----------------
const nShaft = night ? 0 : 1 + Math.floor(rng() * 3) + (golden ? 2 : 0);
for (let i = 0; i < nShaft; i++) {
const gapX = sxp + (rng() + rng() - 1) * W * 0.22;
const y0 = H * (0.10 + rng() * 0.22);
const tilt = shDx * (0.12 + rng() * 0.30);
const warm = golden ? '#f2c47e' : dawn ? '#ecc194' : '#f4ead0';
const pS = 0.262 + (i / Math.max(1, nShaft)) * 0.030;
for (let k = 0, nK = 6 + Math.floor(rng() * 3); k < nK; k++) {
const t = k / nK;
const px = gapX + tilt * t * W, py = y0 + (hzY + dykeH * 0.4 - y0) * t;
stamped(pS + 0.022 * t + rng() * 0.002,
veil(px, py, W * (0.04 + rng() * 0.07) * (0.6 + t), tilt * 0.3,
Math.max(3, H * (0.024 + rng() * 0.030)), warm, (0.03 + rng() * 0.04) * softK * (1 - t * 0.3), 0.22));
}
// the pool where the shaft lands
stamped(pS + 0.026, veil(clamp(gapX + tilt * W, W * 0.05, W * 0.95), dykeY + dykeH * 0.4,
W * 0.06, 0, Math.max(2, H * 0.012), warm, 0.06 * softK, 0.25));
}
// -- THE HORIZON: the far polder dissolving -----------------------------------
{
const afFar = aerialFade(0.06);
const farC = grade(mixHex(FLD_M[1], afFar.haze, 0.60), 0.6);
for (let i = 0, n = Math.round(46 * (misty ? 0.7 : 1)); i < n; i++) {
stamped(0.300 + rng() * 0.016, veil(rng() * W, hzY + H * (rng() * 0.012),
W * (0.02 + rng() * 0.05), (rng() - 0.5) * 0.05, Math.max(1.2, H * 0.005),
farC, (0.06 + rng() * 0.09) * softK, 0.3));
}
}
// -- THE POLDER FIELD: muted greens and ochres, broken ------------------------
// the far flats breathe: a whisper of haze eating the first band of field
for (let i = 0; i < 12; i++) {
stamped(0.326 + rng() * 0.006, veil(rng() * W, hzY + (dykeY - hzY) * rng() * 0.16,
W * (0.08 + rng() * 0.18), (rng() - 0.5) * 0.06, Math.max(2, H * (0.010 + rng() * 0.016)),
mixHex(skyHor, FLD_M[1], 0.5), (0.04 + rng() * 0.05) * softK, 0.3));
}
const fieldN = Math.round(780 * covK * clamp((dykeY - hzY) / (0.28 * H), 0.25, 1.15));
for (let i = 0; i < fieldN; i++) {
const yn = Math.pow(rng(), 1.15); // 0 at the horizon .. 1 at the dyke
const x = rng() * W, y = hzY + (dykeY - hzY) * yn;
const ci = rng();
const col = ci < 0.42 ? pickA(FLD_M) : ci < 0.72 ? pickA(FLD_L) : pickA(FLD_D);
const len = W * (0.015 + 0.045 * yn) * (0.5 + rng());
stamped(0.330 + 0.105 * yn + rng() * 0.004, smear(x, y, len, (rng() - 0.5) * 0.14,
Math.max(1.2, H * 0.005 * (0.6 + yn * 1.4)), col, (0.13 + rng() * 0.15) * softK, 0.4));
// the broken-colour counterpoint: a round touch in a neighbouring tone
if (rng() < 0.45) {
stamped(0.330 + 0.105 * yn + rng() * 0.004,
touch(x + (rng() - 0.5) * len, y + (rng() - 0.5) * H * 0.008,
Math.max(1.0, H * 0.006 * (0.6 + yn)), pickA(FLD_L), (0.10 + rng() * 0.12) * softK));
}
}
// the pooled light: a warm patch on the field where the light falls
if (!night && cond.sunStrength > 0.3) {
const poolX = sxp + shDx * W * (0.05 + rng() * 0.15);
for (let i = 0, n = Math.round(40 * softK); i < n; i++) {
const yn = 0.15 + rng() * 0.8;
stamped(0.438 + rng() * 0.014, smear(clamp(poolX + (rng() - 0.5) * W * 0.16, 0, W),
hzY + (dykeY - hzY) * yn, W * (0.02 + rng() * 0.05) * (0.5 + yn), (rng() - 0.5) * 0.12,
Math.max(1.2, H * 0.005 * (0.6 + yn)), mixHex(FLD_L[0], golden ? '#f0c880' : '#f4ecd0', 0.45),
(0.06 + rng() * 0.08) * softK, 0.4));
}
}
// cloud shadows racing the fields: broad cool darker bands
if (!night) {
for (let i = 0, n = 2 + Math.floor(rng() * 3); i < n; i++) {
const cshY = hzY + (dykeY - hzY) * (0.15 + rng() * 0.7);
const cshX = rng() * W;
const pC = 0.442 + (i / 5) * 0.014;
for (let k = 0, nK = 8 + Math.floor(rng() * 6); k < nK; k++) {
const t = (k / nK) * 2 - 1;
stamped(pC + 0.010 * (k / nK) + rng() * 0.001,
smear(cshX + t * W * (0.10 + rng() * 0.12), cshY + (rng() - 0.5) * H * 0.012,
W * (0.03 + rng() * 0.06), (rng() - 0.5) * 0.08, Math.max(1.6, H * 0.010),
mixHex(FLD_D[1], '#5c6470', 0.35), (0.07 + rng() * 0.09) * softK, 0.3));
}
}
}
// -- THE DRAINAGE DITCHES: thin silver lines across the flats -----------------
if (ditchesOn) {
const ditchC = night ? mixHex(skyHor, '#c8d4e6', 0.25) : mixHex(skyHor, '#dfe4e2', 0.25);
const nD = 3 + Math.floor(rng() * 4);
for (let d = 0; d < nD; d++) {
const yF = 0.12 + (d / nD) * 0.78 + rng() * 0.06;
const dy = hzY + (dykeY - hzY) * clamp(yF, 0.05, 0.95);
const glints = rng() < 0.6; // some ditches reflect, some stay dark
const nG = 8 + Math.floor(rng() * 8);
const pD = 0.470 + (d / nD) * 0.024;
for (let k = 0; k < nG; k++) {
const t = k / (nG - 1);
const gx = W * (-0.02 + t * 1.04) + (rng() - 0.5) * W * 0.02;
const gy = dy + Math.sin(t * 9 + d) * H * 0.003;
stamped(pD + 0.020 * t + rng() * 0.001,
smear(gx, gy, W * (0.015 + rng() * 0.035) * (0.6 + yF), (rng() - 0.5) * 0.06,
Math.max(0.9, H * 0.003 * (0.7 + yF)),
glints ? ditchC : mixHex(FLD_D[1], deepC, 0.4),
(glints ? 0.12 : 0.08) * softK, 0.25));
}
}
// one ditch running toward us, converging on the vanishing point
if (rng() < 0.6) {
const sideD = rng() < 0.5 ? -1 : 1;
const vX = W * 0.5, vY = hzY;
const bx = vX + sideD * W * (0.28 + rng() * 0.2);
for (let k = 0, nK = 12; k < nK; k++) {
const t = k / (nK - 1);
const gx = bx + (vX - bx) * Math.pow(t, 1.4);
const gy = dykeY + (vY - dykeY) * Math.pow(t, 1.6);
stamped(0.498 + 0.012 * t + rng() * 0.001,
smear(gx, gy, W * (0.008 + 0.03 * Math.pow(1 - t, 1.2)), sideD * 0.9,
Math.max(0.8, H * 0.0025 * (0.7 + (1 - t) * 1.6)), ditchC, 0.11 * softK, 0.3));
}
}
}
// -- THE DYKE: the low dark horizontal, grass-topped ---------------------------
{
const nDyke = Math.round(230 * covK);
for (let i = 0; i < nDyke; i++) {
const t = rng();
const x = rng() * W;
const y = dykeY + dykeH * (rng() < 0.65 ? Math.pow(rng(), 0.7) : rng());
const col = y < dykeY + dykeH * 0.28 && rng() < 0.55
? pickA(DYKE_GRASS) : pickA(DYKE);
stamped(0.516 + 0.030 * t + rng() * 0.003,
smear(x, y, W * (0.02 + rng() * 0.055), (rng() - 0.5) * 0.07,
Math.max(1.4, dykeH * (0.22 + rng() * 0.3)), col, (0.20 + rng() * 0.16) * softK, 0.4));
}
// grass blades catching the light along the crest
for (let i = 0, n = Math.round(90 * covK); i < n; i++) {
const gx = rng() * W, gy = dykeY - H * 0.002 - rng() * dykeH * 0.20;
stamped(0.548 + rng() * 0.010,
touch(gx, gy, Math.max(0.9, H * 0.004), pickA(DYKE_GRASS), (0.22 + rng() * 0.18) * softK, 0, 1.4));
}
// the dyke's shadow falling forward
for (let i = 0, n = 22; i < n; i++) {
stamped(0.560 + rng() * 0.008,
smear(rng() * W, dykeY + dykeH * (1.02 + rng() * 0.35), W * (0.03 + rng() * 0.06),
(rng() - 0.5) * 0.06, Math.max(1.2, H * 0.005),
mixHex(FLD_D[0], '#20241f', 0.4), (0.10 + rng() * 0.10) * softK, 0.35));
}
}
// -- CATTLE: 0-5 dots of life on the crest --------------------------------------
for (let s = 0; s < nCattle; s++) {
const cx = W * (0.05 + rng() * 0.90);
const onCrest = rng() < 0.6;
const cy = onCrest ? dykeY - H * 0.002 + (rng() - 0.3) * dykeH * 0.2
: hzY + (dykeY - hzY) * (0.35 + rng() * 0.55);
const cs = Math.max(3.0, W * 0.015 * (0.7 + rng() * 0.6)) * (onCrest ? 1 : 0.8);
const sheep = rng() < 0.3;
const pC = 0.572 + s * 0.004;
const bodyC = sheep ? grade('#d8d4c4', 0.9) : grade(pickA(['#3a3028', '#2e2822', '#4a3a2c', '#584838']), 0.9);
stamped(pC, smear(cx, cy, cs * 1.5, 0, cs * 0.5, bodyC, 0.80 * softK, 0.55)); // body
stamped(pC + 0.0008, touch(cx + (rng() < 0.5 ? -1 : 1) * cs * 0.75, cy - cs * 0.18,
cs * 0.32, bodyC, 0.65 * softK)); // head
stamped(pC + 0.0016, touch(cx, cy + cs * 0.32, cs * 0.6, mixHex(bodyC, '#14120e', 0.35),
0.4 * softK, 0, 0.4)); // shadow
if (!sheep) for (let l = 0; l < 2; l++) {
stamped(pC + 0.0022 + l * 0.0004, (cc, jr) => rigger(cc, jr, {
x: cx + (l ? cs * 0.4 : -cs * 0.3), y: cy + cs * 0.2, length: cs * 0.45,
color: bodyC, alpha: 0.5 * softK, angle: Math.PI / 2, width: Math.max(0.5, cs * 0.1), bow: 0,
}));
}
}
// -- THE HALO: the sky holds its breath behind the mill --------------------------
for (let i = 0; i < 6; i++) {
stamped(0.596 + rng() * 0.004,
veil(mx + (rng() - 0.5) * wB * 2, millTop + millH * (0.1 + rng() * 0.5),
wB * (2.0 + rng()), 0, millH * (0.10 + rng() * 0.10),
mixHex(skyMid, skyHor, 0.5), 0.05 * softK, 0.4));
}
// -- THE MILL: truncated cone, cap, stage, and the great cross of sails ----------
{
const pM = 0.606;
// the cone itself: one filled silhouette, so the form closes in every hand;
// texture and light ride on top of it, stroke by stroke
const coneFill = (cc) => {
const c = mixHex(MILL_SIL, night ? '#10141c' : '#191614', 0.30);
const n = parseInt(c.slice(1), 16);
cc.fillStyle = `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},0.88)`;
cc.beginPath();
cc.moveTo(mx - wB * 0.5, dykeY + 3);
cc.lineTo(mx - wT * 0.5, millTop + wT * 0.30);
cc.lineTo(mx + wT * 0.5, millTop + wT * 0.30);
cc.lineTo(mx + wB * 0.5, dykeY + 3);
cc.closePath();
cc.fill();
// the cap, a thrown dome over the cone's neck
cc.beginPath();
cc.ellipse(mx, millTop + wT * 0.18, wT * 0.62, wT * 0.30, 0, Math.PI, 0);
cc.closePath();
cc.fill();
};
stamped(pM, coneFill);
const xoffAt = (t) => Math.sin(t * 2.2) * wB * 0.012; // a breath of wonk
const nRow = 14;
for (let k = 0; k < nRow; k++) {
const t = k / (nRow - 1); // 0 at the base, 1 under the cap
const y = dykeY - millH * t * 0.96;
const hw = (wB + (wT - wB) * t) * 0.5;
const xoff = xoffAt(t);
const col = mixHex(MILL_SIL, night ? '#10141c' : '#191614', 0.35 - 0.20 * t); // grounded dark
stamped(pM + 0.004 + 0.024 * t + rng() * 0.002,
smear(mx + xoff, y, hw * 1.7, 0, Math.max(2.4, (millH / nRow) * 1.7), col,
(0.40 + rng() * 0.16) * softK, 0.55));
// the transparent shadow side: Rembrandt dark with colour alive in it,
// kept inside the cone so no hand can make it bulge
stamped(pM + 0.005 + 0.024 * t,
smear(mx + xoff + shDx * hw * 0.40, y + H * 0.002, (millH / nRow) * 1.1, Math.PI / 2,
Math.max(1.2, hw * 0.34),
mixHex(col, golden || dawn ? '#c07840' : '#5c4a58', 0.32), 0.26 * softK, 1));
// the lit rim lives in its own continuous chain below, hugging the taper
}
// the lit rim: one continuous hair of light down the sunward edge, laid
// plain so no hand may bend it into the body
if (!night) {
for (let k = 0; k < 8; k++) {
const t = 0.16 + (k / 7) * 0.66;
const hw = (wB + (wT - wB) * t) * 0.5;
const y = dykeY - millH * t * 0.96;
stamped(pM + 0.032 + 0.020 * (k / 7),
veil(mx + xoffAt(t) - shDx * hw * 0.90, y, (millH / 8) * 1.6, Math.PI / 2,
0, mixHex(GLINT, '#f0c880', rng() * 0.4),
(0.20 + rng() * 0.10) * softK, 0.30));
}
}
// the gallery stage
if (galleryOn) {
stamped(0.646, veil(mx, millTop + millH * 0.44, wT * 1.5, 0, Math.max(1.2, millH * 0.012),
mixHex(MILL_SIL, '#c8b890', 0.25), 0.35 * softK, 0.3));
}
// the lit window (night) or a dark door (day)
if (night || dusk) {
stamped(0.648, touch(mx + (rng() - 0.5) * wT * 0.4, millTop + millH * 0.55,
Math.max(1.4, wT * 0.14), LANTERN, 0.95 * softK));
stamped(0.6484, veil(mx, millTop + millH * 0.55, wT * 0.9, 0, wT * 0.4, LANTERN, 0.12 * softK, 1));
}
// THE SAILS: four straight strokes at the dice-rolled angle
const nArm = 4;
const sailC = mixHex(MILL_SIL, '#14110e', 0.25);
const clothC = mixHex(MILL_SIL, night ? '#8a94ac' : GLINT, night ? 0.15 : 0.22);
const ghostOff = working ? 0.07 + windK * 0.13 : 0; // mid-turn: the ghost edge
for (let a = 0; a < nArm; a++) {
const ang = sailAngle + (a * TAU) / 4;
const pA = 0.654 + a * 0.006;
// the arm itself: one straight stroke the hand may not bend — the great
// sail crosses the sky like the masts do, on the rigger
stamped(pA, (cc, jr) => rigger(cc, jr, {
x: hub.x, y: hub.y, length: sailL, color: sailC, alpha: 0.72 * softK,
angle: ang, width: Math.max(1.6, wB * 0.085), bow: (rng() - 0.5) * sailL * 0.008,
}));
// the root: the hand may paint the mass where the arms meet
const nS = 3;
for (let k = 0; k < nS; k++) {
const t = 0.06 + k * 0.07;
stamped(pA + 0.004 * (k + 1) + rng() * 0.0008,
smear(hub.x + Math.cos(ang) * sailL * t, hub.y + Math.sin(ang) * sailL * t,
sailL * 0.07, ang, Math.max(1.6, wB * 0.12 * (1 - t)),
sailC, (0.55 - k * 0.10) * softK, 0.5));
}
// the stock: one hair of a stroke through the whole arm
stamped(pA + 0.005, (cc, jr) => rigger(cc, jr, {
x: hub.x, y: hub.y, length: sailL, color: mixHex(sailC, '#0c0a08', 0.3), alpha: 0.5 * softK,
angle: ang, width: Math.max(0.5, wB * 0.02), bow: (rng() - 0.5) * sailL * 0.01,
}));
// the sailcloth: a pale plane hugging the leading side of the arm —
// laid as plain veils so the cloth reads in every hand
if (a % 2 === 0 || windK > 0.5) {
const side = a < 2 ? 1 : -1; // each leading arm carries its cloth one way
const perp = ang + Math.PI / 2;
const off = wB * 0.09 * side;
for (let k = 0; k < 3; k++) {
const t = 0.28 + k * 0.17;
stamped(pA + 0.0052 + 0.001 * k,
veil(hub.x + Math.cos(ang) * sailL * t + Math.cos(perp) * off,
hub.y + Math.sin(ang) * sailL * t + Math.sin(perp) * off,
sailL * 0.17, ang, Math.max(2.0, sailL * 0.062), clothC, 0.34 * softK, 0.35));
}
}
// the ghost edge: the mill working, sails caught between beats
if (working) {
const ga = ang + (windK > 0.5 ? 1 : -1) * ghostOff;
for (let k = 0; k < 2; k++) {
const t = 0.35 + k * 0.22;
stamped(pA + 0.0056 + 0.001 * k,
smear(hub.x + Math.cos(ga) * sailL * t, hub.y + Math.sin(ga) * sailL * t,
sailL * 0.15, ga, Math.max(1.0, wB * 0.045), sailC, 0.07 * softK, 0.4));
}
}
}
// the hub
stamped(0.680, touch(hub.x, hub.y, Math.max(1.6, wB * 0.14), sailC, 0.8 * softK));
// the mill's long shadow across the field, fleeing the light
const shLen = millH * 0.9 * longK;
for (let k = 0; k < 6; k++) {
const t = (k + 0.5) / 6;
stamped(0.684 + 0.010 * t + rng() * 0.001,
smear(mx + shDx * shLen * t, dykeY + dykeH * 0.5 + H * 0.010 * t
+ (dykeY - hzY) * 0.12 * t,
wB * 0.5 * (1.4 - t * 0.5), shDx > 0 ? 0.10 : Math.PI - 0.10,
Math.max(1.4, millH * 0.014 * (1.2 - t * 0.4)),
mixHex(FLD_D[1], '#1e1c18', 0.50), (0.22 - t * 0.12) * softK, 0.35));
}
// the mill doubled in the canal, if the canal runs near
if (canalOn) {
const cyw = canalYF * H;
for (let k = 0; k < 5; k++) {
stamped(0.696 + 0.004 * k,
veil(mx + Math.sin(cyw * 0.05 + k) * W * 0.006 + (rng() - 0.5) * wB * 0.3,
cyw + canalH * (0.15 + k * 0.18), wB * 0.5 * (1 - k * 0.12), Math.PI / 2,
Math.max(0.8, millH * 0.012 * (1 - k * 0.15)), mixHex(MILL_SIL, WAT[2], 0.55),
0.12 * (1 - k * 0.14) * softK, 0.3));
}
}
}
// -- DISTANT MILLS: grey towers on the far flats ----------------------------------
for (let d = 0; d < nDistant; d++) {
const af = aerialFade(0.10 + rng() * 0.10);
let dx = W * (0.08 + rng() * 0.84);
if (Math.abs(dx - mx) < W * 0.12) dx = mx + (dx < mx ? -1 : 1) * W * 0.16;
const dh = H * (0.055 + rng() * 0.045);
const dBase = hzY + (dykeY - hzY) * (0.08 + rng() * 0.22);
const dTop = dBase - dh;
const dw = dh * 0.22;
const dc = grade(mixHex(MILL_SIL, af.haze, 0.45 + af.desat), 0.8);
const pD = 0.706 + d * 0.012;
for (let k = 0; k < 5; k++) {
const t = k / 4;
stamped(pD + 0.006 * t, touch(dx, dBase - dh * t, dw * (1 - t * 0.35), dc,
(0.5 - t * 0.1) * af.alpha * softK, 0, 1.25));
}
const dSail = rng() * TAU;
for (let a = 0; a < 4; a++) {
const ang = dSail + (a * TAU) / 4;
stamped(pD + 0.008 + a * 0.0004,
smear(dx + Math.cos(ang) * dh * 0.30, dTop + dh * 0.08 + Math.sin(ang) * dh * 0.30,
dh * 0.42, ang, Math.max(0.8, dh * 0.05), mixHex(dc, af.haze, 0.2),
0.4 * af.alpha * softK, 0.5));
}
if (misty) stamped(pD + 0.010, veil(dx, dTop + dh * 0.3, dw * 2.4, 0, dh * 0.4,
'#e8ece8', 0.14 * softK, 0.4)); // the body dissolving, sails clear
}
// -- THE CANAL: the sky laid flat, broken horizontally -----------------------------
if (canalOn) {
const cyw = canalYF * H;
const nCan = Math.round(210 * covK);
for (let i = 0; i < nCan; i++) {
const yn = rng();
const x = (rng() * 1.06 - 0.03) * W;
const y = cyw + canalH * yn + (rng() - 0.5) * H * 0.004;
const ci = clamp(Math.floor(yn * 3 + rng() * 1.4), 0, 3);
const len = W * (0.02 + rng() * 0.06);
stamped(0.742 + 0.026 * yn + rng() * 0.003,
smear(x, y, len, (rng() - 0.5) * 0.06, Math.max(1.0, canalH * (0.16 + rng() * 0.2)),
WAT[ci], (0.14 + rng() * 0.16) * softK, 0.3));
}
// the banks breathe: water and land bleed into each other at both edges
for (let i = 0, n = 14; i < n; i++) {
stamped(0.740 + rng() * 0.004, veil(rng() * W, cyw - H * 0.002 + (rng() - 0.6) * H * 0.006,
W * (0.03 + rng() * 0.07), (rng() - 0.5) * 0.06, Math.max(1.5, H * 0.007),
mixHex(skyMid, FLD_M[1], 0.5), 0.10 * softK, 0.25));
}
// the near bank's dark hold
for (let i = 0, n = 16; i < n; i++) {
stamped(0.772 + rng() * 0.006,
smear(rng() * W, cyw + canalH * (1.0 + rng() * 0.25), W * (0.03 + rng() * 0.06),
(rng() - 0.5) * 0.05, Math.max(1.2, H * 0.005), mixHex(FLD_D[0], '#20241f', 0.4),
0.10 * softK, 0.3));
}
// the light column doubling in the water
{
const colX = sxp;
for (let i = 0, n = 26; i < n; i++) {
const yn = rng();
stamped(0.780 + 0.008 * yn + rng() * 0.002,
smear(colX + (rng() + rng() - 1) * W * 0.03, cyw + canalH * yn,
W * (0.006 + rng() * 0.018), (rng() - 0.5) * 0.1, Math.max(0.8, canalH * 0.10),
mixHex(GLINT, WAT[1], rng() * 0.4), (0.14 + rng() * 0.16) * softK, 0.3));
}
}
// a small boat, dice-gated: a tjalk lying quiet
if (boatOn) {
const bx = W * (0.12 + rng() * 0.76);
const by = cyw + canalH * (0.3 + rng() * 0.4);
const bl = W * (0.035 + rng() * 0.03);
const pB = 0.792;
const bTone = grade(pickA(['#2c3236', '#252e3c', '#3a3034']), 0.9);
for (let k = 0; k < 5; k++) {
const t = (k / 4) * 2 - 1;
stamped(pB + 0.002 * k,
veil(bx + t * bl * 0.5, by + bl * 0.03 * Math.pow(Math.max(0, 1 - t * t), 0.6),
bl * 0.16, 0, Math.max(1.0, bl * 0.10), bTone, 0.55 * softK, 0.5));
}
stamped(pB + 0.011, smear(bx, by - bl * 0.05, bl * 0.8, 0, Math.max(0.7, bl * 0.05),
mixHex('#c4b696', bTone, 0.3), 0.4 * softK, 0.5));
stamped(pB + 0.013, veil(bx, by - bl * 0.14, bl * 0.34, 0, Math.max(1.0, bl * 0.09),
bTone, 0.5 * softK, 0.6)); // the little cabin
stamped(pB + 0.015, veil(bx, by + bl * 0.16, bl * 0.8, 0, Math.max(0.8, bl * 0.08),
mixHex(WAT[2], bTone, 0.35), 0.14 * softK, 0.3));
if (night && rng() < 0.6) {
stamped(pB + 0.017, touch(bx + bl * 0.1, by - bl * 0.16, Math.max(0.9, bl * 0.06),
LANTERN, 0.8 * softK));
}
}
}
// -- THE HOUR'S SIGNATURES ----------------------------------------------------
if (dawn) { // mist lying on the polder, the mills grey towers in it
for (let k = 0; k < 26; k++) {
const my = hzY + (dykeY - hzY) * (0.1 + rng() * 0.75);
stamped(0.808 + rng() * 0.012, veil(rng() * W, my, W * (0.06 + rng() * 0.16),
(rng() - 0.5) * 0.08, Math.max(2, H * (0.014 + rng() * 0.026)),
mixHex('#e8ece8', skyHor, rng() * 0.5), (0.05 + rng() * 0.07) * softK, 0.25));
}
if (mistMill) {
stamped(0.812, veil(mx, millTop + millH * 0.55, wB * 2.6, 0, millH * 0.35,
'#e8ece8', 0.16 * softK, 0.35)); // the body in mist, sails above it
}
}
if (golden) { // the shafts pooling on the dyke — the money shot
for (let k = 0; k < 20; k++) {
stamped(0.812 + rng() * 0.012, veil(rng() * W, dykeY + (rng() - 0.3) * dykeH * 0.8,
W * (0.04 + rng() * 0.09), (rng() - 0.5) * 0.10, Math.max(2, H * 0.010),
'#f0c880', (0.05 + rng() * 0.07) * softK, 0.3));
}
// the sails rim-lit against the light
for (let a = 0; a < 4; a++) {
const ang = sailAngle + (a * TAU) / 4;
stamped(0.820 + a * 0.001,
veil(hub.x + Math.cos(ang) * sailL * 0.6, hub.y + Math.sin(ang) * sailL * 0.6,
sailL * 0.45, ang, Math.max(1.0, sailL * 0.03), '#f8e0a8', 0.16 * softK, 0.3));
}
}
if (misty) { // mills dissolving; the far flats go first
for (let k = 0; k < 22; k++) {
stamped(0.816 + rng() * 0.012, veil(rng() * W, hzY - H * 0.02 + rng() * H * 0.10,
W * (0.08 + rng() * 0.20), (rng() - 0.5) * 0.08, Math.max(2, H * (0.02 + rng() * 0.04)),
'#e8ece8', (0.05 + rng() * 0.07) * softK));
}
}
if (rain) { // wet light: sheen on field and dyke
for (let k = 0; k < 18; k++) {
const yn = rng();
stamped(0.822 + rng() * 0.010, smear(rng() * W, hzY + (dykeY - hzY) * yn,
W * (0.02 + rng() * 0.05) * (0.5 + yn), (rng() - 0.5) * 0.08,
Math.max(1.0, H * 0.004 * (0.5 + yn)), '#d8e4e6', (0.07 + rng() * 0.07) * softK, 0.25));
}
}
if (snow) { // the flats whitened, the ditches dark
for (let k = 0; k < 24; k++) {
const yn = Math.pow(rng(), 1.1);
stamped(0.824 + rng() * 0.010, smear(rng() * W, hzY + (dykeY - hzY) * yn,
W * (0.03 + rng() * 0.06) * (0.5 + yn), (rng() - 0.5) * 0.08,
Math.max(1.6, H * 0.008 * (0.6 + yn)), mixHex('#eef2f4', FLD_L[0], rng() * 0.3),
(0.08 + rng() * 0.10) * softK, 0.35));
}
}
// -- the unifying breath --------------------------------------------------------
for (let i = 0; i < 18; i++) {
const y = rng() * H;
const col = y < hzY ? mixHex(sg[1].color, sg[2].color, rng() * 0.5)
: y < dykeY ? pickA(FLD_M)
: y < dykeY + dykeH ? pickA(DYKE) : pickA(FLD_D);
stamped(0.842 + rng() * 0.014, 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 * (hand.mode === 'impasto' ? 0.45 : 1)));
}
// -- the last notes: the brightest accents -----------------------------------------
for (let i = 0, n = Math.round(70 * covK); i < n; i++) {
const where = rng();
let gx, gy;
if (canalOn && where < 0.45) {
gx = rng() * W;
gy = canalYF * H + canalH * rng();
} else if (ditchesOn && where < 0.7) {
gx = rng() * W;
gy = hzY + (dykeY - hzY) * rng();
} else {
gx = rng() * W;
gy = dykeY - H * 0.004 + rng() * dykeH * 1.2;
}
stamped(0.862 + 0.10 * (i / Math.max(1, n)) + rng() * 0.003,
touch(gx, gy, Math.max(0.7, W * (0.0012 + rng() * 0.0026)),
rng() < 0.3 ? GLINT : mixHex(GLINT, skyHor, 0.3),
(0.24 + rng() * 0.28) * softK * (night ? 0.35 : 1), 0, 0.4));
}
// the sail tips catching the last light
if (!night) {
for (let a = 0; a < 4; a++) {
const ang = sailAngle + (a * TAU) / 4;
stamped(0.966 + a * 0.002,
touch(hub.x + Math.cos(ang) * sailL * 0.96, hub.y + Math.sin(ang) * sailL * 0.96,
Math.max(0.8, wB * 0.05), mixHex(GLINT, '#f0c880', 0.3), 0.30 * softK, 0, 0.6));
}
}
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 port, marée basse — the atelier's estuary harbour, after Monet's fishing
// boats at Honfleur and his Seine-estuary shores. The TIDE is the master dice:
// at low tide (the canonical state) the flats breathe out — flat-bottomed
// boats heel over on the exposed mud, dice-rolled angles, masts leaning like
// a forest of marram reeds, wet pools holding slivers of sky; the water
// persists as a band on the far side with the moored fleet beyond. At high
// tide the mud drowns, the boats float level and their hulls tremble in the
// water; rising or falling, the waterline halts halfway and the fleet is
// half-lifted. Above: a wide estuary sky — layered cloud banks, gulls, the
// far shore hazed to a strip with its church spire or lighthouse. Sparse
// harbour life, dice-gated: lobster pots stacked on the mud, one or two
// figures working between the hulls, a rowing dinghy.
//
// CONDITIONS. makeConditions rolls hour/season/weather/hand as everywhere;
// the scene then rolls its own TIDE from the same dedicated stream (after
// every other consumer, so nothing upstream is perturbed): low (canonical,
// ~42%), rising / falling (intermediate waterline), high (~20%). The hour
// grades everything: dawn lays rose-gold on the wet mud and drags long hull
// shadows; noon is bright with hard glints; goldenHour ambers the flats and
// silhouettes the masts; dusk and night sink the harbour into deep blue and
// light the lanterns, doubled in the pools and the water. Weather: mist
// ghosts the far shore and fades the mast tops; rain streaks and sheens
// everything; a heavy sky at high tide makes rough water — short steep
// waves, pale caps, the boats straining on their rigging.
//
// HOUSE RULES. Structure (hulls, mud, water) rides veil() — translucent soft
// slabs layered to near-opaque — so the forms close in EVERY hand; the hand
// (brushes.handFor/handDab) realizes the texture touches its own way; masts,
// rigging and gulls ride the rigger, which no hand may straighten. bleed() is
// spent only on a capped handful of hero glows under the wet hand.
//
// Contract: identical to scenes/verger.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; nothing consumes randomness at draw time.
// TONAL SPINE: sky palest > mud/water mid > hulls and figures darkest >
// glints and lanterns brightest.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import { handFor, handDab, dab, roundDab, bleed, rigger, aerialFade } from '../brushes.js';
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
let strokes = null;
const TIDE_FR = {
low: 'marée basse', rising: 'marée montante',
falling: 'marée descendante', high: 'marée haute',
};
// the scene's conditions: makeConditions passthrough, then the tide die —
// cast after every other consumer so forcing an axis never moves the tide
const rollCond = (rng, constraints) => {
const c = makeConditions(rng, constraints);
const u = rng();
c.tide = u < 0.42 ? 'low' : u < 0.60 ? 'rising' : u < 0.80 ? 'falling' : 'high';
return c;
};
export default {
id: 'port',
title: 'Le port, marée basse',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => rollCond(rng, constraints),
subtitle: (c) => `Le port, ${TIDE_FR[c.tide] || 'marée basse'}, ${subtitleFr(c)}`,
init(ctx, W, H, rng, conditions) {
const cond = conditions || rollCond(mulberry32(1886), null);
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', noon = tod === 'noon';
const misty = weather === 'mist', rain = weather === 'rain';
const heavy = weather === 'overcast' || weather === 'rain';
const rough = heavy && cond.tide === 'high'; // the straining fleet
const tideT = cond.tide === 'low' ? 0 : cond.tide === 'high' ? 1 : 0.5;
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 pickA = (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 sits ------
const hand = handFor(cond.technique);
const inkK = hand.alphaCurve === 'soft' ? 1.9 : hand.alphaCurve === 'bleed' ? 1.5 : 1;
const sizeK = hand.alphaCurve === 'soft' || hand.alphaCurve === 'bleed' ? 0.72 : 1;
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean;
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(2.0, Math.max(1, 1 / Math.max(0.25, slMean * kMean)));
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);
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));
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, handW(w), c, a, ang, squash);
handDab(cc, jr, hand, {
x, y, len: 0, ang, w: handW(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, handL(len), handW(w), c, a, ang);
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(w), c, a, squash,
plain: (g) => {
g.strokeStyle = c;
g.globalAlpha = clamp(a, 0, 1);
g.lineWidth = Math.max(0.8, w);
g.lineCap = 'round';
const dx = (Math.cos(ang) * len) / 2, dy = (Math.sin(ang) * len) / 2;
g.beginPath();
g.moveTo(x - dx, y - dy);
g.lineTo(x + dx, y + dy);
g.stroke();
g.globalAlpha = 1;
},
});
};
// the soft air — mist, halos, reflections — stays translucent in every hand
const veil = (x, y, len, ang, w, c, a, squash = 0.5) => (cc) => {
const half = Math.max(1, len * 0.5);
const ab = hand.alphaBase;
dab(cc, x, y, half, c, a * ab, ang, clamp(squash, 0.06, 8));
dab(cc, x, y, half * 1.45, c, a * 0.34 * ab, ang, clamp(squash * 0.92, 0.06, 8));
};
// HERO bleed budget: only the mouille hand spends it (≤5 calls)
let heroBudget = isWet && (dusk || night || golden) ? 5 : 0;
const heroGlow = (x, y, r, c, a) => {
if (heroBudget <= 0) return null;
heroBudget--;
return (cc) => bleed(cc, clamp(r * 0.18, 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);
});
};
// -- palettes -------------------------------------------------------------
const softK = { clear: 1, mist: 0.88, overcast: 0.78, rain: 0.75, snow: 0.85 }[weather] || 1;
const sg = skyGradient(cond);
const skyMid = sg[1].color, skyHor = sg[2].color;
const hzC = sg[3].color, deepC = sg[4].color;
// the hour grades everything below the sky
const grade = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#232a3c', 0.42 * k);
else if (dusk) o = mixHex(o, '#4c3e52', 0.20 * k);
else if (dawn) o = mixHex(o, '#d09880', 0.14 * k);
else if (golden) o = mixHex(o, '#d8a860', 0.17 * k);
else if (noon) o = mixHex(o, '#f2ead8', 0.05 * k);
if (misty) o = mixHex(o, '#e2e6e2', 0.08 * k);
if (heavy) o = mixHex(o, '#9aa0a2', 0.10 * k);
return o;
};
// the mud: broken ochre / blue-grey, warm-grey band of the estuary
let MUD_L = ['#b09a6e', '#a89278', '#bca47e'];
let MUD_M = ['#8e8468', '#827e7c', '#96885e'];
let MUD_D = ['#6e6a60', '#626066', '#58565c'];
if (season === 'winter') {
const f = (h) => mixHex(h, '#c0ccd4', 0.20);
MUD_L = MUD_L.map(f); MUD_M = MUD_M.map(f); MUD_D = MUD_D.map(f);
} else if (season === 'summer') {
const f = (h) => mixHex(h, '#c8a86a', 0.07);
MUD_L = MUD_L.map(f); MUD_M = MUD_M.map(f); MUD_D = MUD_D.map(f);
}
if (night) { // the flats sink into steel blue; nothing warm survives
const f = (h) => mixHex(h, '#2c3444', 0.55);
MUD_L = MUD_L.map(f); MUD_M = MUD_M.map(f); MUD_D = MUD_D.map(f);
}
MUD_L = MUD_L.map((h) => grade(h)); MUD_M = MUD_M.map((h) => grade(h));
MUD_D = MUD_D.map((h) => grade(h));
// the water band: sky laid down flat, deepening toward its near edge
const WAT = [hzC, mixHex(hzC, deepC, 0.35), mixHex(hzC, deepC, 0.70), deepC]
.map((h) => (night ? mixHex(h, '#141c2c', 0.18) : h));
// the boats: charcoal, deep blue, a rare rust-red; pale gunwales
const HULLS = ['#2c3236', '#252e3c', '#3a3034'];
const RUST = '#7c4028';
const gunC0 = night ? mixHex('#c4b696', '#4a4c58', 0.45) : '#c4b696';
const mastC = night ? '#181820' : dusk || golden ? '#221e22' : '#2a2a2e';
const GLINT = night ? '#dce4f2' : '#fff4d8';
const LANTERN = '#f2c268';
const FIG_D = night ? mixHex('#2e2c34', '#181a26', 0.3) : '#2e2c34';
const FIG_S = '#3c3a44';
// -- geometry: horizon, the tide line, the light ---------------------------
const hzF = cond.horizonY;
const hzY = H * hzF;
const wlY = hzY + (0.10 + 0.76 * tideT) * (H - hzY); // the tide line
const mudH = H - wlY;
const sunLeft = cond.sunX < 0.5;
const shDx = sunLeft ? 1 : -1; // shadows flee the light
const longK = golden ? 2.2 : (dawn || dusk) ? 1.4 : night ? 0.8 : noon ? 0.5 : 0.7;
// -- THE PRIME: sky, water band, mud — painted once, straight -------------
{
const g = ctx.createLinearGradient(0, 0, 0, hzY + 1);
g.addColorStop(0, sg[0].color);
g.addColorStop(Math.min(0.97, hzF * 0.6), sg[1].color);
g.addColorStop(1, sg[2].color);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, hzY + 1);
const g2 = ctx.createLinearGradient(0, hzY, 0, wlY + 1);
g2.addColorStop(0, mixHex(skyHor, hzC, 0.75));
g2.addColorStop(1, deepC);
ctx.fillStyle = g2;
ctx.fillRect(0, hzY, W, wlY - hzY + 1);
const g3 = ctx.createLinearGradient(0, wlY, 0, H);
g3.addColorStop(0, mixHex(MUD_D[1], deepC, 0.35)); // wet mud at the tide line
g3.addColorStop(0.5, MUD_M[0]);
g3.addColorStop(1, MUD_L[0]);
ctx.fillStyle = g3;
ctx.fillRect(0, wlY - 1, W, H - wlY + 1);
}
// -- THE SKY: wide, low-lit, layered banks ---------------------------------
for (let i = 0, n = Math.round(230 * covK); i < n; i++) {
const y = Math.pow(rng(), 1.15) * hzY;
const col = y < hzY * 0.45
? mixHex(sg[0].color, sg[1].color, rng())
: mixHex(sg[1].color, sg[2].color, rng());
stamped(0.010 + 0.052 * (y / Math.max(1, hzY)) + rng() * 0.005,
veil(rng() * W, y, W * (0.035 + rng() * 0.09), (rng() - 0.5) * 0.25,
Math.max(2, H * (0.014 + rng() * 0.026)), col, (0.06 + rng() * 0.10) * softK));
}
// cloud banks: long layered slabs with a darker belly
for (let i = 0, nC = 2 + Math.floor(rng() * 3); i < nC; i++) {
const cy0 = H * (0.06 + rng() * 0.26), cx0 = rng() * W, bw = W * (0.18 + rng() * 0.30);
const nW = 9 + Math.floor(rng() * 8);
const base = mixHex(skyMid, night ? '#8a94ac' : '#ffffff', night ? 0.10 : 0.30 + rng() * 0.15);
const belly = mixHex(base, '#8a8494', 0.26);
for (let k = 0; k < nW; k++) {
const t = k / nW;
stamped(0.070 + 0.034 * (i / nC) + 0.004 * t + rng() * 0.002,
veil(cx0 + (t - 0.5) * bw + (rng() - 0.5) * W * 0.03,
cy0 + Math.sin(t * Math.PI) * H * 0.014 + (rng() - 0.5) * H * 0.012,
W * (0.025 + rng() * 0.055), (rng() - 0.5) * 0.07,
Math.max(1.5, H * (0.006 + rng() * 0.013)),
rng() < 0.3 ? belly : base, (0.09 + rng() * 0.13) * softK));
}
}
// sun or moon
{
const sx = cond.sunX * W, sy = cond.sunY * H;
if (cond.sunVisible) {
const glowFn = heroBudget > 0 && golden ? heroGlow(sx, sy, W * 0.11, '#f6e0a8', 0.12) : null;
if (glowFn) stamped(0.108, glowFn);
else {
stamped(0.108, veil(sx, sy, W * 0.14, 0, W * 0.08, '#f6e0a8', 0.13 * softK, 1));
stamped(0.109, veil(sx, sy, W * 0.08, 0, W * 0.05, '#f8e6b0', 0.15 * softK, 1));
}
for (let k = 0; k < 3; k++) {
stamped(0.112 + k * 0.001, touch(sx + (rng() - 0.5) * W * 0.004, sy + (rng() - 0.5) * W * 0.004,
W * (0.014 + rng() * 0.006), '#f8e8b4', (0.60 + rng() * 0.25) * softK));
}
} else {
const glowFn = heroBudget > 0 ? heroGlow(sx, sy, W * 0.09, '#c8d2e2', 0.10) : null;
if (glowFn) stamped(0.108, glowFn);
else {
stamped(0.108, veil(sx, sy, W * 0.10, 0, W * 0.06, '#c8d2e2', 0.12 * softK, 1));
stamped(0.109, veil(sx, sy, W * 0.055, 0, W * 0.035, '#dde4ee', 0.14 * softK, 1));
}
for (let k = 0; k < 2; k++) {
stamped(0.112 + k * 0.001, touch(sx, sy, W * (0.009 + rng() * 0.003), '#e8edf4', 0.75 * softK));
}
}
}
// -- THE FAR SHORE: a hazed strip, its spire or lighthouse -----------------
const afFar = aerialFade(0.06);
const shoreC = grade(mixHex('#7c7460', afFar.haze, 0.55), 0.6);
for (let i = 0; i < Math.round(54 * (misty ? 0.7 : 1)); i++) {
stamped(0.126 + rng() * 0.020, veil(rng() * W, hzY + H * ((rng() - 0.8) * 0.016),
W * (0.02 + rng() * 0.05), (rng() - 0.5) * 0.06, Math.max(1.2, H * 0.006),
shoreC, (0.07 + rng() * 0.10) * softK, 0.3));
}
{
const markX = W * (0.12 + rng() * 0.76);
const markC = grade(mixHex('#5c5450', afFar.haze, 0.35), 0.6);
if (rng() < 0.5) { // the church spire above its nave
const spH = H * (0.024 + rng() * 0.012);
stamped(0.148, veil(markX, hzY - H * 0.004, W * 0.02, 0, Math.max(1.2, H * 0.008), markC, 0.45 * softK)); // nave
for (let k = 0; k < 5; k++) {
const t = k / 4;
stamped(0.150 + k * 0.0006, touch(markX + (rng() - 0.5) * 1.2, hzY - H * 0.004 - spH * t,
Math.max(0.9, H * 0.007 * (1 - t * 0.8)), markC, 0.55 * softK, 0, 1.35));
}
} else { // the lighthouse: a thin tower with its gallery
const lhH = H * (0.030 + rng() * 0.014);
const lhW = Math.max(1.2, H * 0.006);
for (let k = 0; k < 5; k++) {
const t = k / 4;
stamped(0.150 + k * 0.0006, touch(markX, hzY - lhH * t, lhW * (1.25 - t * 0.35),
markC, 0.6 * softK, 0, 1.15));
}
stamped(0.1534, touch(markX - lhW * 0.5, hzY - lhH, lhW * 0.7, markC, 0.6 * softK, 0, 0.8));
stamped(0.1536, touch(markX + lhW * 0.5, hzY - lhH, lhW * 0.7, markC, 0.6 * softK, 0, 0.8));
if (night || dusk) { // its lamp, doubled in the water
stamped(0.1540, touch(markX, hzY - lhH, Math.max(1.2, lhW * 0.6), LANTERN, 0.85 * softK));
stamped(0.1542, veil(markX, hzY - lhH, lhW * 1.6, 0, lhW, LANTERN, 0.12 * softK, 1));
stamped(0.1544, touch(markX, hzY + (wlY - hzY) * 0.06, Math.max(1.0, lhW * 0.5), LANTERN, 0.3 * softK));
}
}
}
// -- GULLS: 0-5, single curved strokes -------------------------------------
const nGull = Math.floor(rng() * 6);
const gullC = grade(mixHex(skyMid, '#f4f2ec', night ? 0.25 : 0.55), 0.4);
for (let i = 0; i < nGull; i++) {
const gx = W * (0.08 + rng() * 0.84), gy = H * (0.05 + rng() * 0.30);
const s = W * (0.007 + rng() * 0.011);
const pG = 0.156 + rng() * 0.008;
stamped(pG, (cc, jr) => rigger(cc, jr, { x: gx - s, y: gy + s * 0.3, length: s,
color: gullC, alpha: 0.55 * softK, angle: -0.45, width: Math.max(0.5, s * 0.09), bow: s * 0.55 }));
stamped(pG + 0.0004, (cc, jr) => rigger(cc, jr, { x: gx + s, y: gy + s * 0.3, length: s,
color: gullC, alpha: 0.55 * softK, angle: Math.PI + 0.45, width: Math.max(0.5, s * 0.09), bow: s * 0.55 }));
}
// -- THE WATER BAND: far side, breathing -----------------------------------
const waterN = Math.round(430 * covK * clamp((wlY - hzY) / (0.30 * H), 0.3, 1.2));
for (let i = 0; i < waterN; i++) {
const yn = Math.pow(rng(), 1.3); // 0 near horizon .. 1 near the tide line
const x = (rng() * 1.08 - 0.04) * W;
const y = hzY + (wlY - hzY) * yn + (rng() - 0.5) * H * 0.006;
const ci = clamp(Math.floor(yn * 3 + rng() * 1.4), 0, 3);
const col = WAT[ci];
const len = W * (0.015 + 0.05 * yn) * (0.5 + rng());
const ang = (rng() - 0.5) * (rough ? 0.16 : 0.06);
const w = Math.max(1.0, (1.2 + 3.6 * yn) * (0.7 + rng() * 0.6));
stamped(0.166 + 0.116 * yn + rng() * 0.004, smear(x, y, len, ang, w, col,
(0.08 + rng() * 0.10) * softK, 0.4));
if (rough && rng() < 0.28) { // pale cap riding the chop
stamped(0.170 + 0.112 * yn + rng() * 0.004, touch(x + len * 0.3, y - H * 0.004,
Math.max(1.0, W * 0.004), mixHex('#e8ecee', skyMid, 0.3), 0.15 * softK, 0, 0.5));
}
}
// -- THE MOORED FLEET: beyond, in the far water ----------------------------
const nFleet = 4 + Math.floor(rng() * 5);
const fleet = [];
for (let i = 0; i < nFleet; i++) {
const fx = W * (0.04 + rng() * 0.92);
const fy = hzY + (wlY - hzY) * (0.05 + rng() * 0.30);
const L = W * (0.015 + rng() * 0.02);
const afF = aerialFade(0.10 + rng() * 0.12);
fleet.push({ fx, fy, L, tone: pickA(HULLS), haze: afF.haze, hazeK: 0.35 + afF.desat, alK: afF.alpha * softK });
const pF = 0.288 + (i / nFleet) * 0.044;
const hullC = mixHex(pickA(HULLS), afF.haze, night ? 0.22 : 0.45 + afF.desat);
stamped(pF, veil(fx, fy, L * 1.1, (rng() - 0.5) * 0.08, Math.max(1.2, L * 0.16), hullC, 0.5 * afF.alpha * softK, 0.4));
stamped(pF + 0.0008, veil(fx + L * 0.2, fy - L * 0.04, L * 0.7, (rng() - 0.5) * 0.08, Math.max(1.0, L * 0.13), hullC, 0.4 * afF.alpha * softK, 0.4));
const mh = Math.min(L * (2.2 + rng() * 1.1), fy - H * 0.02);
stamped(pF + 0.0016, (cc, jr) => rigger(cc, jr, { x: fx + (rng() - 0.5) * L * 0.5, y: fy - L * 0.05,
length: mh, color: mixHex(mastC, afF.haze, 0.4 + afF.desat), alpha: 0.45 * afF.alpha * softK,
angle: -Math.PI / 2 + (rng() - 0.5) * 0.06, width: Math.max(0.5, L * 0.02), bow: (rng() - 0.5) * L * 0.15 }));
// the mast doubles in the water, trembling
const mxR = fx + (rng() - 0.5) * L * 0.5;
for (let k = 0; k < 2; k++) {
stamped(pF + 0.0024 + k * 0.0008, veil(mxR + Math.sin(fy * 0.08 + k) * L * 0.25,
fy + L * (0.14 + k * 0.13), L * 0.5, Math.PI / 2, Math.max(0.6, L * 0.05),
mixHex(mastC, WAT[1], 0.55), 0.10 * afF.alpha * softK, 0.3));
}
}
// -- THE MUD: broken ochre and blue-grey, wet sheen at the tide line -------
const mudN = Math.round(760 * covK * clamp(mudH / (0.34 * H), 0.18, 1.15));
for (let i = 0; i < mudN; i++) {
const yn = Math.pow(rng(), 0.85); // 0 at the tide line .. 1 near us
const x = rng() * W, y = wlY + mudH * yn;
const wet = 1 - yn;
let col;
if (wet > 0.72 && rng() < 0.55) col = mixHex(pickA(MUD_M), skyHor, 0.30 + rng() * 0.28); // sky sheen
else col = rng() < 0.45 ? pickA(MUD_M) : rng() < 0.5 ? pickA(MUD_L) : pickA(MUD_D);
const al = (0.10 + rng() * 0.14) * softK;
const len = W * (0.02 + rng() * 0.05) * (0.5 + yn);
stamped(0.336 + 0.126 * yn + rng() * 0.004, smear(x, y, len, (rng() - 0.5) * 0.16,
Math.max(1.2, H * 0.006 * (0.6 + yn)), col, al, 0.4));
}
// cool blue-grey patches: the flats' broken colour, never one flat ochre
for (let i = 0, nCp = Math.round(48 * clamp(mudH / (0.3 * H), 0.2, 1)); i < nCp; i++) {
const yn = 0.15 + rng() * 0.8;
stamped(0.400 + 0.055 * rng(), smear(rng() * W, wlY + mudH * yn,
W * (0.03 + rng() * 0.06) * (0.5 + yn), (rng() - 0.5) * 0.14,
Math.max(1.4, H * 0.008 * (0.6 + yn)),
mixHex(pickA(MUD_D), '#6a707e', 0.45 + rng() * 0.3), (0.10 + rng() * 0.10) * softK, 0.4));
}
// wet sheen streaks: the flats holding the light horizontally
for (let i = 0; i < Math.round(26 * clamp(mudH / (0.3 * H), 0.2, 1)); i++) {
const yn = rng() * 0.5;
stamped(0.466 + 0.020 * rng(), smear(rng() * W, wlY + mudH * yn,
W * (0.03 + rng() * 0.06) * (0.5 + yn), (rng() - 0.5) * 0.08,
Math.max(1.0, H * 0.004 * (0.5 + yn)), mixHex(skyHor, '#dfe4e2', 0.3),
(0.07 + rng() * 0.07) * softK, 0.25));
}
// the tide line breathes: water and mud bleed into each other at the edge
for (let i = 0; i < 30; i++) {
stamped(0.468 + rng() * 0.016, veil(rng() * W, wlY + (rng() - 0.45) * H * 0.012,
W * (0.03 + rng() * 0.07), (rng() - 0.5) * 0.08, Math.max(1.5, H * 0.008),
mixHex(skyHor, pickA(MUD_M), 0.45), 0.10 * softK));
}
// -- THE POOLS: wet sheen pools reflecting the sky --------------------------
const nPools = Math.round((9 + rng() * 5) * clamp(mudH / (0.3 * H), 0.15, 1));
const poolCol = night ? mixHex(skyHor, '#c8d4e6', 0.30) : skyHor;
for (let i = 0; i < nPools; i++) {
const cx = rng() * W, cy = wlY + mudH * (0.08 + rng() * 0.82);
const pw = W * (0.02 + rng() * 0.055), ph = Math.max(1.6, pw * (0.12 + rng() * 0.10));
const pP = 0.490 + (i / Math.max(1, nPools)) * 0.034;
const poolC = mixHex(poolCol, pickA(MUD_M), 0.30); // a wet mirror, not a lamp
stamped(pP, veil(cx, cy, pw, 0, ph, mixHex(poolC, skyMid, rng() * 0.4), 0.13 * softK, 0.14));
stamped(pP + 0.0008, veil(cx + pw * 0.2, cy + ph * 0.5, pw * 0.7, 0, ph * 0.7, poolC, 0.10 * softK, 0.16));
stamped(pP + 0.0016, veil(cx, cy + ph * 1.2, pw * 0.9, 0, ph * 0.5, pickA(MUD_D), 0.08 * softK, 0.3)); // rim
if ((dawn || golden || noon) && rng() < 0.5) { // the low light caught in the pool
stamped(pP + 0.0024, touch(cx + (rng() - 0.5) * pw, cy, Math.max(0.8, pw * 0.10),
GLINT, 0.22 * softK, 0, 0.4));
}
if (night && rng() < 0.45) {
stamped(pP + 0.0024, touch(cx + (rng() - 0.5) * pw, cy, Math.max(0.8, pw * 0.10),
mixHex(LANTERN, '#e8d8b0', 0.4), 0.18 * softK, 0, 0.4));
}
}
// -- THE BOATS: the dice ground them or float them --------------------------
const nB = 3 + Math.floor(rng() * 6); // 3-8
const boats = [];
for (let i = 0; i < nB; i++) {
const u = (i + 0.3 + rng() * 0.5) / nB;
const L = W * (0.055 + 0.085 * Math.pow(rng(), 0.8));
// the tide decides this hull's fate; mid tide splits the fleet
const floatP = cond.tide === 'low' ? 0 : cond.tide === 'high' ? 1 : 0.5;
const afloat = rng() < floatP;
let y;
if (afloat) y = hzY + (wlY - hzY) * (0.10 + rng() * 0.68) + H * 0.012;
else y = wlY + mudH * (0.04 + rng() * 0.88);
y = clamp(y, hzY + H * 0.03, H * 0.94);
const heel = afloat ? (rng() - 0.5) * 0.06
: (rng() < 0.5 ? -1 : 1) * (0.10 + rng() * 0.20); // grounded hulls lie over
const dir = rng() < 0.5 ? 1 : -1; // which end is the bow
let tone = rng() < 0.18 ? RUST : pickA(HULLS);
if (night) tone = mixHex(tone, '#1c2230', 0.45); // no colour survives the moon
else if (dusk) tone = mixHex(tone, '#242030', 0.22);
boats.push({
u, x: clamp(u * W, L * 0.55, W - L * 0.55), y, L,
hh: L * (0.13 + rng() * 0.05), heel, dir, tone,
gun: gunC0, afloat,
mastU: dir * (0.10 + rng() * 0.28),
mastH: 0, // capped against the frame below
lean: (rng() - 0.5) * 0.10,
phase: rng() * TAU,
tex: 4 + Math.floor(rng() * 4),
});
}
boats.sort((a, b) => a.L - b.L); // the small and far arrive first
const P0 = 0.530, P1 = 0.794;
for (let bi = 0; bi < boats.length; bi++) {
const b = boats[bi];
const sub = (P1 - P0) / boats.length;
const pB = P0 + sub * bi;
const st = (k) => pB + sub * clamp(k, 0, 0.98) + rng() * 0.0004;
const cs = Math.cos(b.heel), sn = Math.sin(b.heel);
const pt = (uu, vv) => ({ x: b.x + uu * cs - vv * sn, y: b.y + uu * sn + vv * cs });
const deckY = b.y;
b.mastH = Math.min(b.L * (2.1 + rng() * 1.2), deckY - H * 0.045);
const alK = (b.afloat ? 0.85 : 1) * softK;
const hazeK = b.afloat ? 0.10 : 0.04;
// the ground shadow (dawn/golden: long, fleeing the light) or the
// water's dark hold under the hull
if (!b.afloat) {
const shLen = b.L * (0.5 + longK * 0.8);
const nSh = dawn || golden || dusk ? 5 : 3;
for (let k = 0; k < nSh; k++) {
const t = (k + 0.5) / nSh;
const q = pt(0, b.hh * (1.1 + t * 0.5));
stamped(st(0.02 + 0.06 * (k / nSh)), veil(q.x + shDx * t * shLen, q.y + t * H * 0.008,
b.L * 0.16 * (1.2 - t * 0.5), b.heel, Math.max(1.4, b.hh * 0.6),
mixHex(pickA(MUD_D), '#4a4640', 0.3), (0.14 - t * 0.05) * alK, 0.3));
}
}
// THE HULL: a filled crescent in the boat's tilted frame — three
// contiguous rows (sheer, flank, keel) of overlapping slabs, riding
// veil() so the form closes dense and dark in every hand
const nRow = 13;
const rows = [
{ vm: 0.28, wm: 0.85, aa: 0.55, dk: 0.10 }, // the sheer, under the gunwale
{ vm: 0.68, wm: 0.75, aa: 0.58, dk: 0.22 }, // the flank
{ vm: 1.02, wm: 0.55, aa: 0.55, dk: 0.38 }, // the keel, darkest
];
for (let rI = 0; rI < rows.length; rI++) {
const row = rows[rI];
for (let k = 0; k < nRow; k++) {
const uu = -1 + (2 * k) / (nRow - 1) + (rng() - 0.5) * 0.10;
const shape = Math.pow(Math.max(0, 1 - uu * uu), 0.62);
const vv = b.hh * row.vm * (0.75 + 0.5 * rng()) * (0.35 + 0.65 * shape);
const q = pt(uu * b.L * 0.5, vv);
const col = mixHex(b.tone, '#12161c', row.dk + (vv / b.hh) * 0.15);
stamped(st(0.09 + 0.055 * rI + 0.05 * (k / nRow)), veil(q.x, q.y,
b.L * 0.15 * (0.8 + rng() * 0.4), b.heel,
Math.max(1.4, b.hh * row.wm), mixHex(col, aerialFade(b.afloat ? 0.3 : 0.6).haze, hazeK),
row.aa * alK, 0.55));
}
}
// the sheer line itself: pale gunwale, contiguous, slightly rising at the bow
const nG = 13;
for (let k = 0; k < nG; k++) {
const t = (k + 0.5) / nG;
const uu = (t * 2 - 1);
const rise = b.hh * (0.05 + 0.20 * Math.pow(Math.max(0, uu * b.dir), 2));
const q = pt(uu * b.L * 0.5, -b.hh * 0.04);
stamped(st(0.27 + 0.035 * (k / nG)), touch(q.x, q.y - rise, Math.max(1.1, b.hh * 0.42),
mixHex(b.gun, b.tone, 0.10 + rng() * 0.15), (night ? 0.28 : 0.55) * alK, b.heel, 0.55));
}
// hull texture through the hand: plank shadows, a patch, the rub rail
for (let k = 0; k < b.tex; k++) {
const uu = rng() * 2 - 1;
const q = pt(uu * b.L * 0.42, b.hh * (0.2 + rng() * 0.55));
stamped(st(0.36 + 0.06 * (k / b.tex)), touch(q.x, q.y, Math.max(0.9, b.hh * 0.30),
rng() < 0.4 ? mixHex(b.tone, '#c8b890', 0.25) : mixHex(b.tone, '#10141a', 0.30),
0.35 * alK, b.heel, 0.55));
}
// THE MAST: tall, thin, leaning with the hull's life
const mp = pt(b.mastU * b.L * 0.5, -b.hh * 0.10);
const mTop = { x: mp.x + Math.sin(b.heel * 0.55 + b.lean) * b.mastH, y: mp.y - Math.cos(b.heel * 0.55 + b.lean) * b.mastH };
stamped(st(0.43), (cc, jr) => rigger(cc, jr, { x: mp.x, y: mp.y, length: b.mastH,
color: mastC, alpha: 0.72 * alK, angle: -Math.PI / 2 + b.heel * 0.55 + b.lean,
width: Math.max(0.7, b.L * 0.013), bow: (rng() - 0.5) * b.mastH * 0.03 }));
stamped(st(0.44), (cc, jr) => rigger(cc, jr, { // a second hair for the mast's body
x: mp.x + b.L * 0.012, y: mp.y, length: b.mastH * 0.92,
color: mixHex(mastC, '#4a4644', 0.3), alpha: 0.30 * alK,
angle: -Math.PI / 2 + b.heel * 0.55 + b.lean, width: Math.max(0.5, b.L * 0.008), bow: 0 }));
// rigging diagonals: the forestay to the bow, rarely a stern backstay
const rig = [];
rig.push([pt(b.dir * b.L * 0.48, -b.hh * 0.2), 0.16]);
if (rng() < 0.3 && b.mastH > b.L * 2.2) rig.push([pt(-b.dir * b.L * 0.46, -b.hh * 0.2), 0.12]);
for (let k = 0; k < rig.length; k++) {
const [end, aa] = rig[k];
stamped(st(0.45 + 0.03 * k), (cc, jr) => rigger(cc, jr, {
x: mTop.x, y: mTop.y,
length: Math.hypot(end.x - mTop.x, end.y - mTop.y),
color: mastC, alpha: aa * alK * (rough ? 1.3 : 1),
angle: Math.atan2(end.y - mTop.y, end.x - mTop.x),
width: Math.max(0.4, b.L * 0.005), bow: (rng() - 0.5) * b.L * 0.04,
}));
}
// the boom lying along the deck, occasionally
if (rng() < 0.4 && b.mastH > b.L) {
const q0 = pt(b.mastU * b.L * 0.5, -b.hh * 0.25);
stamped(st(0.50), (cc, jr) => rigger(cc, jr, { x: q0.x, y: q0.y, length: b.L * (0.3 + rng() * 0.25),
color: mastC, alpha: 0.4 * alK, angle: b.dir > 0 ? b.heel + 0.05 : b.heel + Math.PI - 0.05,
width: Math.max(0.5, b.L * 0.010), bow: 0 }));
}
// THE REFLECTION: mud holds a dark smudge; water trembles the hull
if (b.afloat) {
const nR = 6;
for (let k = 0; k < nR; k++) {
const yy = deckY + b.hh * (0.9 + k * 0.42 * (0.8 + rng() * 0.4));
const xx = b.x + Math.sin(yy * 0.045 + b.phase) * b.L * 0.06 + (rng() - 0.5) * b.L * 0.10;
stamped(st(0.53 + 0.07 * (k / nR)), veil(xx, yy, b.L * 0.12 * (1 - k * 0.09), 0,
Math.max(1.0, b.hh * 0.42 * (1 - k * 0.08)), mixHex(b.tone, WAT[2], 0.35),
0.17 * (1 - k / (nR + 1)) * alK, 0.35));
}
// the mast doubles, a trembling hair
for (let k = 0; k < 3; k++) {
stamped(st(0.60 + 0.02 * k), veil(mTop.x * 0.4 + b.x * 0.6 + Math.sin(deckY * 0.05 + k) * b.L * 0.10,
deckY + b.hh * 1.2 + k * b.mastH * 0.09, b.mastH * 0.11, Math.PI / 2,
Math.max(0.5, b.L * 0.012), mixHex(mastC, WAT[1], 0.55), 0.10 * alK, 0.3));
}
} else {
// mud-reflection: a soft dark twin just under the keel
for (let k = 0; k < 4; k++) {
const q = pt((rng() * 2 - 1) * b.L * 0.35, b.hh * (1.15 + k * 0.16));
stamped(st(0.53 + 0.05 * (k / 4)), veil(q.x, q.y, b.L * 0.14 * (1 - k * 0.12), b.heel,
Math.max(1.0, b.hh * 0.4 * (1 - k * 0.15)), mixHex(b.tone, pickA(MUD_D), 0.45),
0.15 * (1 - k * 0.16) * alK, 0.3));
}
}
}
// -- HARBOUR LIFE, dice-gated and sparse -------------------------------------
// lobster pots stacked on the exposed mud
if (cond.tide !== 'high' && rng() < 0.62) {
const nPot = 2 + Math.floor(rng() * 3);
for (let s = 0; s < nPot; s++) {
const px = rng() * W, py = wlY + mudH * (0.15 + rng() * 0.75);
const ps = Math.max(1.2, W * 0.012 * (0.7 + rng() * 0.7));
const pP = 0.800 + s * 0.004;
const potC = grade('#3c342c', 0.7);
for (let k = 0; k < 6; k++) {
stamped(pP + k * 0.0004, touch(px + (rng() - 0.5) * ps * 2.2, py + (rng() - 0.5) * ps * 0.9,
ps * (0.5 + rng() * 0.5), rng() < 0.3 ? mixHex(potC, '#6a5a40', 0.4) : potC, 0.55 * softK, 0, 0.75));
}
stamped(pP + 0.0028, (cc, jr) => rigger(cc, jr, { x: px - ps, y: py + ps * 0.3, length: ps * 2.4,
color: mixHex(potC, '#8a7a5a', 0.5), alpha: 0.4 * softK, angle: (rng() - 0.5) * 0.5 + (rng() < 0.5 ? 0 : Math.PI),
width: Math.max(0.4, ps * 0.08), bow: (rng() - 0.5) * ps }));
if (rng() < 0.4) stamped(pP + 0.0032, touch(px + (rng() - 0.5) * ps * 2, py - ps * 0.7,
ps * 0.4, '#c06a38', 0.6 * softK)); // a buoy
stamped(pP + 0.0036, veil(px, py + ps * 0.7, ps * 2.4, 0, Math.max(0.8, ps * 0.3),
pickA(MUD_D), 0.12 * softK, 0.3)); // their shadow
}
}
// figures working between the hulls
if (rng() < 0.55 && boats.length) {
const nFig = 1 + (rng() < 0.4 ? 1 : 0);
for (let s = 0; s < nFig; s++) {
const T = boats[Math.floor(rng() * boats.length)];
const fx = clamp(T.x + (rng() < 0.5 ? -1 : 1) * (T.L * 0.55 + rng() * W * 0.05), W * 0.04, W * 0.96);
const fy = clamp(T.y + (rng() - 0.3) * T.hh * 3, wlY + mudH * 0.06, H * 0.95);
const sF = H * 0.040 * (0.75 + rng() * 0.5) * (0.5 + T.L / (W * 0.14)) * 0.9;
const pF = 0.818 + s * 0.006;
const bend = rng() < 0.5; // bent to the pots, or upright with a load
const coatC = rng() < 0.3 ? '#4a3a30' : FIG_S;
stamped(pF, smear(fx - sF * 0.16, fy - sF * 0.1, sF * 0.42, Math.PI / 2, Math.max(0.7, sF * 0.13), FIG_D, 0.6 * softK, 1)); // legs
stamped(pF + 0.0006, smear(fx + sF * 0.16, fy - sF * 0.1, sF * 0.42, Math.PI / 2, Math.max(0.7, sF * 0.13), FIG_D, 0.6 * softK, 1));
stamped(pF + 0.0012, touch(fx, fy - sF * (bend ? 0.34 : 0.48), Math.max(1.2, sF * 0.42),
coatC, 0.7 * softK, 0, bend ? 1.5 : 1.25)); // the coat
stamped(pF + 0.0018, touch(fx + (bend ? sF * 0.22 : 0), fy - sF * (bend ? 0.52 : 0.82),
Math.max(0.8, sF * 0.20), '#c89878', 0.7 * softK)); // the head
stamped(pF + 0.0024, (cc, jr) => rigger(cc, jr, { // the arm, to the work
x: fx + (bend ? sF * 0.1 : -sF * 0.2), y: fy - sF * (bend ? 0.40 : 0.55),
length: sF * (bend ? 0.5 : 0.42), color: coatC, alpha: 0.6 * softK,
angle: bend ? 0.9 : -0.5, width: Math.max(0.5, sF * 0.10), bow: 0 }));
if (!bend) stamped(pF + 0.0028, touch(fx + sF * 0.5, fy - sF * 0.5, Math.max(0.9, sF * 0.2),
potC0(), 0.6 * softK)); // the carried basket
stamped(pF + 0.0032, veil(fx + shDx * sF * 0.6, fy + sF * 0.05, sF * 0.9, 0,
Math.max(0.8, sF * 0.16), pickA(MUD_D), 0.13 * softK, 0.3)); // the shadow
}
}
// a rowing dinghy, pulled up or lying to its painter
if (rng() < 0.6) {
const afloat = cond.tide === 'high' ? true : cond.tide === 'low' ? false : rng() < 0.5;
const dx0 = rng() * W;
const dy0 = afloat ? hzY + (wlY - hzY) * (0.45 + rng() * 0.35)
: wlY + mudH * (0.15 + rng() * 0.7);
const dL = W * (0.030 + rng() * 0.025);
const pD = 0.836;
const dTone = pickA(HULLS);
for (let k = 0; k < 5; k++) {
const uu = (k / 4) * 2 - 1;
const vv = dL * 0.10 * Math.pow(Math.max(0, 1 - uu * uu), 0.6) * (0.3 + 0.7 * rng());
stamped(pD + 0.002 * k, veil(dx0 + uu * dL * 0.5, dy0 + vv, dL * 0.14, 0,
Math.max(0.9, dL * 0.10), mixHex(dTone, '#14181e', vv / (dL * 0.1) * 0.2), 0.5 * softK, 0.5));
}
stamped(pD + 0.011, smear(dx0, dy0 - dL * 0.06, dL * 0.7, 0, Math.max(0.6, dL * 0.05),
mixHex(gunC0, dTone, 0.3), 0.4 * softK, 0.5)); // the gunwale
stamped(pD + 0.013, (cc, jr) => rigger(cc, jr, { // an oar trailing
x: dx0 + dL * 0.2, y: dy0 - dL * 0.05, length: dL * 1.1, color: '#6a5638',
alpha: 0.5 * softK, angle: -0.35 + (rng() < 0.5 ? 0 : Math.PI * 0.85),
width: Math.max(0.5, dL * 0.03), bow: dL * 0.08 }));
stamped(pD + 0.015, veil(dx0, dy0 + dL * 0.14, dL * 0.8, 0, Math.max(0.8, dL * 0.08),
afloat ? mixHex(WAT[2], dTone, 0.3) : pickA(MUD_D), 0.13 * softK, 0.3));
}
// -- THE HOUR'S SIGNATURES ---------------------------------------------------
if (dawn) { // rose-gold first light laid over the wet flats
for (let k = 0; k < 22; k++) {
stamped(0.856 + rng() * 0.014, veil(rng() * W, wlY + mudH * rng(),
W * (0.04 + rng() * 0.09), (rng() - 0.5) * 0.15, Math.max(2, H * 0.012),
mixHex(MUD_L[0], '#f4bcac', 0.45), (0.05 + rng() * 0.06) * softK));
}
}
if (golden) { // amber pooling on the flats, masts already silhouette
for (let k = 0; k < 20; k++) {
stamped(0.856 + rng() * 0.014, veil(rng() * W, wlY + mudH * rng(),
W * (0.04 + rng() * 0.09), (rng() - 0.5) * 0.15, Math.max(2, H * 0.012),
'#f0c880', (0.05 + rng() * 0.06) * softK));
}
}
if (night || dusk) { // the lanterns, doubled in pools and water
const nL = 3 + Math.floor(rng() * 3);
for (let k = 0; k < nL; k++) {
const lx = W * (0.08 + rng() * 0.84);
const onBoat = rng() < 0.5 && boats.length;
const ly = onBoat
? boats[Math.floor(rng() * boats.length)].y - H * (0.02 + rng() * 0.05)
: hzY - H * (0.004 + rng() * 0.02);
const inWater = !onBoat || cond.tide === 'high';
const pL = 0.858 + k * 0.004;
stamped(pL, touch(lx, ly, Math.max(1.2, W * 0.004), LANTERN, 0.9 * softK));
stamped(pL + 0.001, veil(lx, ly, W * 0.03, 0, W * 0.015, LANTERN, 0.10 * softK, 1));
// the doubling: down the water or into the mud pools
for (let m = 0; m < 3; m++) {
const ry = inWater ? ly + (wlY - ly) * 0.3 + m * H * 0.014
: ly + (H - ly) * (0.2 + m * 0.22);
stamped(pL + 0.002 + m * 0.0008, veil(lx + Math.sin(ry * 0.05) * W * 0.005, ry,
W * 0.007, Math.PI / 2, Math.max(0.8, W * 0.0025), LANTERN,
(0.13 - m * 0.03) * softK, 0.25));
}
}
}
if (misty) { // the far shore ghosts; the masts fade at the top
for (let k = 0; k < 22; k++) {
stamped(0.872 + rng() * 0.014, veil(rng() * W, hzY - H * 0.03 + rng() * H * 0.06,
W * (0.08 + rng() * 0.20), (rng() - 0.5) * 0.08, Math.max(2, H * (0.02 + rng() * 0.04)),
'#e8ece8', (0.05 + rng() * 0.07) * softK));
}
for (const b of boats) {
if (b.mastH < b.L) continue;
stamped(0.880 + rng() * 0.008, veil(b.x + Math.sin(b.heel) * b.mastH * 0.4,
b.y - b.mastH * 0.72, b.mastH * 0.30, -Math.PI / 2 + b.heel * 0.5,
Math.max(2, b.L * 0.06), '#e8ece8', 0.10 * softK, 0.5));
}
}
if (rain) { // the sheen: every flat surface catching the pale light
for (let k = 0; k < 20; k++) {
const yn = rng();
stamped(0.888 + rng() * 0.012, smear(rng() * W, wlY + mudH * yn,
W * (0.02 + rng() * 0.05) * (0.5 + yn), (rng() - 0.5) * 0.08,
Math.max(1.0, H * 0.004 * (0.5 + yn)), '#d8e4e6', (0.07 + rng() * 0.07) * softK, 0.25));
}
}
// -- the unifying breath -------------------------------------------------------
for (let i = 0; i < 18; i++) {
const y = rng() * H;
const col = y < hzY ? mixHex(sg[1].color, sg[2].color, rng() * 0.5)
: y < wlY ? WAT[1 + Math.floor(rng() * 2)]
: mixHex(pickA(MUD_M), pickA(MUD_L), rng() * 0.4);
stamped(0.904 + rng() * 0.014, 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 * (hand.mode === 'impasto' ? 0.45 : 1)));
}
// -- the last notes: glints, the brightest accents ------------------------------
const glintX = cond.sunX * W;
for (let i = 0, n = Math.round(70 * covK); i < n; i++) {
const inWater = cond.tide === 'high' ? rng() < 0.75 : rng() < 0.4;
let gx, gy;
if (inWater) {
gx = glintX + (rng() + rng() - 1) * W * 0.09;
gy = hzY + (wlY - hzY) * (0.05 + rng() * 0.9);
} else {
gx = rng() * W;
gy = wlY + mudH * (0.05 + rng() * 0.9);
}
const twink = night ? rng() < 0.5 : true;
stamped(0.922 + 0.074 * (i / Math.max(1, n)) + rng() * 0.003,
touch(gx, gy, Math.max(0.7, W * (0.0015 + rng() * 0.003)),
twink ? (rng() < 0.3 ? GLINT : mixHex(GLINT, skyHor, 0.3))
: mixHex(GLINT, '#c8d4e6', 0.4),
(0.28 + rng() * 0.30) * softK * (night ? 0.30 : 1), 0, 0.4));
}
// hull highlights: the pale gunwale catching the last light
for (let i = 0, n = Math.round((night ? 10 : 24) * covK); i < n && boats.length; i++) {
const b = boats[Math.floor(rng() * boats.length)];
const uu = (rng() * 2 - 1) * 0.8;
const q = { x: b.x + uu * b.L * 0.5 * Math.cos(b.heel),
y: b.y + uu * b.L * 0.5 * Math.sin(b.heel) - b.hh * 0.1 };
stamped(0.940 + rng() * 0.030, touch(q.x, q.y, Math.max(0.8, b.hh * 0.2),
mixHex(b.gun, GLINT, night ? 0.05 : 0.35), (0.25 + rng() * 0.25) * softK * (night ? 0.5 : 1),
b.heel, 0.5));
}
// small local helper used once above (hoisted use is safe: called at stamp-build time)
function potC0() { return grade('#5c4a34', 0.7); }
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);
}
},
};
// Verger en fleurs — the atelier's orchard, after Pissarro's spring orchards
// at Éragny and Monet's at Giverny. Four to nine apple and pear trees stand in
// receding rows through mown grass (a classic Éragny composition, laid out on
// brushes.Horizon's one-point ground plane): each tree a dark brown-violet
// trunk with a rigger-branch skeleton, carrying a crown that is a CLOUD of
// blossom — pink-white broken dabs dense at the core, dissolving at the edge
// into single falling petals. Fresh spring green below: luminous yellow-green
// in the light pools, cool blue-green under the crowns, mowing lines and wheel
// ruts leading the eye to the vanishing region. Soft spring sky above with
// high wispy clouds, the light low and tender. The signature accent: a sparse
// drift of petals floating between the trees, denser under the crowns, their
// fall following a dice-rolled breeze.
//
// CONDITIONS. The hour transforms the scene: dawn pools mist between the rows
// and lays pink light on the blossom; noon is bright and high-contrast;
// goldenHour turns the blossom peach-gold and drags the shadows long; dusk and
// night make the crowns pale ghosts over moonlit grass, with one lit farmhouse
// window. The season axis reads as the orchard's year: spring is the canonical
// full bloom (a further dice picks early/full/late within it), summer plays
// late spring — petal-fall heavy, grass taller — and autumn plays the fruit
// set, green-gold crowns studded with red apples. Winter is RE-ROLLED AWAY
// (see conditions()); the astronomically rare forced survivor paints the bare
// winter orchard, rigger branch structure exposed over pale grass. Weather:
// rain beats the petals down onto the grass and sheens the mowing lines, mist
// dissolves the rows in depth, clear is the tender default. The painter's hand
// (brushes.handFor) realizes every touch its own way.
//
// HOUSE BUG WORKAROUND (mouille): 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, no filter. bleed
// is reserved for a capped handful of HERO glows (≤6: the moon, the sun, the
// lit window) under the wet hand only.
//
// Contract: identical to scenes/tournesols.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. TONAL SPINE: sky palest > grass mid-light > blossom brightest
// accents > trunks/shadows darkest.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, bleed, dab, roundDab, stroke, rigger, Horizon, aerialFade,
} 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: 'verger',
title: 'Verger en fleurs',
artist: 'Atelier Impressionniste',
durationMs: 55000,
// Season override: an orchard in blossom has no winter. Free-rolled winters
// are re-drawn (same dedicated stream, so still fully deterministic) toward
// the orchard's year; a FORCED winter survives and paints honestly — the
// bare orchard, branch skeleton over pale grass. Eight tries max; the free
// survivor is ~(1/6)^8 — essentially never.
conditions: (rng, constraints) => {
const forced = constraints || {};
let c = makeConditions(rng, forced);
for (let i = 0; i < 8; i++) {
if (c.season !== 'winter' || forced.season === 'winter') return c;
c = makeConditions(rng, forced);
}
return c;
},
subtitle: (c) => (c.season === 'winter' ? 'Verger d’hiver, ' : 'Verger en fleurs, ') + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1877)); // Pissarro, Les Vergers, 1877
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 stormy = weather === 'overcast' || weather === 'rain';
const misty = weather === 'mist';
const rain = weather === 'rain';
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 sits ------
const hand = handFor(cond.technique);
// thin-ink hands (vernis glazes, mouille washes) get a nominal-alpha
// compensation and a size brake, so the blossom cloud stays dense and
// tight no matter which wrist holds the brush
const inkK = hand.alphaCurve === 'soft' ? 1.9 : hand.alphaCurve === 'bleed' ? 1.5 : 1;
const sizeK = hand.alphaCurve === 'soft' || hand.alphaCurve === 'bleed' ? 0.72 : 1;
// the hand table's multipliers are wrist character, not size inflation:
// renormalize the nominal w/len so a glaze wrist lays the SAME mark the
// plain hand would, only softer/wider-edged — no bowling-pin trunks
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean; // impasto (capsule radius w*k/2), wet (own math below)
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(2.0, Math.max(1, 1 / Math.max(0.25,
((hand.strokeLength[0] + hand.strokeLength[1]) / 2) *
((hand.dabSize[0] + hand.dabSize[1]) / 2))));
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);
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));
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, handW(w), c, a, ang, squash);
handDab(cc, jr, hand, {
x, y, len: 0, ang, w: handW(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, handL(len), handW(w), c, a, ang);
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(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, the far 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)));
};
// HERO bleed budget: only the mouille hand spends it (≤6 single calls),
// and only where a wet halo earns its keep.
let heroBudget = isWet && (dusk || night || golden) ? 6 : 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 orchard's year -------------------------------------------------
let bloom; // 'early' | 'full' | 'late' | 'fruit' | 'bare'
if (season === 'winter') bloom = 'bare';
else if (season === 'autumn') bloom = 'fruit';
else if (season === 'summer') bloom = 'late'; // summer plays late spring
else {
const u = rng(); // spring itself is diced: buds, full bloom, petal-fall
bloom = u < 0.25 ? 'early' : u < 0.78 ? 'full' : 'late';
}
// -- palettes -------------------------------------------------------------
const softK = { clear: 1, mist: 0.88, overcast: 0.78, rain: 0.75, snow: 0.85 }[weather] || 1;
const sg = skyGradient(cond);
const skyMid = sg[1].color;
// the grass: fresh spring green, luminous in light, cool in shade
// (freshen FIRST, then let the hour dim — the night must keep the dark)
const gp = groundPalette(season, cond.warmth);
const dimG = (h) => night ? mixHex(h, '#2e3a56', 0.48)
: dusk ? mixHex(h, '#6a5468', 0.20)
: dawn ? mixHex(h, '#d09880', 0.10)
: golden ? mixHex(h, '#d8a860', 0.12)
: h;
const G_LIT = gp.light.map((h) => mixHex(mixHex(mixHex(h, '#c8dc60', 0.30), '#dce87c', 0.18), '#e0ec7c', 0.15)).map(dimG); // luminous yellow-green
const G_MID = gp.mid.map((h) => mixHex(h, '#7fae48', 0.30)).map(dimG); // fresh, not olive
const G_SHADE = gp.shade.map((h) => mixHex(h, '#4e7a74', 0.26)).map(dimG); // cool blue-green
// the blossom: pink-white broken tones; the hour leans the whole cloud
const gradeB = (col) => {
let out = col;
if (night) out = mixHex(out, '#b8c0d6', 0.42); // pale ghosts
else if (dusk) out = mixHex(out, '#c0b4cc', 0.26);
else if (golden) out = mixHex(out, '#f2c088', 0.26); // peach-gold
else if (dawn) out = mixHex(out, '#f4bcac', 0.16); // pink first light
else if (noon) out = mixHex(out, '#fff4ee', 0.10); // maximum colour
if (stormy) out = mixHex(out, '#a8a8a6', 0.12);
if (misty) out = mixHex(out, '#e2e6e2', 0.08);
return out;
};
const B_CORE = ['#f9f1e8', '#f6e3e2', '#f3d4d8'].map(gradeB); // warm white, cream, blush
const B_EDGE = ['#f0c3ce', '#eab4c4', '#f4d8da'].map(gradeB);
const B_DEEP = ['#d8a0b4', '#c68ea6'].map(gradeB);
const B_SHADE = gradeB('#c298ae');
const BUD = gradeB('#e09cb0');
const HI = gradeB(night ? '#d8dce8' : golden ? '#ffe8c0' : '#fdf8f0'); // brightest accents
const FOLIAGE = ['#8aa04e', '#a0ac52', '#7c9446'].map(dimG); // the fruit state
const APPLE = ['#b83a24', '#c84830', '#a83222'];
// trunks: dark brown-violet, the darkest register
const TRUNK = (['#4e3844', '#3e2c38', '#5a4048'].map((h) =>
night ? mixHex(h, '#1c1620', 0.45) : dusk ? mixHex(h, '#2a2030', 0.22) : h));
const BRT = night ? mixHex('#3a2a34', '#1c1620', 0.4) : '#3a2a34';
// -- geometry: horizon, ground plane, light -------------------------------
const hzF = cond.horizonY;
const hzY = H * hzF;
const vanX = W * (0.42 + rng() * 0.16);
const hor = new Horizon(hzY, vanX, 1.75, H);
const sunLeft = cond.sunX < 0.5;
const shDx = sunLeft ? 1 : -1; // shadows flee the light
const longK = golden ? 2.4 : (dawn || dusk) ? 1.4 : night ? 0.9 : noon ? 0.45 : 0.6;
const breeze = (rng() - 0.5) * 1.0; // the light wind, in radians
const dirX = breeze >= 0 ? 1 : -1;
// -- THE PRIME: sky above, grass below, painted once ----------------------
{
const g = ctx.createLinearGradient(0, 0, 0, hzY + 1);
g.addColorStop(0, sg[0].color);
g.addColorStop(Math.min(0.97, hzF * 0.6), sg[1].color);
g.addColorStop(1, sg[2].color);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, hzY + 1);
const gTop = mixHex(sg[2].color, G_MID[0], 0.62); // the far grass hazes into the sky
const g2 = ctx.createLinearGradient(0, hzY, 0, H);
g2.addColorStop(0, gTop);
g2.addColorStop(1, mixHex(G_MID[1], G_LIT[1], 0.35));
ctx.fillStyle = g2;
ctx.fillRect(0, hzY, W, H - hzY);
}
// -- THE SKY: broken pale blue, wispy clouds high, the light source -------
for (let i = 0, n = Math.round(240 * covK); i < n; i++) {
const y = Math.pow(rng(), 1.15) * hzY;
const col = y < hzY * 0.45
? mixHex(sg[0].color, sg[1].color, rng())
: mixHex(sg[1].color, sg[2].color, rng());
stamped(0.010 + 0.055 * (y / Math.max(1, hzY)) + rng() * 0.005,
veil(rng() * W, y, W * (0.03 + rng() * 0.08), (rng() - 0.5) * 0.3,
Math.max(2, H * (0.012 + rng() * 0.022)), col, (0.06 + rng() * 0.10) * softK));
}
const cloudCol = night ? mixHex('#5a647c', sg[2].color, 0.25)
: mixHex(sg[2].color, '#ffffff', 0.42);
for (let i = 0, nC = 2 + Math.floor(rng() * 3); i < nC; i++) {
const cy0 = H * (0.05 + rng() * 0.17), cx0 = rng() * W;
const nW = 7 + Math.floor(rng() * 9);
for (let k = 0; k < nW; k++) {
stamped(0.070 + 0.018 * (k / nW) + rng() * 0.004,
veil(cx0 + (rng() - 0.5) * W * 0.17, cy0 + (rng() - 0.5) * H * 0.022,
W * (0.03 + rng() * 0.07), (rng() - 0.5) * 0.06, Math.max(1.5, H * (0.005 + rng() * 0.010)),
cloudCol, (0.09 + rng() * 0.13) * softK));
}
}
// sun or moon, painted early so the crowns may pass in front of it
{
const sx = cond.sunX * W, sy = cond.sunY * H;
if (cond.sunVisible) {
const glowFn = heroBudget > 0 && golden ? heroGlow(sx, sy, W * 0.11, '#f6e0a8', 0.12) : null;
if (glowFn) stamped(0.096, glowFn);
else {
stamped(0.096, veil(sx, sy, W * 0.13, 0, W * 0.075, '#f6e0a8', 0.14 * softK));
stamped(0.096, veil(sx, sy, W * 0.075, 0, W * 0.045, '#f8e6b0', 0.16 * softK));
}
for (let k = 0; k < 3; k++) {
stamped(0.100 + k * 0.001, touch(sx + (rng() - 0.5) * W * 0.004, sy + (rng() - 0.5) * W * 0.004,
W * (0.013 + rng() * 0.006), '#f8e8b4', (0.60 + rng() * 0.25) * softK));
}
} else { // the moon over the orchard
const glowFn = heroBudget > 0 ? heroGlow(sx, sy, W * 0.09, '#c8d2e2', 0.10) : null;
if (glowFn) stamped(0.096, glowFn);
else {
stamped(0.096, veil(sx, sy, W * 0.10, 0, W * 0.06, '#c8d2e2', 0.12 * softK));
stamped(0.096, veil(sx, sy, W * 0.055, 0, W * 0.035, '#dde4ee', 0.14 * softK));
}
for (let k = 0; k < 2; k++) {
stamped(0.100 + k * 0.001, touch(sx, sy, W * (0.009 + rng() * 0.003), '#e8edf4', 0.75 * softK));
}
}
}
// the far distance: a breath of haze sitting on the horizon
for (let i = 0; i < 16; i++) {
stamped(0.086 + rng() * 0.008, veil(rng() * W, hzY - H * 0.025 + rng() * H * 0.04,
W * (0.10 + rng() * 0.20), 0, Math.max(2, H * 0.016),
mixHex(sg[2].color, '#e8ece8', 0.4), (0.07 + rng() * 0.08) * softK * (night ? 0.35 : 1)));
}
// -- THE GRASS: broken green laid far to near, light pools, value bands ---
const pools = [];
for (let i = 0, nP = 2 + Math.floor(rng() * 2); i < nP; i++) {
pools.push({ x: rng(), y: 0.30 + rng() * 0.62, sx: 0.13 + rng() * 0.12, sy: 0.09 + rng() * 0.08 });
}
const litAt = (xn, yn) => {
let v = 0;
for (const p of pools) {
const dx = (xn - p.x) / p.sx, dy = (yn - p.y) / p.sy;
v += Math.exp(-(dx * dx + dy * dy));
}
return Math.min(1, v);
};
for (let i = 0, nG = Math.round(920 * covK); i < nG; i++) {
const yn = Math.pow(rng(), 0.72); // 0 far .. 1 near
const xn = rng();
const y = hzY + (H - hzY) * yn, x = xn * W;
const lv = litAt(xn, yn);
let col = lv > 0.45 ? mixHex(pick(G_MID), pick(G_LIT), 0.55 + lv * 0.4)
: lv < 0.25 ? mixHex(pick(G_SHADE), pick(G_MID), rng() * 0.5)
: mixHex(pick(G_MID), rng() < 0.5 ? G_LIT[0] : G_SHADE[1], rng() * 0.35);
if (night && rng() < 0.4) col = mixHex(col, '#c8d4e4', 0.16); // moonlight silvering
const hazeX = misty ? 0.14 + (1 - yn) * 0.30 : dawn ? 0.06 + (1 - yn) * 0.12
: (1 - yn) * 0.05;
col = mixHex(col, aerialFade(yn).haze, hazeX);
const al = (0.10 + rng() * 0.16) * (0.55 + 0.45 * yn) * softK;
const len = W * (0.022 + rng() * 0.050) * (0.5 + yn);
stamped(0.105 + 0.115 * yn + rng() * 0.004, smear(x, y, len, (rng() - 0.5) * 0.24,
Math.max(1.2, H * 0.006 * (0.6 + yn)), col, al, 0.35));
}
// mowing lines: subtle value bands across the field
const nBands = 3 + Math.floor(rng() * 3);
for (let b = 0; b < nBands; b++) {
const dB = 0.14 + (b / nBands) * 0.66 + rng() * 0.05;
const by = hor.computeGroundPlane(W / 2, dB, H).y;
const toneUp = rng() < 0.5;
for (let i = 0, nI = Math.round(10 + 26 * dB); i < nI; i++) {
const x = rng() * W, y = by + (rng() - 0.5) * H * 0.008;
const col = toneUp ? mixHex(pick(G_MID), pick(G_LIT), 0.22 + rng() * 0.18)
: mixHex(pick(G_MID), pick(G_SHADE), 0.25 + rng() * 0.2);
stamped(0.222 + 0.036 * (b / nBands) + rng() * 0.005, smear(x, y,
W * (0.03 + rng() * 0.05) * (0.4 + dB), (rng() - 0.5) * 0.14,
Math.max(1.4, H * 0.007 * (0.5 + dB)), col, (0.09 + rng() * 0.08) * softK, 0.3));
}
}
// wheel ruts: two converging value lines into the vanishing region
if (rng() < 0.7) {
const rutNear = W * (0.05 + rng() * 0.05);
for (const s of [-1, 1]) {
for (let i = 0; i < 14; i++) {
const d = 0.10 + (i / 13) * 0.82;
const q = hor.computeGroundPlane(vanX + s * rutNear, d, H);
const col = mixHex(pick(G_SHADE), '#5c5248', 0.30 + rng() * 0.15);
stamped(0.264 + (i / 14) * 0.022 + rng() * 0.003, smear(q.x, q.y,
W * 0.020 * (0.4 + d), (rng() - 0.5) * 0.10, Math.max(1.0, H * 0.004 * (0.5 + d)),
col, (0.10 + 0.07 * d) * softK, 0.4));
}
}
}
// -- THE ORCHARD: alleys of blossom trees receding into the field ---------
// two or three grass alleys run toward the vanishing region; each carries
// two or three trees at shrinking depths, the lines converging like the
// wheel ruts. Trees are painted strictly back to front.
const totalTrees = 4 + Math.floor(rng() * 6); // 4-9
const nAlleys = totalTrees <= 5 ? 2 : 3;
const ALLEY_U = [0.15 + rng() * 0.15, 0.43 + rng() * 0.14, 0.72 + rng() * 0.15];
const rot = Math.floor(rng() * nAlleys);
const perAlley = Math.ceil(totalTrees / nAlleys);
const planned = [];
for (let i = 0; i < totalTrees; i++) {
const a = i % nAlleys;
const j = Math.floor(i / nAlleys);
const d = clamp(0.84 - j * (0.58 / perAlley) - a * 0.045
+ (rng() - 0.5) * 0.06, 0.14, 0.94);
planned.push({ u: ALLEY_U[(a + rot) % 3] + (rng() - 0.5) * 0.05, d });
}
planned.sort((p, q) => p.d - q.d); // far first, like the brush arriving
// keep neighbours apart: no two trees of similar depth stack into one mass
for (let i = 1; i < planned.length; i++) {
for (let tries = 0; tries < 3; tries++) {
const qa = hor.computeGroundPlane(planned[i].u * W, planned[i].d, H);
const ra = W * 0.150 * qa.scale;
let pushed = false;
for (let j = 0; j < i; j++) {
const qb = hor.computeGroundPlane(planned[j].u * W, planned[j].d, H);
const rb = W * 0.150 * qb.scale;
if (Math.abs(qa.x - qb.x) < (ra + rb) * 0.45 && Math.abs(planned[i].d - planned[j].d) < 0.22) {
planned[i].u = clamp(planned[i].u + (planned[i].u < 0.5 ? -0.10 : 0.10), -0.04, 1.04);
pushed = true;
break;
}
}
if (!pushed) break;
}
}
const trees = [];
let nearHero = null; // the largest near tree, for the ladder
const P0 = 0.300, P1 = 0.648;
for (let i = 0; i < planned.length; i++) {
const { u, d } = planned[i];
const q = hor.computeGroundPlane(clamp(u, -0.04, 1.04) * W, d, H);
const sc = q.scale;
const r = Math.max(5, W * 0.150 * sc * (0.82 + rng() * 0.36));
const trunkH = r * (d < 0.3 ? 0.55 + rng() * 0.15 : 0.72 + rng() * 0.26);
const cx = q.x, baseY = q.y, cy = baseY - trunkH - r * 0.38;
const sub = (P1 - P0) / planned.length;
const pB = P0 + sub * i;
const st = (k) => pB + sub * clamp(k, 0, 0.98) + rng() * 0.0004;
trees.push({ cx, cy, r, baseY, d });
if (d > 0.6 && (!nearHero || r > nearHero.r)) nearHero = { cx, baseY, r, d };
const af = aerialFade(d);
const hazeK = clamp(af.desat + (misty ? 0.12 + d * 0.34 : dawn ? 0.04 + d * 0.12 : 0), 0, 0.85);
const alK = af.alpha * softK;
// the ground shadow: a soft cool mass fleeing the light — rides veil()
// so every hand lays it quiet, never a row of hard dark ellipses
if (r > 8 && !night) {
const shLen = r * (0.9 + longK * 0.9);
const nSh = Math.max(4, Math.round(9 * (0.4 + d)));
for (let k = 0; k < nSh; k++) {
const t = Math.pow(rng(), 0.8);
const sx2 = cx + shDx * t * shLen + (rng() - 0.5) * r * 0.5;
const sy2 = baseY + H * 0.004 + t * H * 0.012 * (0.4 + d) + (rng() - 0.5) * H * 0.006;
if (sy2 > H - 2) continue;
const scol = mixHex(pick(G_SHADE), '#4a6a78', 0.30 + rng() * 0.2);
stamped(st(0.02 + 0.10 * (k / nSh)), veil(Math.min(W, sx2), Math.min(H - 2, sy2),
shLen * 0.26 * (1.3 - t), (rng() - 0.5) * 0.10, Math.max(1.6, r * 0.16),
scol, (0.13 - t * 0.07) * alK * (noon ? 1.4 : 1)));
}
}
// the trunk: a dark brown-violet column. STRUCTURAL — it rides veil()
// (translucent soft slabs, layered to near-opaque) so the form closes
// in EVERY hand, even the skipping dry one; hand-routed touches add
// texture on top but never carry the form
if (r > 7) {
const tw0 = r * 0.115; // half-width at the base
const tcol0 = mixHex(pick(TRUNK), af.haze, hazeK * 0.6);
stamped(st(0.125), veil(cx, baseY - trunkH * 0.52, trunkH * 1.12, -Math.PI / 2,
Math.max(1.0, tw0 * 0.70), tcol0, 0.50 * alK)); // the continuous spine
const nT = 5;
for (let k = 0; k < nT; k++) {
const t = (k + 0.5) / nT;
const ty = baseY - trunkH * t;
const fw = tw0 * (2.6 - t * 1.0); // full width, tapering upward
stamped(st(0.13 + 0.05 * k), veil(cx + (rng() - 0.5) * r * 0.02 * t, ty,
trunkH / nT * 2.4, -Math.PI / 2, Math.max(1.0, fw * 0.5),
mixHex(tcol0, '#2c1e28', t * 0.12), (0.48 + rng() * 0.12) * alK));
}
for (let k = 0; k < 4; k++) { // the hand's texture over the spine
const t = 0.15 + rng() * 0.7;
stamped(st(0.24 + 0.03 * k), touch(cx + (rng() - 0.5) * tw0 * 0.8,
baseY - trunkH * t, Math.max(1.2, tw0 * 0.9),
mixHex(tcol0, '#1c141c', 0.35), 0.45 * alK, 0, 0.8));
}
stamped(st(0.30), veil(cx + (sunLeft ? -1 : 1) * tw0 * 0.45, baseY - trunkH * 0.45,
trunkH * 0.42, -Math.PI / 2, Math.max(0.9, tw0 * 0.45),
mixHex(pick(TRUNK), '#a8785c', 0.45), 0.22 * alK)); // the lit flank
}
// the branch skeleton: mostly swallowed by the crown — short hair-strokes
// rising INTO it; bare trees wear the whole structure
const nBr = bloom === 'bare' ? 8 + Math.floor(rng() * 5)
: r > 16 ? 2 + Math.floor(rng() * 2) : 0;
for (let k = 0; k < nBr; k++) {
const a = -Math.PI / 2 + (rng() - 0.5) * 1.2;
const bx = cx + (rng() - 0.5) * r * 0.25;
const by = cy + r * (0.30 + rng() * 0.25);
const bl = r * (0.30 + rng() * 0.30) * (bloom === 'bare' ? 2.1 : 1);
const bw = Math.max(0.6, r * 0.022);
stamped(st(0.31 + 0.10 * (k / Math.max(1, nBr))), (cc, jr) =>
rigger(cc, jr, { x: bx, y: by, length: bl, color: mixHex(BRT, af.haze, hazeK * 0.5),
alpha: (bloom === 'bare' ? 0.65 : 0.32) * alK, angle: a, width: bw,
bow: (rng() - 0.5) * r * 0.15 }));
}
// THE CROWN: a cloud of blossom — dense pink-white core dissolving
// at the edge, the underside sitting back, the sky breathing through
if (bloom !== 'bare') {
const lobes = [];
const nL = 3 + Math.floor(rng() * 3);
for (let l = 0; l < nL; l++) lobes.push({
dx: (rng() - 0.5) * r * 0.90,
dy: (rng() - 0.5) * r * 0.50 - r * 0.04,
lr: r * (0.60 + rng() * 0.34),
});
const bloomK = bloom === 'early' ? 0.52 : bloom === 'late' ? 0.66
: bloom === 'fruit' ? 0.8 : 1;
const nD = clamp(Math.round(r * r * 0.030 * bloomK * covK), 14, 620);
for (let k = 0; k < nD; k++) {
const L = lobes[Math.floor(rng() * nL)];
const a = rng() * TAU, rr = Math.pow(rng(), 0.55);
if (rr > 0.88 && rng() < 0.22) continue; // the cloud dissolves
const px = cx + L.dx + Math.cos(a) * rr * L.lr;
const py = cy + L.dy + Math.sin(a) * rr * L.lr * 0.86;
const core = rr < 0.5;
let col = core ? pick(B_CORE) : mixHex(pick(B_EDGE), pick(B_DEEP), rng() * 0.35);
if (Math.sin(a) > 0.42 && rr > 0.3) col = mixHex(col, B_SHADE, 0.22 + rng() * 0.22);
if (bloom === 'late' && rng() < 0.4) col = mixHex(col, '#a8b878', 0.18 + rng() * 0.2);
if (bloom === 'fruit') col = mixHex(col, pick(FOLIAGE), 0.55 + rng() * 0.3);
const size = Math.max(0.9, r * 0.088 * sizeK * (core ? 1.12 : 0.70) * (0.78 + rng() * 0.44));
const al = (core ? 0.68 + rng() * 0.26 : 0.38 + rng() * 0.30)
* alK * inkK * (night ? 0.72 : 1);
stamped(st(0.44 + 0.40 * (k / nD)), touch(px, py, size, col, al));
}
// sky holes: the cloud breathes
for (let k = 0, nH = Math.max(2, Math.round(nD * 0.030)); k < nH; k++) {
const L = lobes[Math.floor(rng() * nL)];
const a = rng() * TAU, rr = 0.25 + rng() * 0.55;
stamped(st(0.48 + 0.28 * rng()), touch(cx + L.dx + Math.cos(a) * rr * L.lr,
cy + L.dy + Math.sin(a) * rr * L.lr * 0.86, Math.max(0.9, r * 0.055),
mixHex(skyMid, af.haze, 0.3 + hazeK * 0.5), 0.24 * alK));
}
// the underside sits back: mauve shade along the cloud's belly
for (let k = 0, nU = Math.max(3, Math.round(nD * 0.09)); k < nU; k++) {
const L = lobes[Math.floor(rng() * nL)];
const a = Math.PI / 2 + (rng() - 0.5) * 1.2, rr = 0.45 + rng() * 0.5;
stamped(st(0.50 + 0.26 * rng()), touch(cx + L.dx + Math.cos(a) * rr * L.lr,
cy + L.dy + Math.sin(a) * rr * L.lr * 0.80, Math.max(0.9, r * 0.070 * sizeK),
B_SHADE, (0.20 + rng() * 0.16) * alK * inkK));
}
// buds (early spring) or apples (the fruit state)
if (bloom === 'early') {
for (let k = 0, nB2 = clamp(Math.round(nD * 0.5), 6, 90); k < nB2; k++) {
const L = lobes[Math.floor(rng() * nL)];
const a = rng() * TAU, rr = 0.3 + rng() * 0.6;
stamped(st(0.86 + 0.07 * (k / nB2)), touch(cx + L.dx + Math.cos(a) * rr * L.lr,
cy + L.dy + Math.sin(a) * rr * L.lr * 0.86, Math.max(0.7, r * 0.038),
BUD, (0.5 + rng() * 0.3) * alK));
}
}
if (bloom === 'fruit') {
for (let k = 0, nA2 = clamp(Math.round(r * 0.10), 4, 16); k < nA2; k++) {
const L = lobes[Math.floor(rng() * nL)];
const a = rng() * TAU, rr = 0.25 + rng() * 0.65;
stamped(st(0.86 + 0.07 * (k / nA2)), touch(cx + L.dx + Math.cos(a) * rr * L.lr,
cy + L.dy + Math.sin(a) * rr * L.lr * 0.86, Math.max(1.0, r * 0.05),
pick(APPLE), (0.55 + rng() * 0.3) * alK));
}
}
// single petals detaching at the cloud's rim
if (bloom === 'full' || bloom === 'late') {
const nE = bloom === 'late' ? 8 + Math.floor(rng() * 6) : 4 + Math.floor(rng() * 4);
for (let k = 0; k < nE; k++) {
const a = rng() * TAU, er = r * (1.0 + rng() * 0.22);
stamped(st(0.93 + 0.05 * (k / nE)), touch(cx + Math.cos(a) * er,
cy + Math.sin(a) * er * 0.9 + (Math.sin(a) > 0 ? r * 0.05 : 0),
Math.max(0.9, r * 0.045), pick(B_EDGE), (0.32 + rng() * 0.24) * alK));
}
}
}
}
// -- THE FARMHOUSE: a mid-distance ochre mass with a tiled roof -----------
{
const dF = 0.26 + rng() * 0.10;
let uF = 0.14 + rng() * 0.72;
for (const T of trees) { // step aside from any farther tree's line
if (T.d < dF && Math.abs(T.cx / W - uF) < 0.16) uF += uF < 0.5 ? 0.22 : -0.22;
}
const qF = hor.computeGroundPlane(clamp(uF, 0.06, 0.94) * W, dF, H);
const scF = Math.max(0.18, qF.scale);
const hw = clamp(W * 0.05 * (scF / 0.2), W * 0.014, W * 0.095);
const hh = hw * (0.52 + rng() * 0.10), roofH = hw * 0.26;
const afF = aerialFade(dF);
const hazeF = clamp(afF.desat * 0.5 + (misty ? 0.14 : 0), 0, 0.6);
let wallC = gradeB('#cfae84');
if (night) wallC = mixHex(wallC, '#232838', 0.45); // the wall sleeps; the window wakes
else if (dusk) wallC = mixHex(wallC, '#3a3244', 0.22);
else if (golden) wallC = mixHex(wallC, '#b08858', 0.18);
const roofC = mixHex(rng() < 0.5 ? '#4e3c38' : '#7c4e3c', afF.haze, hazeF);
const pF = 0.406;
const alF = Math.min(1, (0.75 + rng() * 0.2) * (0.45 + afF.alpha * 0.9) * softK);
for (let rI = 0; rI < 7; rI++) { // the wall
for (let cI = 0; cI < 10; cI++) {
const wx = qF.x - hw / 2 + (cI + 0.5) * (hw / 10) + (rng() - 0.5) * hw * 0.03;
const wy = qF.y - hh + (rI + 0.5) * (hh / 7);
stamped(pF + (rI * 10 + cI) * 0.0004, touch(wx, wy, Math.max(1.5, hw * 0.16),
mixHex(wallC, rng() < 0.3 ? '#b8906a' : '#e2c89e', rng() * 0.25), alF));
}
}
for (let k = 0; k < 6; k++) { // the roof: a tiled triangle, ridge to eave
const t = k / 5;
const halfW = hw * (0.18 + 0.44 * t);
const ry = qF.y - hh - roofH + t * roofH;
const nR = 4 + k * 2;
for (let m = 0; m < nR; m++) {
const rx = qF.x - halfW + ((m + 0.5) * (2 * halfW)) / nR;
stamped(pF + 0.006 + k * 0.0007 + m * 0.00005, smear(rx, ry,
hw * 0.14 * (0.7 + rng() * 0.6), (rng() - 0.5) * 0.08,
Math.max(1.2, roofH * 0.45), mixHex(roofC, '#3c3230', 0.2 + rng() * 0.25), alF, 0.55));
}
}
stamped(pF + 0.011, smear(qF.x, qF.y - hh, hw * 0.55, 0, Math.max(1.0, hh * 0.16), // the eave line
mixHex('#4a3a34', afF.haze, hazeF * 0.5), 0.5 * alF, 0.4));
stamped(pF + 0.012, touch(qF.x + hw * 0.22, qF.y - hh * 0.42, Math.max(1.0, hw * 0.08),
mixHex('#4a3a30', afF.haze, hazeF * 0.5), 0.7 * alF)); // the door
if (night || dusk) { // the lit farmhouse window: the night's hearth
const winX = qF.x - hw * 0.18, winY = qF.y - hh * 0.62;
const glowFn = heroBudget > 0 ? heroGlow(winX, winY, hw * 0.55, '#e8b45a', 0.16) : null;
if (glowFn) stamped(pF + 0.014, glowFn);
else stamped(pF + 0.014, veil(winX, winY, hw * 0.55, 0, hw * 0.3, '#e8b45a', 0.10 * softK));
stamped(pF + 0.016, touch(winX, winY, Math.max(1.2, hw * 0.08), '#f2c268', 0.85));
} else {
stamped(pF + 0.014, touch(qF.x - hw * 0.18, qF.y - hh * 0.62, Math.max(1.0, hw * 0.06),
mixHex('#5a4a3c', sg[2].color, 0.3), 0.5 * afF.alpha));
}
for (let k = 0; k < 4; k++) { // its shadow on the grass: a soft mass
stamped(pF + 0.018 + k * 0.0004, veil(qF.x + shDx * hw * (0.5 + k * 0.2), qF.y + H * 0.004,
hw * 0.45, (rng() - 0.5) * 0.1, Math.max(1.6, hh * 0.34),
mixHex(pick(G_SHADE), '#4a6a78', 0.3), (0.10 - k * 0.015) * softK));
}
}
// -- dice-gated tenants: white sheep, or a figure with a ladder -----------
if (rng() < 0.42) {
const nS = 2 + Math.floor(rng() * 3);
for (let s = 0; s < nS; s++) {
const dS = 0.42 + rng() * 0.30;
const qS = hor.computeGroundPlane((0.08 + rng() * 0.84) * W, dS, H);
const ss = qS.scale;
const bw = Math.max(2.2, W * 0.042 * ss);
const bodyC = night ? mixHex('#ece8dc', '#8a90a8', 0.35) : '#ece8dc';
const bodyD = night ? mixHex('#c4c2cc', '#6a7088', 0.3) : '#cfcabc';
const darkC = night ? mixHex('#3a3440', '#1c1824', 0.3) : '#3a3440';
const pS = 0.652 + s * 0.002;
// the fleece: soft wool masses (veil — the form closes in every hand)
stamped(pS, veil(qS.x - bw * 0.18, qS.y - bw * 0.34, bw * 0.55, 0, bw * 0.42, bodyC, 0.55 * softK));
stamped(pS + 0.0004, veil(qS.x + bw * 0.14, qS.y - bw * 0.30, bw * 0.52, 0, bw * 0.40, bodyC, 0.50 * softK));
stamped(pS + 0.0008, veil(qS.x + bw * 0.40, qS.y - bw * 0.24, bw * 0.44, 0, bw * 0.34, bodyD, 0.42 * softK));
stamped(pS + 0.0012, veil(qS.x - bw * 0.42, qS.y - bw * 0.26, bw * 0.44, 0, bw * 0.34, bodyD, 0.40 * softK));
stamped(pS + 0.0016, touch(qS.x - bw * 0.62, qS.y - bw * 0.44, bw * 0.17, darkC, 0.75 * softK)); // the head
stamped(pS + 0.0020, smear(qS.x - bw * 0.30, qS.y + bw * 0.10, bw * 0.42, Math.PI / 2,
Math.max(0.6, bw * 0.07), darkC, 0.5 * softK, 1)); // the legs
stamped(pS + 0.0024, smear(qS.x + bw * 0.18, qS.y + bw * 0.08, bw * 0.40, Math.PI / 2,
Math.max(0.6, bw * 0.07), darkC, 0.5 * softK, 1));
stamped(pS + 0.0028, veil(qS.x, qS.y + bw * 0.16, bw * 0.75, 0, Math.max(0.8, bw * 0.18),
mixHex(pick(G_SHADE), '#4a6a78', 0.3), 0.10 * softK)); // its shadow
}
} else if (rng() < 0.5 && nearHero) { // the pruner's ladder against the big tree
const T = nearHero;
const lx = T.cx + (rng() < 0.5 ? -1 : 1) * T.r * 0.35;
const topX = T.cx + (lx < T.cx ? -0.08 : 0.08) * T.r;
const topY = T.baseY - T.r * 1.05;
const railW = Math.max(0.7, T.r * 0.014);
const pL = 0.652;
stamped(pL, (cc, jr) => rigger(cc, jr, { x: lx, y: T.baseY, length: Math.hypot(topX - lx, T.baseY - topY),
color: '#6a5638', alpha: 0.6 * softK, angle: Math.atan2(topY - T.baseY, topX - lx), width: railW, bow: 0 }));
stamped(pL + 0.001, (cc, jr) => rigger(cc, jr, {
x: lx + T.r * 0.09, y: T.baseY, length: Math.hypot(topX + T.r * 0.09 - lx, T.baseY - topY),
color: '#6a5638', alpha: 0.6 * softK, angle: Math.atan2(topY - T.baseY, topX + T.r * 0.09 - lx),
width: railW, bow: 0 }));
for (let k = 0; k < 5; k++) { // the rungs
const t = (k + 1) / 6;
stamped(pL + 0.002 + k * 0.0004, smear(lx + (topX + T.r * 0.045 - lx) * t,
T.baseY + (topY - T.baseY) * t, T.r * 0.075, 0, Math.max(0.6, T.r * 0.012), '#6a5638', 0.55 * softK, 1));
}
// the figure: body, head, a steadying arm
const fx = lx + T.r * 0.22, fy = T.baseY - T.r * 0.34;
stamped(pL + 0.006, touch(fx, fy, Math.max(1.1, T.r * 0.065), '#5a6a8a', 0.65 * softK, 0, 1.35));
stamped(pL + 0.0064, touch(fx, fy - T.r * 0.12, Math.max(0.8, T.r * 0.04), '#c89878', 0.65 * softK));
stamped(pL + 0.0068, smear(fx - T.r * 0.045, fy + T.r * 0.08, T.r * 0.09, Math.PI / 2,
Math.max(0.6, T.r * 0.022), '#4a4454', 0.55 * softK, 1));
stamped(pL + 0.0072, smear(fx + T.r * 0.045, fy + T.r * 0.08, T.r * 0.09, Math.PI / 2,
Math.max(0.6, T.r * 0.022), '#4a4454', 0.55 * softK, 1));
stamped(pL + 0.0076, (cc, jr) => rigger(cc, jr, { x: fx - T.r * 0.03, y: fy - T.r * 0.04,
length: T.r * 0.16, color: '#5a6a8a', alpha: 0.5 * softK,
angle: Math.atan2(topY - fy, topX - fx), width: Math.max(0.5, T.r * 0.014), bow: 0 }));
}
// -- FALLING PETALS: the signature accent ---------------------------------
for (const T of trees) {
if (bloom === 'fruit' || bloom === 'bare') break;
let nTP = bloom === 'late' ? 14 + Math.floor(rng() * 10)
: bloom === 'full' ? 6 + Math.floor(rng() * 7)
: bloom === 'early' ? 1 + Math.floor(rng() * 2) : 0;
if (rain) nTP = Math.round(nTP * 0.35); // the rain beats them down
for (let k = 0; k < nTP; k++) {
const t = Math.pow(rng(), 0.7);
const px = T.cx + (rng() * 2 - 1) * T.r * 1.15 + dirX * t * T.r * (1.2 + rng() * 1.6);
const py = clamp(T.cy + T.r * 0.7 + t * (T.baseY - T.cy + H * 0.05) * (0.3 + rng() * 0.7),
T.cy, H - 2);
const size = clamp(1.2 + T.d * 2.8 * (0.6 + rng() * 0.8), 1.2, 4.4);
stamped(0.670 + 0.060 * (k / Math.max(1, nTP)) + rng() * 0.003,
touch(px, py, size, pick(B_EDGE.concat(B_CORE)), (0.6 + rng() * 0.35) * inkK * softK,
breeze * 0.4, 0.55));
if (rng() < 0.3) stamped(0.672 + 0.060 * rng(),
touch(px + dirX * size * 2.2, py + size * 1.6, size * 0.7, pick(B_EDGE),
0.4 * inkK * softK, breeze * 0.4, 0.55));
}
// petals settled on the grass under the crown
if (bloom === 'late' || bloom === 'full' || rain) {
const nGP = rain ? 10 + Math.floor(rng() * 8) : bloom === 'late' ? 6 : 3;
for (let k = 0; k < nGP; k++) {
const gx = T.cx + (rng() * 2 - 1) * T.r * 1.1;
const gy = T.baseY + rng() * H * 0.03;
if (gy > H - 2) continue;
stamped(0.736 + rng() * 0.020, touch(gx, gy, clamp(1.2 + T.d * 2.4 * (0.6 + rng()), 1.2, 4),
pick(B_CORE), (0.35 + rng() * 0.25) * softK, breeze * 0.3, 0.35));
}
}
}
// ambient petals drifting between the rows
if (bloom === 'full' || bloom === 'late' || bloom === 'early') {
const nA = bloom === 'late' ? 30 : bloom === 'full' ? 20 : 6;
for (let k = 0; k < nA; k++) {
const ax = rng() * W, ay = hzY + rng() * (H - hzY) * 0.8;
stamped(0.760 + rng() * 0.018, touch(ax, ay, 0.9 + rng() * 1.4,
rng() < 0.5 ? pick(B_CORE) : pick(B_EDGE), (0.4 + rng() * 0.3) * softK, breeze * 0.4, 0.55));
}
}
if (rain) { // the sheen: rain-wet mowing lines catching the pale light
for (let k = 0; k < 24; k++) {
const yn = 0.2 + rng() * 0.8;
stamped(0.780 + rng() * 0.010, smear(rng() * W, hzY + (H - hzY) * yn,
W * (0.02 + rng() * 0.04) * (0.4 + yn), (rng() - 0.5) * 0.1,
Math.max(1.0, H * 0.004 * (0.5 + yn)), '#d8e4e6', (0.07 + rng() * 0.07) * softK, 0.25));
}
}
// -- THE HOUR'S SIGNATURES -------------------------------------------------
if (dawn || misty) { // mist pooling between the rows
for (let k = 0, nM = 26; k < nM; k++) {
const dM = 0.15 + rng() * 0.55;
const qM = hor.computeGroundPlane(rng() * W, dM, H);
stamped(0.800 + rng() * 0.018, veil(qM.x, qM.y, W * (0.10 + rng() * 0.20),
(rng() - 0.5) * 0.1, Math.max(2, H * (0.02 + rng() * 0.05)), '#e8ece8',
(0.05 + rng() * 0.07) * softK));
}
}
if (dawn) { // pink first light on the lit flanks of the crowns
for (const T of trees) {
if (T.r < 14 || bloom === 'bare') continue;
for (let k = 0; k < 5; k++) {
const a = (sunLeft ? Math.PI : 0) + (rng() - 0.5) * 1.4;
const rr = 0.35 + rng() * 0.6;
stamped(0.818 + rng() * 0.012, touch(T.cx + Math.cos(a) * rr * T.r,
T.cy + Math.sin(a) * rr * T.r * 0.9, Math.max(1.2, T.r * 0.06),
mixHex(pick(B_CORE), '#f4bcac', 0.5), (0.10 + rng() * 0.10) * softK));
}
}
}
if (golden) { // the warm pooling of low light on the grass
for (let k = 0; k < 24; k++) {
const gx = sunLeft ? rng() * W * 0.55 : W * (0.45 + rng() * 0.55);
stamped(0.812 + rng() * 0.016, veil(gx, hzY + rng() * (H - hzY) * 0.85,
W * (0.04 + rng() * 0.09), (rng() - 0.5) * 0.2, Math.max(2, H * 0.012),
'#f0c880', (0.04 + rng() * 0.05) * softK));
}
}
if (night || dusk) { // moonlight silvering the grass
for (let k = 0; k < 30; k++) {
const yn2 = Math.pow(rng(), 0.8);
stamped(0.806 + rng() * 0.018, veil(rng() * W, hzY + (H - hzY) * yn2,
W * (0.02 + rng() * 0.05) * (0.4 + yn2), (rng() - 0.5) * 0.1,
Math.max(1.4, H * 0.006 * (0.5 + yn2)), mixHex(pick(G_LIT), '#c8d4e4', 0.5),
(0.04 + rng() * 0.05) * softK));
}
}
// -- the unifying breath ----------------------------------------------------
for (let i = 0; i < 20; i++) {
const y = rng() * H;
const col = y < hzY ? mixHex(sg[1].color, sg[2].color, rng() * 0.5)
: mixHex(pick(G_MID), pick(G_LIT), rng() * 0.4);
stamped(0.858 + rng() * 0.016, 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 * (hand.mode === 'impasto' ? 0.45 : 1)));
}
// -- the last notes: blossom highlights, the brightest accents ---------------
for (let i = 0, n = Math.round(90 * covK); i < n && trees.length; i++) {
const T = trees[Math.floor(rng() * trees.length)];
if (T.r < 10 || bloom === 'bare' || bloom === 'fruit') continue;
const a = -Math.PI / 2 + (sunLeft ? -1 : 1) * (0.3 + rng() * 1.0) + (rng() - 0.5) * 0.8;
const rr = 0.35 + rng() * 0.65;
stamped(0.884 + rng() * 0.050, touch(T.cx + Math.cos(a) * rr * T.r,
T.cy + Math.sin(a) * rr * T.r * 0.9, Math.max(0.8, T.r * 0.05), HI,
(0.28 + rng() * 0.22) * softK));
}
for (let i = 0, n = Math.round(50 * covK); i < n; i++) { // luminous grass tips
const yn = 0.55 + rng() * 0.45;
stamped(0.936 + rng() * 0.032, smear(rng() * W, hzY + (H - hzY) * yn,
W * (0.014 + rng() * 0.026) * (0.5 + yn), (rng() - 0.5) * 0.3,
Math.max(1.0, H * 0.005 * (0.6 + yn)), mixHex(pick(G_LIT), '#e8f088', 0.35),
(0.16 + rng() * 0.16) * softK, 0.4));
}
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