Commit 004704eb by PLN (Algolia)

impression: nineteen canvases — chateau, forest, market

parent 758057fc
// Sous bois — the forest interior of Fontainebleau, the Barbizon school's own
// woods: Rousseau's and Diaz's trees, where the Impressionists learned that
// light is a thing the leaves do. We stand INSIDE the wood, looking up and
// through. Great trunks rise full-frame — cropped top and bottom, so the
// forest reads as a cathedral of green and gold — one massive near trunk at
// the frame edge (the classic Fontainebleau composition), its bark laid in
// broken grey-olive planes with pale lichen. The canopy is the upper third: a
// layered ceiling of leaf masses in every green — yellow-green where light
// pours through, emerald mid, blue-green depths — opened by LIGHT HOLES, real
// dice-placed gaps where the sky burns through as pale gold-white. Below, the
// carpet: leaf litter in russet-ochre under green shade, broken light pools
// soft-edged between deep blue-green shadow, ferns as small radiating stroke
// fans, a fallen branch or mossy boulder for an anchor. Dapple is the soul.
//
// DICE (init stream, after the conditions stream). 5-12 trunks — count,
// spacing and width all rolled, never a colonnade: one massive edge trunk
// always, thin far saplings for depth rhythm. 2-5 light holes, irregular,
// placed clear of the trunks. 2-4 floor pools. 3-8 ferns. A fallen branch or a
// mossy boulder (or nothing). Visible light shafts are rare — clear skies, an
// obliging hour, 1-2 beams with dust in them. Life, sparse and dice-gated: a
// roe deer or a wood pigeon, a distant figure on a faint path, a woodcutter's
// stack of cut logs.
//
// CONDITIONS. The hour transforms the wood: dawn lays cold blue mist between
// the trunks, the light still arriving; noon is maximum dapple — hot gold
// pools, hard shadow; goldenHour rakes amber light UNDER the canopy from the
// edge; dusk sinks the wood into violet; night goes blue-black, the trunks
// silhouettes and the FIREFLIES (2-6, dice) the only lights. The season is the
// wood's year: spring is fresh light green with floor anemones as tiny white
// dabs; summer is heavy deep shade; autumn turns the canopy gold-russet with
// leaves falling; winter thins the ceiling and pales the floor. Weather: rain
// makes everything WET — bark glossy with specular streaks, pools on the floor
// mirroring the canopy; mist dissolves the depths while the near trunks stay
// strong; overcast boils the canopy and picks ONE trunk for a stark rim light.
//
// SKELETON AND HAND: the load-bearing strokes — the prime, the canopy's depth
// masses, the trunk fills, the light holes' cores, the pool cores — ride the
// plain house stroke (mer precedent: masses plain, accents through the hand),
// because a pointillisme wrist must not starve the tonal spine. Every
// expressive touch — bark crevices, lichen, leaf dabs, dapple, ferns, moss —
// routes through brushes.handFor(conditions.technique) + handDab.
//
// Contract: identical to scenes/loire.js — every stroke precomputed in init()
// with a fixed progress stamp, draw() paints exactly the stamps in [from, to),
// the persistent canvas accumulates, same seed -> same painting. Per-stroke
// jitter and weather touches ride their own mulberry32 streams (the `jr`
// handed to each stroke closure); NOTHING else consumes randomness at draw
// time. TONAL SPINE (pixel-verified): light holes and dapple pools brightest >
// canopy mids layered > trunks darkest-mid > floor mid-dark.
import {
mulberry32, makeConditions, weatherOverlay,
mix as mixHex, lerp, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, brokenColor,
dab, roundDab, rigger, stroke,
} from '../brushes.js';
let strokes = null;
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
// The canopy's green, season by season: [two deeps, two mids, two lights, two
// bursts]. The bursts only live where a hole pours light through the leaves.
const CANOPY = {
spring: {
deep: ['#3a5a30', '#2f4c2a'], mid: ['#5e8040', '#527438'],
light: ['#8ab04e', '#9cbc56'], burst: ['#b2cc60', '#c4d870'],
},
summer: {
deep: ['#2a4224', '#233a20'], mid: ['#48642e', '#3e5a2a'],
light: ['#688236', '#748c3c'], burst: ['#8ca242', '#9cb44e'],
},
autumn: {
deep: ['#54421e', '#463818'], mid: ['#84642a', '#8e7030'],
light: ['#b48a3a', '#c29844'], burst: ['#d0a850', '#dcb85c'],
},
winter: {
deep: ['#323a2e', '#2a322a'], mid: ['#4e5842', '#566048'],
light: ['#6e7858', '#788260'], burst: ['#828c64', '#8e986e'],
},
};
// The sky's burn through a hole, hour by hour.
const HOLE_C = {
dawn: '#eae6d6', morning: '#f4eed8', noon: '#faf5e0',
goldenHour: '#f6d190', dusk: '#dca874', night: '#9aa6bc',
};
// The leaf litter under green shade, season by season.
const LITTER = {
spring: ['#8a7442', '#7a6a3e', '#9a8450'],
summer: ['#8a6a3e', '#7a5c36', '#96784a'],
autumn: ['#96602e', '#a87438', '#7c5228'],
winter: ['#6e5a40', '#5e4e38', '#7a664a'],
};
// Bark: grey-olive planes, the darkest-mid register. Night turns the trunk a
// silhouette of itself.
const BARK = ['#4e483e', '#403a30', '#584e40', '#4a4034'];
export default {
id: 'bois',
title: 'Sous bois',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Sous bois, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1887), 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', morning = tod === 'morning';
const misty = weather === 'mist', rain = weather === 'rain';
const stormy = weather === 'overcast', snowy = weather === 'snow';
const spring = season === 'spring', autumn = season === 'autumn';
const summer = season === 'summer';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
// one jitter stream per stroke; weather touches ride ~12% of strokes
const stamped = (p, fn) => {
const jr = mulberry32((0x1B015 + Math.floor(rng() * 0xffffff) * 691) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; the scene keeps painting */ }
}
});
};
// -- THE HAND ----------------------------------------------------------
const hand = handFor(cond.technique);
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean;
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(1.5, Math.max(1, 1 / Math.max(0.25, slMean * kMean)));
const inkK = hand.alphaCurve === 'soft' ? 2.3 : hand.alphaCurve === 'bleed' ? 1.7
: hand.alphaCurve === 'matte' ? 1.25 : 1;
// touch and smear paint DIRECTLY (canvas first, the stamp's own jr second)
const touch = (cc, jr, x, y, w, c, a, ang = 0, squash = 1) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: 0, ang, w: handW(w), c, a: aa, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: aa, angle: ang, squash }),
});
};
const smear = (cc, jr, x, y, len, ang, w, c, a, squash = 1) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(w), c, a: aa, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, c, aa),
});
};
// the soft air — mist, glow spill, the unifying breath — stays translucent
// in every hand; passed straight to stamped() as the stroke closure
const veil = (x, y, len, ang, w, c, a, squash = 0.5) => (cc) => {
const half = Math.max(1, len * 0.5);
const ab = hand.alphaBase;
dab(cc, x, y, half, c, a * ab, ang, clamp(squash, 0.06, 8));
dab(cc, x, y, half * 1.45, c, a * 0.34 * ab, ang, clamp(squash * 0.92, 0.06, 8));
};
// the skeleton stroke, hand-independent: the composition must survive
// every wrist (mer precedent — masses plain, accents through the hand)
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.35,
});
// column — a full-height vertical mass as a chain of overlapping dabs:
// stroke() caps its resampling at 400 steps, which dots out a long thin
// band, so the trunks are laid by hand instead. PAINT. No rng: structural.
const column = (c, x0, y0, x1, y1, w, col, a) => {
const d = Math.hypot(x1 - x0, y1 - y0);
const n = Math.max(2, Math.ceil(d / Math.max(0.8, w * 0.28)));
for (let k = 0; k <= n; k++) {
dab(c, lerp(x0, x1, k / n), lerp(y0, y1, k / n), Math.max(0.9, w * 0.55), col, a, 0, 1);
}
};
// -- palettes ------------------------------------------------------------
const softK = { clear: 1, mist: 0.88, overcast: 0.78, rain: 0.75, snow: 0.85 }[weather] || 1;
const HOLE = HOLE_C[tod] || HOLE_C.morning;
const POOL = mixHex(HOLE, '#f0cc7e', night ? 0.12 : 0.42);
const CAN = CANOPY[season] || CANOPY.summer;
const LIT = LITTER[season] || LITTER.summer;
const grade = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#161e2e', 0.46 * k);
else if (dusk) o = mixHex(o, '#3c3248', 0.26 * k);
else if (dawn) o = mixHex(o, '#8a94ac', 0.18 * k);
else if (golden) o = mixHex(o, '#d8a060', 0.15 * k);
else if (noon) o = mixHex(o, '#f4eeda', 0.04 * k);
if (misty) o = mixHex(o, '#dfe4e0', 0.10 * k);
if (stormy) o = mixHex(o, '#6a7078', 0.12 * k);
if (rain) o = mixHex(o, '#7a8894', 0.10 * k);
if (snowy) o = mixHex(o, '#d8e0e6', 0.10 * k);
return o;
};
// summer's heavy shade: the whole wood sits a step down
const shadeK = summer ? 0.14 : 0;
const dimLeaf = (h) => shadeK > 0 ? mixHex(h, '#182616', shadeK) : h;
const DEEP = CAN.deep.map((h) => dimLeaf(grade(h)));
const MID = CAN.mid.map((h) => dimLeaf(grade(h)));
const LIGHT = CAN.light.map((h) => dimLeaf(grade(h)));
const BURST = CAN.burst.map((h) => dimLeaf(grade(h)));
const FSH = ['#2c3a2e', '#253228', '#334232'].map((h) => grade(h)); // floor shadow, blue-green
const BARKS = BARK.map((h) => grade(night ? mixHex(h, '#141820', 0.55) : h));
const FERN_C = grade(season === 'winter' ? '#5c5038' : '#2e4c2e');
const FERN_L = grade(season === 'winter' ? '#78684a' : '#4a6c3a');
// -- geometry ------------------------------------------------------------
const sunX = cond.sunX * W;
const F = H * (0.62 + rng() * 0.08); // the floor line (the carpet's horizon)
const floorRange = H - F;
// -- THE PRIME: forest dark, vertical only — darkest overhead, a pale
// green breath of depth in the middle distance, litter brown below --------
const prime = (c) => {
const g = c.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, grade(mixHex(CAN.deep[0], '#10160d', 0.42)));
g.addColorStop(0.14, grade(mixHex(CAN.deep[0], '#141a10', 0.22)));
g.addColorStop(0.38, grade(mixHex(CAN.mid[1], '#3c4a2c', 0.30)));
g.addColorStop(Math.max(0.45, F / H - 0.03), grade(mixHex(CAN.mid[1], '#8a905e', 0.18)));
// the carpet arrives slowly: green shade lingers into the litter brown
g.addColorStop(Math.min(0.985, F / H + 0.005), grade(mixHex(LIT[1], CAN.deep[0], 0.45)));
g.addColorStop(Math.min(0.99, F / H + 0.06), grade(mixHex(LIT[1], '#2a2216', 0.34)));
g.addColorStop(1, grade(mixHex(LIT[0], '#241c12', 0.30)));
c.fillStyle = g;
c.fillRect(0, 0, W, H);
};
prime(ctx);
add(0.001, prime);
// -- THE LIGHT HOLES (dice): the composition's lights ----------------------
const nHoles = (night ? 2 : 3) + Math.floor(rng() * 3); // 2-5
const holes = [];
for (let i = 0; i < nHoles; i++) {
for (let tries = 0; tries < 8; tries++) {
const hx = 0.10 + rng() * 0.80, hy = 0.05 + rng() * 0.24;
const hr = 0.038 + rng() * 0.062;
if (holes.every((h) => Math.hypot(h.x - hx, h.y - hy) > h.r + hr + 0.05)) {
holes.push({ x: hx, y: hy, r: hr });
break;
}
}
}
const holeGlow = (xn, yn) => {
let v = 0;
for (const h of holes) {
const dx = (xn - h.x) / (h.r * 1.8), dy = (yn - h.y) / (h.r * 1.6);
v += Math.exp(-(dx * dx + dy * dy));
}
return Math.min(1, v);
};
// -- THE CANOPY: a layered ceiling, far to near ----------------------------
const nMass = 5 + Math.floor(rng() * 4); // 5-8 leaf masses
const masses = [];
for (let i = 0; i < nMass; i++) {
masses.push({
x: 0.04 + rng() * 0.92,
y: 0.03 + rng() * 0.22,
r: 0.09 + rng() * 0.13,
d: rng(), // depth row: 0 far .. 1 near
});
}
// two side masses: foliage pressing down the frame edges
const sideM = [];
for (const s of [0, 1]) {
if (rng() < 0.85) sideM.push({ x: s === 0 ? -0.02 : 1.02, y: 0.13 + rng() * 0.15, r: 0.14 + rng() * 0.10, d: 0.8 });
}
const leafCol = (xn, yn, depthRow) => {
const g = holeGlow(xn, yn);
let col;
if (depthRow === 0) col = mixHex(pick(DEEP), pick(MID), rng() * 0.35);
else if (depthRow === 1) col = mixHex(pick(MID), pick(DEEP), rng() * 0.30);
else col = mixHex(pick(LIGHT), pick(BURST), rng() * 0.5);
if (g > 0.25) col = mixHex(col, pick(LIGHT), Math.min(0.75, g * 0.8));
if (g > 0.5) col = mixHex(col, HOLE, Math.min(0.30, (g - 0.5) * 0.5));
const sunK = 0.5 - Math.abs(xn - cond.sunX);
if (sunK > 0.2 && !night) col = mixHex(col, pick(LIGHT), Math.min(0.22, (sunK - 0.2) * 0.5));
return grade(col);
};
const massAll = masses.concat(sideM);
massAll.sort((a, b) => a.d - b.d);
const leafK = winterThinK(); // winter thins the ceiling
function winterThinK() { return season === 'winter' ? 0.55 : 1; }
for (let i = 0, nFar = Math.round(420 * covK * leafK); i < nFar; i++) { // the depths: blue-green, hazed
const m = massAll[Math.floor(rng() * Math.min(4, massAll.length))];
const xn = m.x + gauss() * m.r * 0.85, yn = m.y + gauss() * m.r * 0.55;
if (yn < -0.02 || yn > 0.44) continue;
const col = grade(mixHex(mixHex(pick(DEEP), '#0e140e', 0.18), pick(MID), rng() * 0.3), 1.05);
const len = W * (0.016 + rng() * 0.038);
const ang = (rng() - 0.5) * 0.5;
const wd = Math.max(1.6, H * (0.007 + rng() * 0.012));
stamped(0.006 + 0.058 * rng(), (c, jr) => {
smear(c, jr, xn * W, yn * H, len, ang, wd, col, (0.15 + rng() * 0.15) * softK, 0.45);
});
}
for (let i = 0, nMid = Math.round(1000 * covK * leafK); i < nMid; i++) { // the emerald mid
const m = massAll[Math.floor(rng() * massAll.length)];
const xn = m.x + gauss() * m.r * 0.7, yn = m.y + gauss() * m.r * 0.5;
if (yn < -0.02 || yn > 0.46) continue;
const col = leafCol(xn, yn, 1);
const len = W * (0.026 + rng() * 0.050);
const ang = (rng() - 0.5) * 0.6;
const wd = Math.max(2, H * (0.010 + rng() * 0.015));
stamped(0.070 + 0.078 * rng(), (c, jr) => {
smear(c, jr, xn * W, yn * H, len, ang, wd, col, (0.17 + rng() * 0.18) * softK, 0.5);
});
}
for (let i = 0, nNear = Math.round(520 * covK * leafK); i < nNear; i++) { // the light-struck layer
const m = massAll[1 + Math.floor(rng() * Math.max(1, massAll.length - 1))];
const xn = m.x + gauss() * m.r * 0.65, yn = m.y + gauss() * m.r * 0.45;
if (yn < -0.02 || yn > 0.48) continue;
const col = leafCol(xn, yn, 2);
const len = W * (0.030 + rng() * 0.055);
const ang = (rng() - 0.5) * 0.7;
const wd = Math.max(2.2, H * (0.012 + rng() * 0.018));
stamped(0.152 + 0.078 * rng(), (c, jr) => {
smear(c, jr, xn * W, yn * H, len, ang, wd, col, (0.20 + rng() * 0.20) * softK, 0.5);
});
}
if (stormy) { // the boiling ceiling: churned dark masses
for (let i = 0, n = 60; i < n; i++) {
const xn = rng(), yn = rng() * 0.30;
const col = grade(mixHex(pick(DEEP), '#3c4048', 0.35));
stamped(0.232 + rng() * 0.008, (c, jr) => {
smear(c, jr, xn * W, yn * H, W * (0.03 + rng() * 0.07), (rng() - 0.5) * 0.9,
Math.max(2, H * (0.010 + rng() * 0.016)), col, (0.10 + rng() * 0.13) * softK, 0.4);
});
}
}
// -- THE HOLES: sky burning through, irregular, ragged-rimmed --------------
for (let hI = 0; hI < holes.length; hI++) {
const h = holes[hI];
const hx = h.x * W, hy = h.y * H, hr = h.r * W;
const holeCol = night ? mixHex(HOLE, '#b8c4d8', 0.2) : HOLE;
const p0 = 0.238 + hI * 0.002;
stamped(p0, veil(hx, hy, hr * 2.4, 0, hr * 1.5, holeCol, (night ? 0.06 : 0.10) * softK)); // the spill
stamped(p0 + 0.0004, (c) => { // the core: structural light, no wrist may scatter it;
dab(c, hx, hy, hr * 0.55, mixHex(holeCol, '#fffef4', night ? 0.2 : 0.45), (night ? 0.36 : 0.58) * softK);
dab(c, hx, hy, hr * 0.34, mixHex(holeCol, '#ffffff', night ? 0.3 : 0.6), (night ? 0.48 : 0.70) * softK);
});
for (let k = 0, nS = 10 + Math.floor(rng() * 5); k < nS; k++) { // the irregular body
const a = rng() * TAU, rr = Math.pow(rng(), 0.55) * hr * 0.95;
stamped(p0 + 0.001 + 0.010 * (k / nS), (c) => {
dab(c, hx + Math.cos(a) * rr, hy + Math.sin(a) * rr * 0.8, hr * (0.24 + rng() * 0.30),
mixHex(holeCol, rng() < 0.3 ? '#fff8e4' : pick(LIGHT), rng() * 0.45),
(0.18 + rng() * 0.22) * softK);
});
}
for (let k = 0, nR = 12 + Math.floor(rng() * 7); k < nR; k++) { // the ragged rim: leaves define it
const a = rng() * TAU, rr = hr * (0.8 + rng() * 0.55);
stamped(p0 + 0.012 + 0.020 * (k / nR), (c, jr) => {
smear(c, jr, hx + Math.cos(a) * rr, hy + Math.sin(a) * rr * 0.8,
W * (0.018 + rng() * 0.034), a + (rng() - 0.5) * 0.9,
Math.max(2, hr * (0.10 + rng() * 0.15)),
grade(mixHex(pick(LIGHT), pick(MID), rng() * 0.5)), (0.30 + rng() * 0.24) * softK, 0.5);
});
}
for (let k = 0, nS2 = 8 + Math.floor(rng() * 5); k < nS2; k++) { // silhouetted leaves against the gap
const a = rng() * TAU, rr = hr * (1.0 + rng() * 0.35);
stamped(p0 + 0.020 + 0.014 * (k / nS2), (c, jr) => {
smear(c, jr, hx + Math.cos(a) * rr, hy + Math.sin(a) * rr * 0.8,
W * (0.014 + rng() * 0.028), a + (rng() - 0.5) * 1.0,
Math.max(1.8, hr * (0.08 + rng() * 0.12)),
grade(mixHex(pick(DEEP), pick(MID), rng() * 0.3)), (0.34 + rng() * 0.24) * softK, 0.5);
});
}
}
for (let i = 0, nT = Math.round(220 * covK * leafK); i < nT; i++) { // the top film: darkest, closest overhead
const xn = rng(), yn = Math.pow(rng(), 1.5) * 0.11;
const col = grade(mixHex(pick(DEEP), '#0e140e', 0.42));
stamped(0.288 + 0.014 * rng(), (c, jr) => {
smear(c, jr, xn * W, yn * H, W * (0.03 + rng() * 0.07), (rng() - 0.5) * 0.4,
Math.max(2.4, H * (0.013 + rng() * 0.020)), col, (0.26 + rng() * 0.20) * softK, 0.4);
});
}
// -- THE MIDDLE DISTANCE: the wood you look THROUGH — hazed undergrowth,
// far trunks dissolved, a breath of green-grey depth between the near ones --
{
const farTrunks = [];
for (let i = 0; i < 3 + Math.floor(rng() * 3); i++) {
farTrunks.push({ x: 0.06 + rng() * 0.88, w: 0.006 + rng() * 0.009, lean: (rng() - 0.5) * 0.06 });
}
const pM = 0.302;
for (const ft of farTrunks) {
const fx = ft.x * W;
const col = grade(mixHex(pick(BARKS), MID[0], 0.60), 1.1);
// stacked broken smears: a distant trunk dissolving, never a wire
for (let k = 0, nFT = 6 + Math.floor(rng() * 4); k < nFT; k++) {
const yy = (k + rng() * 0.8) / nFT;
stamped(pM + 0.003 * (k / nFT) + rng() * 0.002, (c, jr) => {
smear(c, jr, fx + (rng() - 0.5) * W * 0.006, yy * H * 1.05 - H * 0.02,
H * (0.14 + rng() * 0.20), -Math.PI / 2 + ft.lean + (rng() - 0.5) * 0.06,
Math.max(2, ft.w * W * 1.4), col, (0.14 + rng() * 0.12) * softK, 0.7);
});
}
}
for (let i = 0, nU = Math.round(280 * covK); i < nU; i++) { // undergrowth masses, hazed
const xn = rng(), yn = 0.37 + Math.pow(rng(), 0.9) * (F / H - 0.37);
const big = rng() < 0.25; // occasional broad leafy mass
const col = grade(mixHex(pick(DEEP), pick(MID), 0.2 + rng() * 0.35), 1.1);
stamped(0.306 + 0.014 * rng(), (c, jr) => {
smear(c, jr, xn * W, yn * H, W * (big ? 0.05 + rng() * 0.06 : 0.03 + rng() * 0.07),
(rng() - 0.5) * 0.35, Math.max(2.2, H * (0.012 + rng() * 0.018)),
col, (0.20 + rng() * 0.16) * softK, 0.4);
});
}
for (let i = 0; i < 8; i++) { // the depth's own breath
stamped(0.320 + rng() * 0.006, veil(rng() * W, H * (0.40 + rng() * 0.22),
W * (0.14 + rng() * 0.28), 0, Math.max(3, H * (0.020 + rng() * 0.035)),
mixHex(MID[0], '#c2c8b4', 0.35), 0.045 * softK, 0.3));
}
}
// -- THE TRUNKS: great verticals, cropped top and bottom --------------------
const trunks = [];
const heroSide = rng() < 0.5 ? 0 : 1;
const nOthers = 4 + Math.floor(rng() * 7); // 4-10 others
// the hero: a massive near trunk at the frame edge, cropped by the frame
const heroX = heroSide === 0 ? -0.01 + rng() * 0.05 : 0.96 + rng() * 0.05;
trunks.push({ x: heroX, w: 0.055 + rng() * 0.040, d: 1, hero: true, lean: (rng() - 0.5) * 0.02 });
for (let i = 0; i < nOthers; i++) {
for (let tries = 0; tries < 8; tries++) {
const tx = 0.06 + rng() * 0.88, tw = 0.016 + rng() * 0.026, td = 0.30 + rng() * 0.60;
// never a colonnade: keep neighbours apart, keep the holes clear
const ok = trunks.every((t) => Math.abs(t.x - tx) > (t.w + tw) * 0.5 + 0.035)
&& holes.every((h) => Math.abs(h.x - tx) > tw * 0.75 + h.r * 0.8);
if (ok) { trunks.push({ x: tx, w: tw, d: td, hero: false, lean: (rng() - 0.5) * 0.05 }); break; }
}
}
for (let i = 0; i < 2 + Math.floor(rng() * 3); i++) { // far saplings: thin, hazed, deep
const tx = 0.05 + rng() * 0.90;
if (holes.every((h) => Math.abs(h.x - tx) > h.r * 0.8)) {
trunks.push({ x: tx, w: 0.004 + rng() * 0.005, d: 0.05 + rng() * 0.2, hero: false, lean: (rng() - 0.5) * 0.08 });
}
}
trunks.sort((a, b) => a.d - b.d);
const stormLitIdx = stormy && trunks.length > 2 ? 1 + Math.floor(rng() * (trunks.length - 1)) : -1;
for (let ti = 0; ti < trunks.length; ti++) {
const t = trunks[ti];
const tw = t.w * W;
const tx = t.x * W;
const tcx = tx + tw * 0.5;
const litDx = sunX < tcx ? -1 : 1; // the lit flank looks sunward
const shadeDx = -litDx;
const hazeK = t.d < 0.25 ? 0.45 + (0.25 - t.d) : t.d < 0.55 ? 0.20 : 0.05;
const hazeCol = mixHex(pick(BARKS), mixHex(MID[0], '#c8ccc0', 0.4), hazeK);
const barkC = mixHex(pick(BARKS), hazeCol, hazeK);
const bark2 = mixHex(pick(BARKS), hazeCol, hazeK * 0.8);
const stormLit = ti === stormLitIdx;
const litC = stormLit
? mixHex(barkC, '#eae6da', 0.42)
: mixHex(barkC, night ? '#7a86a0' : HOLE, night ? 0.10 : 0.26);
const crevC = mixHex(barkC, '#1a1610', 0.32 + 0.10 * t.d);
const slot = 0.330 + 0.240 * (ti / trunks.length);
const sl = 0.240 / trunks.length;
const st = (k) => slot + sl * clamp(k, 0, 0.98) + rng() * 0.0004;
// the fill: structural columns, plain, the form closes in every hand —
// except the far saplings, which dissolve into stacked soft smears (a
// thin full-height column reads as a wire)
const lean = t.lean;
if (t.d < 0.25 && !t.hero) {
for (let k = 0, nSap = 6 + Math.floor(rng() * 4); k < nSap; k++) {
const yy = (k + rng() * 0.8) / nSap;
stamped(st(0.02 + 0.10 * (k / nSap)), (c, jr) => {
smear(c, jr, tcx + (rng() - 0.5) * tw, yy * H * 1.05 - H * 0.02,
H * (0.14 + rng() * 0.22), -Math.PI / 2 + lean + (rng() - 0.5) * 0.06,
Math.max(2, tw * 1.5), mixHex(barkC, MID[0], 0.35), (0.16 + rng() * 0.12) * softK, 0.7);
});
}
} else {
stamped(st(0.02), (c, jr) => {
column(c, tcx - Math.tan(lean) * H * 0.55, -H * 0.05, tcx + Math.tan(lean) * H * 0.55, H * 1.05,
tw, barkC, 0.62);
column(c, tcx + tw * 0.12, -H * 0.05, tcx + tw * 0.12 + Math.tan(lean) * H * 1.1, H * 1.05,
tw * 0.66, mixHex(bark2, crevC, 0.30), 0.50);
});
stamped(st(0.08), (c, jr) => { // the shade plane and the lit rim
column(c, tcx + shadeDx * tw * 0.24, -H * 0.04, tcx + shadeDx * tw * 0.24, H * 1.04,
tw * 0.52, mixHex(crevC, '#0e0c08', 0.30), 0.50);
});
if (stormLit || !night) {
// the rim light comes and goes: stacked segments, never one hair —
// a continuous pale line down a trunk reads synthetic
const nRim = stormLit ? 7 : 3 + Math.floor(rng() * 4);
for (let k = 0; k < nRim; k++) {
const ry = rng() * H * 1.05 - H * 0.02;
stamped(st(0.08 + 0.06 * (k / nRim)), (c, jr) => {
rigger(c, jr, { x: tcx + litDx * tw * 0.38 + (rng() - 0.5) * tw * 0.06, y: ry,
length: H * (0.10 + rng() * 0.20), color: litC,
alpha: (stormLit ? 0.55 : 0.30) * (0.6 + rng() * 0.4) * softK,
angle: Math.PI / 2 + lean, width: Math.max(1.0, tw * 0.13), bow: 0 });
});
}
}
}
// bark: vertical broken strokes, crevices and ridges alternating
const nTex = t.hero ? 110 : t.d < 0.25 ? 8 : 30 + Math.floor(rng() * 28);
for (let k = 0; k < nTex; k++) {
const u = rng();
const bx = tx + (0.08 + rng() * 0.84) * tw;
const by = rng() * H;
const ridgeK = (litDx * (bx - tcx) > 0 ? 0.45 : 0.25) + (t.hero ? 0.08 : 0);
const col = u < ridgeK
? grade(mixHex(barkC, '#8a8272', 0.25 + rng() * 0.25))
: mixHex(crevC, '#14110c', rng() * 0.25);
stamped(st(0.14 + 0.56 * (k / Math.max(1, nTex))), (c, jr) => {
smear(c, jr, bx, by, H * (0.012 + rng() * 0.042), -Math.PI / 2 + (rng() - 0.5) * 0.14,
Math.max(0.8, tw * (0.10 + rng() * 0.18)), col, (0.20 + rng() * 0.30) * softK, 0.6);
});
}
// lichen: pale sage patches, the near trunk wears the most
const nLic = t.hero ? 2 + Math.floor(rng() * 2) : rng() < 0.5 ? 1 : 0;
for (let p = 0; p < nLic; p++) {
const px = tx + tw * (0.2 + rng() * 0.6), py = H * (0.12 + rng() * 0.74);
for (let k = 0, nD = 7 + Math.floor(rng() * 6); k < nD; k++) {
stamped(st(0.74 + 0.12 * (k / nD)), (c, jr) => {
touch(c, jr, px + gauss() * tw * 0.22, py + gauss() * H * 0.016,
Math.max(0.9, tw * (0.08 + rng() * 0.10)),
grade(rng() < 0.5 ? '#94a284' : '#a8b48e'), (0.16 + rng() * 0.16) * softK);
});
}
}
if (t.hero) { // a knot: two dark rings, the old tree's signature
const kx = tx + tw * (0.3 + rng() * 0.4), ky = H * (0.2 + rng() * 0.5);
stamped(st(0.90), (c, jr) => {
dab(c, kx, ky, tw * 0.16, mixHex(crevC, '#100e0a', 0.3), 0.55, 0, 0.75);
dab(c, kx, ky, tw * 0.07, '#0e0c09', 0.6, 0, 0.7);
});
}
// the trunk's footing: dark where it meets the litter
for (let k = 0, nF2 = t.hero ? 8 : 4; k < nF2; k++) {
stamped(st(0.92 + 0.05 * (k / nF2)), (c, jr) => {
smear(c, jr, tx + rng() * tw, F + rng() * floorRange * 0.14,
W * (0.014 + rng() * 0.03), (rng() - 0.5) * 0.3,
Math.max(1.4, tw * 0.22), mixHex(FSH[1], '#141210', 0.3), 0.30 * softK, 0.35);
});
}
}
// -- THE FLOOR: leaf litter under green shade ---------------------------------
const pools = [];
for (let i = 0, nP = (noon ? 3 : 2) + Math.floor(rng() * (noon ? 3 : 2)); i < nP; i++) {
pools.push({ x: 0.08 + rng() * 0.84, yn: 0.12 + rng() * 0.76, sx: 0.10 + rng() * 0.12, sy: 0.05 + rng() * 0.07 });
}
const poolGlow = (xn, yn) => {
let v = 0;
for (const p of pools) {
const dx = (xn - p.x) / p.sx, dy = (yn - p.yn) / p.sy;
v += Math.exp(-(dx * dx + dy * dy));
}
return Math.min(1, v);
};
for (let i = 0, nL = Math.round(430 * covK); i < nL; i++) {
const fn = Math.pow(rng(), 0.8); // 0 at the floor line, 1 near us
const xn = rng(), yn = fn;
const y = F + fn * floorRange + gauss() * H * 0.004, x = xn * W;
const lv = poolGlow(xn, yn);
let col;
if (lv > 0.5) col = mixHex(pick(LIT), POOL, 0.35 + lv * 0.3);
else if (lv < 0.25) col = mixHex(pick(LIT), pick(FSH), 0.30 + rng() * 0.25);
else col = mixHex(pick(LIT), rng() < 0.5 ? pick(FSH) : pick(LIT), rng() * 0.35);
if (autumn && rng() < 0.4) col = mixHex(col, '#a86428', 0.15 + rng() * 0.2); // russet drift
const len = W * (0.012 + rng() * 0.026) * (0.5 + fn);
const ang = (rng() - 0.5) * 0.34;
const wd = Math.max(1.2, H * (0.004 + rng() * 0.008) * (0.6 + fn));
stamped(0.578 + 0.070 * fn + rng() * 0.004, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, grade(col), (0.20 + rng() * 0.20) * softK, 0.35);
});
}
for (let i = 0, nD2 = 70; i < nD2; i++) { // the dark distance where the carpet meets the trunks
const x = rng() * W;
stamped(0.652 + rng() * 0.006, (c, jr) => {
smear(c, jr, x, F + rng() * floorRange * 0.10, W * (0.03 + rng() * 0.08), (rng() - 0.5) * 0.12,
Math.max(1.8, H * 0.009), grade(mixHex(FSH[0], '#1a221c', 0.35)), 0.30 * softK, 0.3);
});
}
for (let i = 0; i < 5; i++) { // the shadow's own breath, plain, binding the edge
stamped(0.654 + rng() * 0.004, veil(rng() * W, F + floorRange * (0.02 + rng() * 0.05),
W * (0.20 + rng() * 0.30), 0, Math.max(3, H * (0.014 + rng() * 0.02)),
mixHex(FSH[0], '#16201a', 0.3), 0.10 * softK, 0.28));
}
// the trunks' shadows fleeing the light across the carpet
for (const t of trunks) {
if (t.hero || t.d < 0.25 || night) continue;
const bx = t.x * W;
const shDx = sunX < bx ? 1 : -1;
for (let k = 0, nS2 = 5 + Math.floor(rng() * 3); k < nS2; k++) {
const tt = k / 6;
stamped(0.656 + rng() * 0.006, (c, jr) => {
smear(c, jr, bx + shDx * tt * W * (0.04 + rng() * 0.06), F + floorRange * (0.02 + rng() * 0.22),
W * (0.02 + rng() * 0.04), (rng() - 0.5) * 0.16,
Math.max(1.6, t.w * W * 0.5), mixHex(FSH[0], '#1c241e', 0.2 + rng() * 0.2),
(0.10 + rng() * 0.10) * (noon ? 2.0 : 1) * softK, 0.3);
});
}
}
// -- THE DAPPLE: broken light pools, the carpet's soul -------------------------
for (const p of pools) { // the pool cores: structural light, plain, broken shape
const px = p.x * W, py = F + p.yn * floorRange, pr = W * p.sx * 0.5;
stamped(0.666 + rng() * 0.003, (c) => {
dab(c, px, py, pr * 0.75, mixHex(POOL, '#fff6d8', 0.3), (night ? 0.06 : noon ? 0.20 : 0.17) * softK, 0, 0.32);
for (let k = 0; k < 6; k++) {
const a = rng() * TAU, rr = Math.pow(rng(), 0.6) * pr * 0.8;
dab(c, px + Math.cos(a) * rr, py + Math.sin(a) * rr * 0.42, pr * (0.26 + rng() * 0.26),
mixHex(POOL, '#fff3c4', rng() * 0.4), (0.13 + rng() * 0.11) * softK, 0, 0.35);
}
});
}
for (let i = 0, nP2 = Math.round(260 * covK); i < nP2; i++) {
const fn = Math.pow(rng(), 0.85);
const xn = rng(), yn = fn;
const y = F + fn * floorRange, x = xn * W;
const lv = poolGlow(xn, yn);
const sz = Math.max(1.2, H * 0.004 * (0.7 + fn));
if (lv > 0.55) {
const col = mixHex(POOL, '#fff3cc', rng() * 0.4);
stamped(0.672 + 0.030 * rng(), (c, jr) => {
touch(c, jr, x + gauss() * W * 0.008, y + gauss() * H * 0.005, sz * (0.8 + rng() * 0.7),
col, (0.32 + rng() * 0.30) * softK, 0, 0.5);
});
} else if (lv < 0.30) {
stamped(0.686 + 0.020 * rng(), (c, jr) => {
smear(c, jr, x, y, W * (0.018 + rng() * 0.04), (rng() - 0.5) * 0.2,
Math.max(1.4, H * 0.006), mixHex(FSH[1], '#18221c', 0.25),
(0.16 + rng() * 0.16) * (noon ? 1.5 : 1) * softK, 0.3);
});
}
}
// -- FERNS: small radiating stroke fans (dice 3-8) ------------------------------
const nFerns = 3 + Math.floor(rng() * 6);
for (let fI = 0; fI < nFerns; fI++) {
const fn = 0.15 + rng() * 0.8;
const fx = (0.04 + rng() * 0.92) * W, fy = F + fn * floorRange;
const fs = H * (0.022 + rng() * 0.030) * (0.6 + fn);
const pF = 0.706 + 0.024 * (fI / nFerns) + rng() * 0.003;
for (let k = 0, nF3 = 6 + Math.floor(rng() * 4); k < nF3; k++) {
const fa = -Math.PI / 2 + ((k / (nF3 - 1)) - 0.5) * 2.0;
stamped(pF + 0.0012 * k, (c, jr) => {
rigger(c, jr, { x: fx, y: fy, length: fs * (0.7 + rng() * 0.5), color: rng() < 0.4 ? FERN_L : FERN_C,
alpha: (0.50 + rng() * 0.3) * softK, angle: fa, width: Math.max(0.8, fs * 0.05), bow: (rng() - 0.5) * fs * 0.3 });
});
}
for (let k = 0; k < 5; k++) { // leaflets
stamped(pF + 0.010 + 0.001 * k, (c, jr) => {
touch(c, jr, fx + (rng() - 0.5) * fs * 1.3, fy - fs * (0.3 + rng() * 0.6),
Math.max(0.9, fs * 0.11), FERN_C, 0.34 * softK);
});
}
}
// -- the anchor: a fallen branch or a mossy boulder (dice) ----------------------
{
const anchorRoll = rng();
if (anchorRoll < 0.45) {
const bx = (0.12 + rng() * 0.76) * W, by = F + (0.35 + rng() * 0.5) * floorRange;
const bl = W * (0.10 + rng() * 0.07), bAng = (rng() - 0.5) * 0.16;
const pB = 0.736;
for (let k = 0; k < 4; k++) {
const t = k / 3;
stamped(pB + 0.0008 * k, (c, jr) => {
smear(c, jr, bx - bl * 0.5 + t * bl + (rng() - 0.5) * 3, by + Math.sin(t * 3) * bl * 0.03,
bl * 0.32, bAng + (rng() - 0.5) * 0.08, Math.max(1.4, H * 0.007),
grade(mixHex('#4a3a2a', FSH[0], 0.2)), 0.55 * softK, 0.6);
});
}
for (let k = 0, nM = 5 + Math.floor(rng() * 4); k < nM; k++) { // moss along the top
stamped(pB + 0.004 + 0.0006 * k, (c, jr) => {
touch(c, jr, bx + (rng() - 0.5) * bl, by - H * 0.004 + (rng() - 0.5) * H * 0.004,
Math.max(0.9, H * 0.004), grade('#7c8c4e'), (0.30 + rng() * 0.20) * softK);
});
}
} else if (anchorRoll < 0.8) {
const bx = (0.15 + rng() * 0.70) * W, by = F + (0.4 + rng() * 0.45) * floorRange;
const br = W * (0.018 + rng() * 0.014);
const pB = 0.736;
stamped(pB, (c, jr) => { // the boulder's mass, structural
band(c, jr, bx, by, br * 2.2, 0, br * 1.1, grade('#6a6a5e'), 0.5);
band(c, jr, bx + br * 0.2, by + br * 0.2, br * 1.4, 0, br * 0.8, grade('#54544a'), 0.45);
});
for (let k = 0, nM = 6 + Math.floor(rng() * 5); k < nM; k++) { // the mossy crown
stamped(pB + 0.003 + 0.0006 * k, (c, jr) => {
touch(c, jr, bx + (rng() - 0.5) * br * 1.6, by - br * (0.3 + rng() * 0.3),
Math.max(0.9, br * 0.16), grade(rng() < 0.6 ? '#7c8c4e' : '#8c9c56'), (0.30 + rng() * 0.22) * softK);
});
}
for (let k = 0; k < 3; k++) { // its shadow
stamped(pB + 0.007 + 0.0005 * k, (c, jr) => {
smear(c, jr, bx + (rng() - 0.5) * br, by + br * 0.5, br * 0.9, 0,
Math.max(1.2, br * 0.2), mixHex(FSH[0], '#141812', 0.3), 0.22 * softK, 0.3);
});
}
}
}
// -- the season's floor notes ----------------------------------------------------
if (spring) { // anemones: tiny white dabs scattered in the litter
for (let k = 0, nA = 26 + Math.floor(rng() * 14); k < nA; k++) {
const ax = rng() * W, ay = F + (0.15 + rng() * 0.8) * floorRange;
stamped(0.742 + 0.008 * rng(), (c, jr) => {
touch(c, jr, ax, ay, Math.max(0.9, H * 0.0025), '#f2efe2', (0.55 + rng() * 0.3) * softK);
if (rng() < 0.4) dab(c, ax, ay, Math.max(0.5, H * 0.001), '#e8d890', 0.5);
});
}
}
if (autumn) { // leaves falling through the frame
for (let k = 0, nF4 = 20 + Math.floor(rng() * 12); k < nF4; k++) {
const lx = rng() * W, ly = H * (0.12 + rng() * 0.78);
stamped(0.744 + 0.008 * rng(), (c, jr) => {
touch(c, jr, lx, ly, Math.max(0.8, H * 0.0022),
grade(rng() < 0.5 ? '#c08a3e' : '#a86830'), (0.40 + rng() * 0.30) * softK,
(rng() - 0.5) * 0.8, 0.5);
});
}
}
// -- a distant figure on a faint path (dice) --------------------------------------
if (rng() < 0.20) {
const pathX0 = 0.30 + rng() * 0.40;
const pP = 0.758;
for (let k = 0; k < 10; k++) { // the path: pale dirt receding
const t = Math.pow(k / 9, 1.4);
const px = lerp(pathX0, 0.5, t) * W + gauss() * W * 0.01;
const py = F + (0.9 - t * 0.75) * floorRange;
stamped(pP + 0.0006 * k, (c, jr) => {
smear(c, jr, px, py, W * (0.030 - t * 0.018), (rng() - 0.5) * 0.1,
Math.max(1.0, H * 0.005 * (1 - t * 0.5)), grade(mixHex(pick(LIT), '#b09868', 0.4)),
(0.12 + rng() * 0.08) * softK, 0.3);
});
}
const fx = lerp(pathX0, 0.5, 0.45) * W + gauss() * W * 0.008; // the figure, under 15 strokes
const fy = F + (0.9 - 0.45 * 0.75) * floorRange;
const fh = H * 0.030;
stamped(pP + 0.010, (c, jr) => {
band(c, jr, fx, fy - fh * 0.42, fh * 0.52, -Math.PI / 2, Math.max(1.2, fh * 0.24),
grade('#3a3430'), 0.75);
band(c, jr, fx - fh * 0.05, fy - fh * 0.06, fh * 0.22, -Math.PI / 2, Math.max(0.7, fh * 0.09),
grade('#2c2824'), 0.65);
band(c, jr, fx + fh * 0.05, fy - fh * 0.06, fh * 0.22, -Math.PI / 2, Math.max(0.7, fh * 0.09),
grade('#2c2824'), 0.65);
dab(c, fx, fy - fh * 0.76, Math.max(0.8, fh * 0.12), grade(mixHex('#3a3430', '#c8a888', 0.3)), 0.7);
rigger(c, jr, { x: fx + fh * 0.1, y: fy - fh * 0.5, length: fh * 0.35, color: grade('#3a3430'),
alpha: 0.5, angle: -Math.PI / 2 - 0.5, width: Math.max(0.6, fh * 0.05), bow: fh * 0.06 });
});
}
// -- LIFE: the roe deer or the wood pigeon (dice) -----------------------------------
{
const lifeRoll = rng();
if (!night && !stormy && lifeRoll < 0.30) { // the roe deer, alert
const dx = (0.30 + rng() * 0.40) * W, dy = F + (0.30 + rng() * 0.45) * floorRange;
const bh = H * 0.024, bl = bh * 1.7;
const pD = 0.772;
stamped(pD, (c, jr) => { // the body, a tawny smear
smear(c, jr, dx, dy - bh * 0.55, bl, 0.02, bh * 0.55, grade('#9a7848'), 0.72 * softK, 0.55);
});
stamped(pD + 0.001, (c, jr) => { // the white heart on the rump
touch(c, jr, dx - bl * 0.42, dy - bh * 0.5, bh * 0.22, '#ded2c0', 0.6 * softK);
});
stamped(pD + 0.002, (c, jr) => { // neck and head, lifted
rigger(c, jr, { x: dx + bl * 0.38, y: dy - bh * 0.7, length: bh * 0.8, color: grade('#8a6a40'),
alpha: 0.7 * softK, angle: -Math.PI / 2 + 0.35, width: Math.max(0.8, bh * 0.14), bow: bh * 0.1 });
});
stamped(pD + 0.003, (c, jr) => {
touch(c, jr, dx + bl * 0.48, dy - bh * 1.45, bh * 0.16, grade('#5c462c'), 0.75 * softK);
rigger(c, jr, { x: dx + bl * 0.46, y: dy - bh * 1.55, length: bh * 0.25, color: grade('#5c462c'),
alpha: 0.6 * softK, angle: -Math.PI / 2 - 0.6, width: Math.max(0.5, bh * 0.06), bow: 0 });
rigger(c, jr, { x: dx + bl * 0.50, y: dy - bh * 1.55, length: bh * 0.25, color: grade('#5c462c'),
alpha: 0.6 * softK, angle: -Math.PI / 2 - 0.2, width: Math.max(0.5, bh * 0.06), bow: 0 });
});
for (let k = 0; k < 4; k++) { // the legs, four thin hairs
stamped(pD + 0.004 + 0.0004 * k, (c, jr) => {
rigger(c, jr, { x: dx + bl * (0.28 + 0.44 * (k % 2)) + (k > 1 ? -bh * 0.2 : 0), y: dy - bh * 0.2,
length: bh * 0.55, color: grade('#3c3020'), alpha: 0.6 * softK, angle: Math.PI / 2 + (rng() - 0.5) * 0.2,
width: Math.max(0.5, bh * 0.06), bow: 0 });
});
}
} else if (lifeRoll < 0.50) { // the wood pigeon, clattering off through the leaves
const px2 = (0.15 + rng() * 0.70) * W, py2 = H * (0.10 + rng() * 0.20);
const ps = W * 0.010;
const pP2 = 0.772;
stamped(pP2, (c, jr) => {
smear(c, jr, px2, py2, ps * 1.5, -0.15, ps * 0.6, grade('#5a6472'), 0.65 * softK, 0.6);
touch(c, jr, px2 + ps * 0.85, py2 - ps * 0.2, ps * 0.4, grade('#4a5462'), 0.7 * softK);
rigger(c, jr, { x: px2 - ps * 0.8, y: py2 + ps * 0.15, length: ps * 0.9, color: grade('#464e5c'),
alpha: 0.55 * softK, angle: Math.PI - 0.35, width: Math.max(0.6, ps * 0.16), bow: ps * 0.1 });
rigger(c, jr, { x: px2 - ps * 0.1, y: py2 - ps * 0.1, length: ps * 1.2, color: grade('#6a7482'),
alpha: 0.4 * softK, angle: -Math.PI / 2 - 0.4, width: Math.max(0.6, ps * 0.2), bow: ps * 0.2 });
});
}
}
if (rng() < 0.25) { // the woodcutter's stack
const sx2 = (rng() < 0.5 ? 0.08 + rng() * 0.12 : 0.78 + rng() * 0.12) * W;
const sy2 = F + (0.5 + rng() * 0.35) * floorRange;
const lg = W * 0.016;
const pS = 0.780;
for (let row = 0; row < 2; row++) {
const nLog = row === 0 ? 3 + Math.floor(rng() * 2) : 2 + Math.floor(rng() * 2);
for (let k = 0; k < nLog; k++) {
const lx = sx2 + k * lg * 1.05 - nLog * lg * 0.4;
const ly = sy2 - row * lg * 0.8;
stamped(pS + row * 0.003 + k * 0.0006, (c, jr) => {
smear(c, jr, lx, ly, lg, 0, Math.max(1.2, lg * 0.35), grade('#6e5238'), 0.6 * softK, 0.7);
touch(c, jr, lx + lg * 0.5, ly, Math.max(0.8, lg * 0.22), grade('#c8a878'), 0.5 * softK);
});
}
}
}
// -- LIGHT SHAFTS: rare, dice-gated, with dust in the beam --------------------------
{
let nShafts = 0;
const shaftLuck = rng();
if (!night && weather === 'clear') {
if ((noon || morning) && shaftLuck < 0.65) nShafts = 1 + (rng() < 0.4 ? 1 : 0);
else if (golden && shaftLuck < 0.5) nShafts = 1;
else if (dawn && shaftLuck < 0.30) nShafts = 1;
} else if (night && weather === 'clear' && shaftLuck < 0.25) nShafts = 1;
for (let sI = 0; sI < nShafts && holes.length; sI++) {
const h = holes[Math.floor(rng() * holes.length)];
const ox = h.x * W, oy = h.y * H;
const dir = cond.lightAngle; // the light's own descent, mostly down
const len = H * 0.78;
const beamC = night ? mixHex(HOLE, '#c8d4e8', 0.4) : mixHex(HOLE, '#fff6da', 0.3);
const dimK = night ? 0.4 : 1;
for (let k = 0, nV = 13; k < nV; k++) {
const t = 0.06 + 0.86 * (k / (nV - 1));
const vx = ox + Math.cos(dir) * t * len, vy = oy + Math.sin(dir) * t * len;
stamped(0.790 + 0.0015 * k + sI * 0.001, veil(vx, vy, W * (0.030 + t * 0.048), dir,
Math.max(4, H * (0.018 + t * 0.014)), beamC, (0.085 * (1 - t * 0.30)) * softK * dimK, 0.55));
}
for (let k = 0, nD3 = 14 + Math.floor(rng() * 8); k < nD3; k++) { // the dust
const t = rng();
const off = gauss() * W * (0.016 + t * 0.020);
stamped(0.800 + 0.008 * rng() + sI * 0.001, (c, jr) => {
touch(c, jr, ox + Math.cos(dir) * t * len - Math.sin(dir) * off,
oy + Math.sin(dir) * t * len + Math.cos(dir) * off,
Math.max(0.6, H * 0.0012), mixHex(beamC, '#fffef2', 0.5),
(0.10 + rng() * 0.16) * softK * dimK);
});
}
}
}
// -- THE HOUR'S SIGNATURES ------------------------------------------------------------
if (dawn || misty) { // blue mist between the trunks, the depths dissolved
for (let k = 0, nM = misty ? 30 : 20; k < nM; k++) {
const my = H * (0.22 + rng() * 0.62);
stamped(0.816 + 0.008 * rng(), veil(rng() * W, my, W * (0.16 + rng() * 0.30), (rng() - 0.5) * 0.08,
Math.max(2, H * (0.018 + rng() * 0.034)),
mixHex('#dfe4e2', dawn ? '#c4ced8' : '#d8ded8', 0.5), (0.04 + rng() * 0.06) * softK, 0.25));
}
for (const t of trunks) { // the near trunks stay strong through the mist
if (t.d < 0.55 && !t.hero) continue;
const mx2 = t.x * W + t.w * W * 0.5;
stamped(0.824 + rng() * 0.004, veil(mx2, H * 0.5, H * 0.7, -Math.PI / 2,
Math.max(3, t.w * W * 1.2), '#e4e8e4', 0.035 * softK, 0.5));
}
}
if (rain) { // everything WET: bark gloss, the carpet mirroring the canopy
for (const t of trunks) {
if (t.d < 0.3 && !t.hero) continue;
const tw2 = t.w * W, tx2 = t.x * W;
const litDx2 = sunX < tx2 + tw2 * 0.5 ? -1 : 1;
for (let k = 0, nG = t.hero ? 7 : 4; k < nG; k++) {
stamped(0.830 + 0.006 * rng(), (c, jr) => {
smear(c, jr, tx2 + tw2 * (0.25 + rng() * 0.5) + litDx2 * tw2 * 0.18, rng() * H,
H * (0.02 + rng() * 0.06), -Math.PI / 2 + (rng() - 0.5) * 0.1,
Math.max(0.7, tw2 * 0.07), mixHex(HOLE, '#dce8ec', 0.5), (0.14 + rng() * 0.16) * softK, 0.2);
});
}
}
for (let k = 0, nM2 = 12; k < nM2; k++) { // mirror pools: canopy doubled in the wet floor
const fn = rng();
stamped(0.836 + 0.004 * rng(), (c, jr) => {
smear(c, jr, rng() * W, F + fn * floorRange, W * (0.014 + rng() * 0.03), (rng() - 0.5) * 0.12,
Math.max(1.2, H * 0.005), mixHex(POOL, pick(MID), 0.35 + rng() * 0.25),
(0.14 + rng() * 0.14) * softK, 0.25);
});
}
}
if (golden) { // low light raking UNDER the canopy from the edge: the amber wash
const edgeX = sunX < W * 0.5 ? 0 : W;
for (let k = 0, nA2 = 26; k < nA2; k++) {
const t = Math.pow(rng(), 1.4);
stamped(0.820 + 0.006 * rng(), veil(edgeX + (sunX < W * 0.5 ? 1 : -1) * t * W * 0.9,
F + rng() * floorRange * 0.9, W * (0.05 + rng() * 0.10), 0,
Math.max(2, H * 0.012), '#f0c880', (0.05 + rng() * 0.06) * softK * (1 - t * 0.6), 0.3));
}
}
if (stormy && stormLitIdx >= 0) { // one trunk starkly lit against the boil
const t = trunks[stormLitIdx];
const tw2 = t.w * W, tx2 = t.x * W;
for (let k = 0; k < 6; k++) {
stamped(0.824 + 0.004 * rng(), (c, jr) => {
smear(c, jr, tx2 + tw2 * (0.72 + rng() * 0.2), rng() * H,
H * (0.03 + rng() * 0.06), -Math.PI / 2 + (rng() - 0.5) * 0.08,
Math.max(1.0, tw2 * 0.12), mixHex('#eae6da', HOLE, 0.3), (0.24 + rng() * 0.20) * softK, 0.25);
});
}
}
// -- FIREFLIES: the night's only lights (2-6, dice) -------------------------------------
if (night) {
const nFire = 2 + Math.floor(rng() * 5);
for (let k = 0; k < nFire; k++) {
const fx = (0.06 + rng() * 0.88) * W, fy = H * (0.28 + rng() * 0.56);
const pF2 = 0.844 + k * 0.0012;
stamped(pF2, (c) => { // the glow, then the spark — plain, structural light
dab(c, fx, fy, Math.max(2.5, W * 0.005), '#c8dc7c', 0.10, 0, 1);
dab(c, fx, fy, Math.max(1.1, W * 0.0016), '#eef4a0', 0.88, 0, 1);
});
}
}
// -- the unifying breath ------------------------------------------------------------------
for (let i = 0; i < 18; i++) {
const y = rng() * H;
const col = y < 0.34 * H ? mixHex(pick(MID), pick(LIGHT), rng() * 0.4)
: y < F ? grade(mixHex(pick(BARKS), pick(DEEP), 0.4))
: grade(mixHex(pick(LIT), pick(FSH), rng() * 0.5));
stamped(0.856 + 0.010 * rng(), 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.04) * softK * (hand.mode === 'impasto' ? 0.45 : 1)));
}
// -- the last notes: the brightest accents, struck last --------------------------------------
for (let i = 0, nG2 = Math.round(34 * covK); i < nG2 && holes.length; i++) { // hole rims burn
const h = holes[Math.floor(rng() * holes.length)];
const a = rng() * TAU, rr = h.r * W * (0.5 + rng() * 0.5);
stamped(0.872 + 0.060 * rng(), (c, jr) => {
touch(c, jr, h.x * W + Math.cos(a) * rr, h.y * H + Math.sin(a) * rr * 0.8,
Math.max(0.8, H * 0.0022), mixHex(HOLE, '#fffef4', 0.5), (0.20 + rng() * 0.20) * softK);
});
}
for (let i = 0, nG3 = Math.round(26 * covK); i < nG3 && pools.length; i++) { // pool cores sing
const p = pools[Math.floor(rng() * pools.length)];
stamped(0.906 + 0.046 * rng(), (c, jr) => {
touch(c, jr, p.x * W + gauss() * W * p.sx * 0.4, F + (p.yn + gauss() * p.sy * 0.5) * floorRange,
Math.max(0.8, H * 0.0024), mixHex(POOL, '#fff8dc', 0.55), (0.24 + rng() * 0.20) * softK, 0, 0.5);
});
}
// -- the final veil: mist and rain soften every edge one last time ---------------------------
if (misty || rain || snowy) {
const veilCol = mixHex('#e2e6e4', night ? '#3a4456' : dawn ? '#c8d2da' : '#d8ded8', night ? 0.5 : 0.25);
for (let i = 0; i < 14; i++) {
stamped(0.968 + 0.020 * rng(), (c, jr) => {
band(c, jr, rng() * W, rng() * H, W * (0.15 + rng() * 0.30), (rng() - 0.5) * 0.04,
H * (0.02 + rng() * 0.04), veilCol, 0.03 + rng() * 0.04);
});
}
}
strokes = list.sort((a, b) => a.p - b.p);
},
draw(ctx, W, H, _rng, from, to, _conditions) {
if (!strokes) return;
for (let i = 0; i < strokes.length; i++) {
const s = strokes[i];
if (s.p >= from && s.p < to) s.fn(ctx);
}
},
};
// La Loire — a château across the river at dusk, painted the way the Val de
// Loire taught France to see itself: the great river broad and shallow, a
// turreted mass of tuffeau gathering the last light on its west faces, and
// the whole scene doubled in the water's long mirror. THE RIVER IS THE STAGE —
// it fills the lower half, sandbank-broken, carrying the sky's dusk colors
// down in broken horizontal strokes; the château and its trembling reflection
// are one doubled gesture, monumentalized from the jardin bridge lesson.
//
// DICE (init stream, after the conditions stream). The château anchors a
// dice-rolled third (left / center / right) of the frame; 4-8 towers with
// slate conical roofs; 1-3 sand islands where 0-2 herons stand (3 strokes
// each); 0-6 swifts as dark sickles crossing the towers; moored rowboats or
// fishing stakes on the near bank; a lone angler maybe (0-1, under 15
// strokes). Night rolls floodlit turrets vs moonlit — the reflection doubled
// in still water either way.
//
// CONDITIONS. makeConditions rolls hour/season/weather/hand as everywhere.
// goldenHour is canonical (stone blushing, mirror gold); dawn lays mist on
// the water and greys the château to a ghost among herons; noon glares with
// heat shimmer at the waterline; dusk is the blue-violet descent with windows
// kindling; night is floodlit turrets or moonlight. Spring runs the river
// high over green banks; summer is the famous étiage — the river LOW, wide
// sandbanks exposed; autumn russets the bank trees; winter pales the water
// and frosts the banks. Weather: mist dissolves the far bank, rain streaks
// the mirror under grey light, overcast boils the sky behind dark water.
//
// SKELETON AND HAND: the load-bearing strokes — the prime, the sky bands,
// the water's mirror courses, the château's filled mass and roofs, the near
// bank blocking — ride the house stroke plain (mer precedent: masses plain,
// accents through the hand), because a pointillisme wrist must not starve
// the tonal spine. Every expressive touch — stone facets, ridge lines,
// grasses, foam, shimmer, the angler's rod — routes through
// brushes.handFor(conditions.technique) + handDab.
//
// 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 (the `jr` handed to each stroke
// closure); NOTHING else consumes randomness at draw time — every position,
// width, angle and colour is fixed in init.
// TONAL SPINE: sky bands brightest > water mid > château warm accents >
// near bank darkest; windows and glints brightest of all.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, lerp, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, brokenColor,
dab, roundDab, rigger, stroke,
} from '../brushes.js';
let strokes = null;
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
// Stone (tuffeau) per hour: [lit west face, shade face]. The lit face carries
// the hour — cream at noon, rose-gold at goldenHour, warm kindling at dusk.
const STONE = {
dawn: ['#d8c0ac', '#988c9a'],
morning: ['#ecdcc2', '#b2a8a6'],
noon: ['#f2e4c8', '#c2b6a8'],
goldenHour: ['#f2ca96', '#c49c88'],
dusk: ['#caa888', '#786a82'],
night: ['#8a8298', '#3a3a4e'],
};
// Slate roofs: [slate body, ridgeline that catches the light].
const ROOF = {
dawn: ['#4c586a', '#8a94a4'],
morning: ['#46525e', '#9aa6b2'],
noon: ['#3e4a58', '#a8b4c0'],
goldenHour: ['#5a4e5c', '#d0b490'],
dusk: ['#343c4e', '#a898a0'],
night: ['#232a3a', '#6a7488'],
};
// Sand of the islands, hour-tinted.
const SAND = {
dawn: '#d8c8ac', morning: '#e4d4b2', noon: '#eedfb8',
goldenHour: '#e8c890', dusk: '#b0a090', night: '#5a5860',
};
export default {
id: 'loire',
title: 'La Loire',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'La Loire, ' + 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';
const stormy = weather === 'overcast';
const snowy = weather === 'snow' || season === 'winter';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
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((0x1014 + Math.floor(rng() * 0xffffff) * 691) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; the scene keeps painting */ }
}
});
};
// -- THE HAND ----------------------------------------------------------
const hand = handFor(cond.technique);
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean;
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(1.5, Math.max(1, 1 / Math.max(0.25, slMean * kMean)));
const inkK = hand.alphaCurve === 'soft' ? 2.3 : hand.alphaCurve === 'bleed' ? 1.7
: hand.alphaCurve === 'matte' ? 1.25 : 1;
const touch = (x, y, w, c, a, ang = 0, squash = 1) => (cc, jr) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: 0, ang, w: handW(w), c, a: aa, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: aa, angle: ang, squash }),
});
};
const smear = (x, y, len, ang, w, c, a, squash = 1) => (cc, jr) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(w), c, a: aa, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, c, aa),
});
};
// the soft air 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));
};
// the skeleton stroke, hand-independent: the composition must survive
// every wrist (mer precedent — masses plain, accents through the hand)
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.35,
});
// -- the sky: sampled from the house gradient (hour+season+weather) -----
const stops = skyGradient(cond);
const skyAt = (t) => {
const x = clamp01(t);
for (let i = 1; i < stops.length; i++) {
if (x <= stops[i].at) {
const a = stops[i - 1], b = stops[i];
const f = (x - a.at) / Math.max(1e-6, b.at - a.at);
return mixHex(a.color, b.color, f);
}
}
return stops[stops.length - 1].color;
};
const hzC = stops[2].color, deepC = stops[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 (stormy || rain) o = mixHex(o, '#9aa0a2', 0.12 * k);
if (snowy) o = mixHex(o, '#dfe7ee', 0.12 * k);
return o;
};
// -- geometry ------------------------------------------------------------
const hzF = clamp01(cond.horizonY);
const hzY = H * hzF;
// THE DICE: the river's level runs the near bank — spring high, summer at
// the famous étiage (river LOW, sand wide), else middling.
const bankF = season === 'spring' ? 0.72 + rng() * 0.05
: season === 'summer' ? 0.85 + rng() * 0.05
: 0.78 + rng() * 0.08;
const bankY = H * bankF;
const riverRange = bankY - hzY;
const sunX = cond.sunX * W;
const moonX = W * (0.20 + rng() * 0.60);
const roadX = night ? moonX : sunX;
const nightLit = night && rng() < 0.45; // floodlit turrets vs moonlit
const moonlit = night && !nightLit;
// -- palettes --------------------------------------------------------------
const softK = { clear: 1, mist: 0.88, overcast: 0.78, rain: 0.75, snow: 0.85 }[weather] || 1;
const [stoneLit, stoneShade] = STONE[tod] || STONE.morning;
const [roofC, ridgeC] = ROOF[tod] || ROOF.morning;
const sandC = SAND[tod] || SAND.morning;
const stoneLitK = nightLit ? '#e8c890' : stoneLit;
const WINGLIT = '#f2c268';
const GLINT = night ? '#dce4f2' : '#fff4d8';
const SIL = night ? '#181c26' : dusk || golden ? '#221e22' : '#2a2626';
const gp = groundPalette(season, cond.warmth);
const BNK_D = gp.shade.map((h) => grade(mixHex(h, '#2c2a24', 0.35), 1.1));
const BNK_M = gp.mid.map((h) => grade(mixHex(h, '#3a362e', 0.22)));
const BNK_L = gp.light.map((h) => grade(h));
if (night) {
const f = (h) => mixHex(h, '#2c3444', 0.42);
for (const arr of [BNK_D, BNK_M, BNK_L]) for (let i = 0; i < arr.length; i++) arr[i] = f(arr[i]);
}
// the mirror: water reflects the sky, near the horizon the sky's own low
// band, nearer us the higher darker sky — the spine held mid-value, a full
// step below the sky's brightness.
const waterAt = (t, x) => {
let col = skyAt(lerp(0.96, 0.30, clamp01(t)));
col = mixHex(col, deepC, 0.38 + 0.26 * clamp01(t));
if (snowy && !night) col = mixHex(col, '#dce8ec', 0.14);
if (stormy) col = mixHex(col, '#39424c', 0.18);
const road = Math.exp(-Math.pow((x - roadX) / (W * 0.10), 2));
// the road on the water belongs to the sun, or to the moon on moonlit
// nights — a floodlit château throws no moon path
if (road > 0.02 && !noon && (!night || moonlit)) col = mixHex(col, GLINT, road * (golden || night ? 0.26 : 0.15));
return col;
};
// -- THE PRIME -------------------------------------------------------------
const paintPrime = (c) => {
const g = c.createLinearGradient(0, 0, 0, hzY + 1);
for (const s of stops) if (s.at <= hzF) g.addColorStop(clamp01(s.at / Math.max(1e-6, hzF)), s.color);
g.addColorStop(1, hzC);
c.fillStyle = g;
c.fillRect(0, 0, W, hzY + 1);
const g2 = c.createLinearGradient(0, hzY, 0, bankY + 1);
g2.addColorStop(0, mixHex(hzC, waterAt(0.08, roadX), 0.35));
g2.addColorStop(0.5, waterAt(0.45, roadX));
g2.addColorStop(1, mixHex(waterAt(0.95, roadX), deepC, 0.4));
c.fillStyle = g2;
c.fillRect(0, hzY, W, riverRange + 1);
const g3 = c.createLinearGradient(0, bankY - 1, 0, H);
g3.addColorStop(0, mixHex(BNK_M[1], '#20241c', 0.30));
g3.addColorStop(0.35, mixHex(BNK_D[1], '#26241f', 0.35));
g3.addColorStop(1, mixHex(BNK_D[0], '#1c1a16', 0.45));
c.fillStyle = g3;
c.fillRect(0, bankY - 1, W, H - bankY + 1);
};
paintPrime(ctx);
add(0.001, paintPrime);
// -- THE SKY: horizontal dusk bands, rose / apricot / violet ----------------
for (let i = 0, n = Math.round(680 * covK); i < n; i++) {
const bn = Math.pow(rng(), 0.95);
const y = H * 0.01 + bn * Math.max(4, hzY - H * 0.02);
const cols = brokenColor(null, rng, skyAt(bn * 0.92), 0.5);
const col = grade(cols[Math.floor(rng() * cols.length)]);
const x = -W * 0.03 + rng() * W * 1.06;
const len = W * (0.07 + rng() * 0.20) * (0.7 + bn * 0.5);
const ang = (rng() - 0.5) * 0.06;
const wd = H * (0.010 + rng() * 0.024) * (0.75 + bn * 0.6);
const a = (0.10 + rng() * 0.16) * softK;
stamped(0.008 + 0.088 * bn + rng() * 0.008, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// -- cloud masses: dusk heaps with lit undersides; storm boils --------------
const nClouds = stormy ? 4 + Math.floor(rng() * 3) : 2 + Math.floor(rng() * 3);
for (let ci = 0; ci < nClouds; ci++) {
const cx = 0.08 + rng() * 0.84, cy = 0.05 + rng() * Math.max(0.10, hzF * 0.55);
const sx = 0.09 + rng() * 0.12, sy = 0.016 + rng() * 0.024;
const n = 12 + Math.floor(rng() * 14);
const under = skyAt(0.35 + rng() * 0.30);
for (let i = 0; i < n; i++) {
const x = (cx + gauss() * sx) * W;
const y = (cy + gauss() * sy) * H;
const rim = gauss() < -0.25;
let base = mixHex(under, night ? '#2a3852' : '#f4f1e8', rim ? 0.50 : night ? 0.10 : 0.26);
if (stormy) base = mixHex(base, '#5c5666', 0.40);
const col = grade(base);
const wd = H * (0.010 + rng() * 0.016);
const len = W * (0.03 + rng() * 0.08);
const ang = (rng() - 0.5) * (stormy ? 0.16 : 0.09);
const a = ((night ? 0.06 : 0.10) + rng() * (rim ? 0.16 : 0.12)) * softK;
stamped(0.108 + 0.036 * (ci / nClouds) + rng() * 0.006, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.5);
});
}
}
if (stormy) { // the boiling sky: churning dark masses over the towers' side
const bx = sunX < W * 0.5 ? 0.72 : 0.28;
for (let i = 0, n = 60; i < n; i++) {
const x = (bx + gauss() * 0.20) * W;
const y = H * (0.03 + Math.abs(gauss()) * 0.14);
const col = grade(mixHex(skyAt(0.4), '#3c3648', 0.55));
const len = W * (0.04 + rng() * 0.09);
const ang = (rng() - 0.5) * 0.30;
const wd = Math.max(2, H * (0.012 + rng() * 0.022));
const a = (0.10 + rng() * 0.14) * softK;
stamped(0.142 + rng() * 0.008, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.4);
});
}
}
// -- stars: the first pale points, night only ---------------------------------
if (night) {
for (let i = 0, n = 80; i < n; i++) {
const x = rng() * W;
const y = rng() * hzY * 0.88;
const size = Math.max(0.5, H * (0.0009 + rng() * 0.0018));
const a = 0.12 + rng() * 0.38;
const col = rng() < 0.3 ? GLINT : '#dce4f0';
stamped(0.152 + rng() * 0.008, (c) => dab(c, x, y, size, col, a));
}
}
// -- SUN or MOON ------------------------------------------------------------
{
const sy = Math.max(H * 0.05, Math.min(cond.sunY * H, hzY - H * 0.03));
const my = hzY * (0.18 + rng() * 0.22);
if (cond.sunVisible && !night) {
const warm = golden ? '#f6cf92' : dawn ? '#f6dfc2' : dusk ? '#e8b284' : '#f4ecd8';
stamped(0.162, veil(sunX, sy, W * 0.13, 0, W * 0.075, warm, (dusk ? 0.20 : 0.14) * softK, 1));
// the disc itself is structural light: plain dabs, no wrist may
// scatter it into moths
stamped(0.164, (c) => {
dab(c, sunX, sy, W * 0.016, mixHex(warm, '#fff8e8', 0.5), 0.55 * softK);
dab(c, sunX, sy, W * 0.011, mixHex(warm, '#ffffff', 0.65), 0.75 * softK);
});
for (let k = 0; k < 2; k++) {
const tx = sunX + (rng() - 0.5) * W * 0.004;
const ty = sy + (rng() - 0.5) * W * 0.004;
const tw = W * (0.010 + rng() * 0.005);
const ta = (0.4 + rng() * 0.2) * softK;
stamped(0.166 + k * 0.002, touch(tx, ty, tw, warm, ta));
}
} else if (moonlit) {
stamped(0.162, veil(moonX, my, W * 0.11, 0, W * 0.06, '#c8d2e2', 0.18 * softK, 1));
stamped(0.164, (c) => {
dab(c, moonX, my, W * 0.014, '#e8edf4', 0.55 * softK);
dab(c, moonX, my, W * 0.009, '#f4f7fa', 0.85 * softK);
});
}
}
// -- SWIFTS: dark sickles crossing the towers, dice 0-6 ----------------------
const nSwifts = night ? 0 : Math.floor(rng() * 7);
for (let i = 0; i < nSwifts; i++) {
const gx = W * (0.08 + rng() * 0.84);
const gy = H * (0.06 + rng() * Math.max(0.10, hzF * 0.75));
const s = W * (0.005 + rng() * 0.008);
const flap = (rng() - 0.5) * 0.7;
const col = dusk ? '#1c1e26' : '#2a2c32';
const ga = 0.6 + rng() * 0.25;
stamped(0.174 + rng() * 0.006, (c, jr) => {
rigger(c, jr, { x: gx, y: gy, length: s, color: col, alpha: ga,
angle: Math.PI * 1.16 + flap, width: Math.max(0.7, s * 0.15), bow: s * 0.45 });
rigger(c, jr, { x: gx, y: gy, length: s, color: col, alpha: ga,
angle: Math.PI * 1.84 + flap, width: Math.max(0.7, s * 0.15), bow: -s * 0.45 });
});
}
// -- THE FAR BANK: a low tree line dissolving at the horizon ------------------
{
// the bank itself: a dark broken band the château stands on
for (let i = 0, n = 54; i < n; i++) {
const x = -W * 0.02 + rng() * W * 1.04;
const y = hzY + 1 + rng() * H * 0.005;
const col = grade(mixHex(gp.shade[0], deepC, 0.55));
const len = W * (0.02 + rng() * 0.06);
const ang = (rng() - 0.5) * 0.05;
const wd = Math.max(1.4, H * (0.0025 + rng() * 0.004));
const a = (0.13 + rng() * 0.14) * softK;
stamped(0.183 + rng() * 0.008, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.35);
});
}
const treeN = Math.round(130 * covK * (misty ? 0.6 : 1));
for (let i = 0; i < treeN; i++) {
const x = rng() * W;
const y = hzY - H * (0.002 + rng() * 0.018);
const cols = brokenColor(null, rng, grade(mixHex(gp.shade[1], deepC, 0.45)), 0.4);
const col = cols[Math.floor(rng() * cols.length)];
const len = W * (0.010 + rng() * 0.026);
const ang = (rng() - 0.5) * 0.2;
const wd = Math.max(1.4, H * (0.004 + rng() * 0.007));
const a = (0.20 + rng() * 0.20) * softK;
stamped(0.185 + rng() * 0.018, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.5);
});
}
// the horizon breathes: bank and sky bleed into each other
for (let i = 0; i < 12; i++) {
const x = rng() * W;
const y = hzY - H * 0.004 + (rng() - 0.5) * H * 0.008;
const len = W * (0.06 + rng() * 0.14);
const ang = (rng() - 0.5) * 0.05;
const wd = Math.max(2, H * 0.008);
stamped(0.200 + rng() * 0.005, veil(x, y, len, ang, wd,
mixHex(hzC, skyAt(0.75), 0.5), 0.06 * softK, 0.25));
}
}
// -- THE RIVER: the great mirror, broken horizontal strokes ------------------
for (let i = 0, n = Math.round(1250 * covK); i < n; i++) {
const t = Math.pow(rng(), 0.9);
const y = hzY + 2 + t * Math.max(4, riverRange - 4);
const x = -W * 0.03 + rng() * W * 1.06;
const cols = brokenColor(null, rng, waterAt(t, x), 0.45);
const col = cols[Math.floor(rng() * cols.length)];
const len = W * (0.04 + rng() * 0.13) * (0.55 + t * 1.0);
const ang = (rng() - 0.5) * (stormy ? 0.10 : 0.045);
const wd = Math.max(1.4, H * (0.006 + rng() * 0.013) * (0.5 + t * 1.2));
const a = (0.32 + rng() * 0.30) * (0.6 + t * 0.7) * softK;
stamped(0.205 + 0.082 * t + rng() * 0.006, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// -- the sun's road / the moon's trembling path in the mirror -----------------
if (!night || moonlit) {
const roadC = night ? '#cdd6e4' : GLINT;
const roadN = night || golden || dawn || dusk ? 70 : 42;
for (let i = 0; i < roadN; i++) {
const t = Math.pow(rng(), 0.8);
const y = hzY + 3 + t * Math.max(4, riverRange - 6);
const x = roadX + gauss() * W * (0.012 + t * 0.030);
const len = Math.max(1.5, H * (0.003 + rng() * 0.008) * (0.6 + t));
const ang = (rng() - 0.5) * 0.10;
const wd = Math.max(0.8, H * 0.0035 * (0.6 + t));
const a = (night ? 0.12 : 0.10) * (0.4 + rng() * 0.9) * (0.4 + t * 0.9) * softK;
stamped(0.300 + rng() * 0.018, (c, jr) => {
band(c, jr, x, y, len, ang, wd, roadC, a);
});
}
}
// -- SAND ISLANDS: pale interruptions where the river runs shallow ------------
const sandFrac = season === 'summer' ? 0.85 : season === 'spring' ? 0.25 : 0.55;
// the étiage: summer's low water strands wide sand — at least two banks
const nSand = season === 'summer' ? 2 + Math.floor(rng() * 2) : 1 + Math.floor(rng() * 3);
const sandBanks = [];
for (let s = 0; s < nSand; s++) {
const t = 0.30 + (s / Math.max(1, nSand)) * 0.55 + (rng() - 0.5) * 0.10;
const sy = hzY + clamp01(t) * (riverRange - 8);
const sw = W * (0.06 + rng() * 0.16) * (0.5 + sandFrac);
const sx = W * (0.08 + rng() * 0.84);
sandBanks.push({ x: sx, y: sy, w: sw });
const nS = Math.round(58 * (0.5 + sandFrac));
for (let k = 0; k < nS; k++) {
const u = rng();
const px = sx + (u - 0.5) * sw + gauss() * W * 0.006;
const py = sy + gauss() * H * 0.004;
const cols = brokenColor(null, rng, grade(mixHex(sandC, waterAt(t, px), rng() * 0.08)), 0.4);
const col = cols[Math.floor(rng() * cols.length)];
const len = W * (0.014 + rng() * 0.038);
const ang = (rng() - 0.5) * 0.05;
const wd = Math.max(1.6, H * (0.005 + rng() * 0.009));
const a = (0.34 + rng() * 0.26) * softK;
// the island's body is structural — the pale interruption must read in
// every wrist; the hand returns on top of it for the grain
stamped(0.325 + 0.020 * (s / nSand) + 0.016 * u + rng() * 0.004, (c, jr) => {
band(c, jr, px, py, len, ang, wd, col, a);
});
if (rng() < 0.5) {
const gx2 = px + (rng() - 0.5) * len;
const gy2 = py + (rng() - 0.5) * H * 0.006;
const gc = grade(mixHex(sandC, '#c8a878', rng() * 0.4));
stamped(0.334 + 0.014 * u + rng() * 0.003, (c, jr) => {
touch(c, jr, gx2, gy2, Math.max(1.2, H * 0.004), gc, 0.22 * softK, 0, 0.5);
});
}
}
// the wet edge: a dark broken hem where sand meets water
for (let k = 0, nE = 18; k < nE; k++) {
const u = rng();
const px = sx + (u - 0.5) * sw;
const py = sy + H * 0.007 + (rng() - 0.5) * H * 0.002;
const len = W * (0.012 + rng() * 0.030);
const ang = (rng() - 0.5) * 0.05;
stamped(0.343 + 0.014 * u + rng() * 0.003, (c, jr) => {
smear(c, jr, px, py, len, ang, Math.max(1.1, H * 0.0035),
mixHex(sandC, deepC, 0.60), 0.22 * softK, 0.3);
});
}
// grass tufts on the bigger islands, summer only
if (season === 'summer' && rng() < 0.6) {
for (let k = 0, nT = 10; k < nT; k++) {
const u = rng();
const px = sx + (u - 0.5) * sw * 0.8;
const py = sy - H * (0.002 + rng() * 0.005);
const len = H * (0.004 + rng() * 0.006);
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.4;
stamped(0.348 + rng() * 0.003, (c, jr) => {
smear(c, jr, px, py, len, ang, Math.max(0.8, H * 0.002),
grade(mixHex(gp.mid[0], sandC, 0.3)), 0.18 * softK, 0.4);
});
}
}
}
// -- HERONS: 0-2, three strokes each, standing where the shallows are ---------
const nHerons = (dawn ? 1 : 0) + Math.floor(rng() * 3);
for (let hI = 0; hI < Math.min(2, nHerons); hI++) {
const bk = sandBanks[Math.floor(rng() * sandBanks.length)] || { x: W * 0.5, y: hzY + riverRange * 0.6, w: W * 0.1 };
const hx = bk.x + (rng() - 0.5) * bk.w * 0.6;
const hy = bk.y - H * 0.002;
const hh = Math.max(9, H * 0.024);
const col = mixHex('#3a3e46', SIL, 0.3);
const pH = 0.352 + hI * 0.004;
stamped(pH, (c, jr) => { // body
smear(c, jr, hx, hy - hh * 0.35, hh * 0.55, 0.1, hh * 0.30, col, 0.75 * softK, 0.55);
});
stamped(pH + 0.001, (c, jr) => { // the neck, an S of a rigger
rigger(c, jr, { x: hx + hh * 0.15, y: hy - hh * 0.5, length: hh * 0.55, color: col,
alpha: 0.7 * softK, angle: -Math.PI / 2 + 0.25, width: Math.max(0.8, hh * 0.09), bow: hh * 0.22 });
});
stamped(pH + 0.002, (c, jr) => { // the legs, two hairs in the water
rigger(c, jr, { x: hx, y: hy - hh * 0.12, length: hh * 0.16, color: col,
alpha: 0.6 * softK, angle: Math.PI / 2, width: Math.max(0.6, hh * 0.06), bow: 0 });
});
// the heron's own faint double below
stamped(pH + 0.003, (c, jr) => {
smear(c, jr, hx, hy + hh * 0.22, hh * 0.5, Math.PI / 2, hh * 0.16,
mixHex(col, waterAt(0.6, hx), 0.6), 0.12 * softK, 0.3);
});
}
// -- THE CHÂTEAU: turreted mass on the far bank, AT the horizon ---------------
const castleThird = rng() < 0.5 ? cond.motifSide
: (['left', 'center', 'right'])[Math.floor(rng() * 3)];
const cwF = 0.14 + rng() * 0.10;
const cxF = castleThird === 'left' ? 0.17 + rng() * 0.13
: castleThird === 'center' ? 0.40 + rng() * 0.20 : 0.68 + rng() * 0.13;
const castleW = W * cwF;
const cxi = clamp(cxF, cwF * 0.5 + 0.05, 1 - cwF * 0.5 - 0.05) * W;
const cxL = cxi - castleW / 2, cxR = cxi + castleW / 2;
const massTopY = hzY - H * (0.055 + rng() * 0.055);
const castleBaseY = hzY + H * 0.004;
const nTowers = 4 + Math.floor(rng() * 5); // 4-8
const towers = [];
for (let i = 0; i < nTowers; i++) {
const corner = i === 0 || i === nTowers - 1;
const tw = castleW * (0.10 + rng() * 0.06);
let x0;
if (i === 0) x0 = cxL - tw * 0.25;
else if (i === nTowers - 1) x0 = cxR - tw * 0.75;
else {
const u = (i + rng() * 0.7) / (nTowers - 1);
x0 = cxL + castleW * u - tw * 0.5;
}
const bodyTop = massTopY - H * (corner ? 0.030 + rng() * 0.028 : 0.010 + rng() * 0.022);
towers.push({
x0, x1: x0 + tw, xc: x0 + tw * 0.5,
bodyTop, apexY: bodyTop - tw * (1.1 + rng() * 0.9), corner,
});
}
towers.sort((a, b) => a.xc - b.xc);
const topAt = (x) => {
let top = massTopY;
for (const t of towers) {
if (x >= t.x0 && x <= t.x1) {
const u = (x - t.x0) / Math.max(1, t.x1 - t.x0);
const roofY = u < 0.5 ? lerp(t.bodyTop, t.apexY, u / 0.5) : lerp(t.apexY, t.bodyTop, (u - 0.5) / 0.5);
if (roofY < top) top = roofY;
}
}
return top;
};
const inTower = (x) => towers.some((t) => x >= t.x0 && x <= t.x1);
const sunLeft = cond.sunX < 0.5;
const litDir = sunLeft ? -1 : 1; // the lit faces look sunward
// stone colour at a point: the west faces catch the last light
const stoneAt = (x, y) => {
const nearSun = 1 - clamp01(Math.abs(x - sunX) / (W * 0.7));
const heightK = clamp01((castleBaseY - y) / Math.max(1, castleBaseY - massTopY));
const lit = 0.30 + 0.42 * nearSun * (litDir * (x - cxi) > 0 ? 1 : 0.55) + 0.16 * heightK;
let col = mixHex(stoneShade, stoneLitK, clamp01(lit));
if (golden) col = mixHex(col, '#e0a868', Math.max((nearSun - 0.5) * 1.2, 0.15));
if (nightLit) col = mixHex(col, '#f0d0a0', 0.20 + 0.38 * (1 - heightK)); // floodlit from below
const hazeMix = (misty ? 0.42 : 0) + (dawn ? 0.22 : 0);
if (hazeMix > 0) col = mixHex(col, mixHex(hzC, '#e4e8e6', 0.4), hazeMix);
return col;
};
// the mass, filled once so the form closes in every hand — a vertical value
// run inside the fill: light catches the upper walls, the base drinks shadow
const castleFill = (cc) => {
const cTop = mixHex(mixHex(stoneShade, stoneLitK, 0.62), golden ? '#c89058' : dusk ? '#8a6a70' : '#a08468', 0.16);
const cBot = mixHex(mixHex(stoneShade, stoneLitK, 0.18), '#4c4038', 0.22);
const nT = parseInt(cTop.slice(1), 16), nB = parseInt(cBot.slice(1), 16);
const g = cc.createLinearGradient(0, massTopY, 0, castleBaseY);
g.addColorStop(0, `rgba(${(nT >> 16) & 255},${(nT >> 8) & 255},${nT & 255},0.92)`);
g.addColorStop(1, `rgba(${(nB >> 16) & 255},${(nB >> 8) & 255},${nB & 255},0.92)`);
cc.fillStyle = g;
cc.beginPath();
cc.moveTo(cxL, castleBaseY);
const step = Math.max(2, castleW / 140);
for (let x = cxL; x < cxR; x += step) cc.lineTo(x, topAt(x));
cc.lineTo(cxR, topAt(cxR));
cc.lineTo(cxR, castleBaseY);
cc.closePath();
cc.fill();
// the shadow side: a dark plane on the east faces, feathered at the seam
// so the mass doesn't split into two flat boxes — three stepped slices,
// alpha growing with depth into the shade
const ns = parseInt(mixHex(stoneShade, '#38302c', 0.45).slice(1), 16);
const feather = Math.max(step * 2, castleW * 0.055);
const shEdges = litDir < 0
? [cxi, cxi + feather, cxi + 2 * feather, cxR]
: [cxi, cxi - feather, cxi - 2 * feather, cxL];
for (let sI = 0; sI < 3; sI++) {
const fA = Math.min(shEdges[sI], shEdges[sI + 1]);
const fB = Math.max(shEdges[sI], shEdges[sI + 1]);
if (Math.abs(fB - fA) < 1) continue;
cc.fillStyle = `rgba(${(ns >> 16) & 255},${(ns >> 8) & 255},${ns & 255},${[0.05, 0.08, 0.12][sI]})`;
cc.beginPath();
cc.moveTo(fA, castleBaseY);
for (let x = fA; x < fB; x += step) cc.lineTo(x, topAt(x));
cc.lineTo(fB, topAt(fB));
cc.lineTo(fB, castleBaseY);
cc.closePath();
cc.fill();
}
// the roofs, filled slate over the mass
const nr = parseInt(roofC.slice(1), 16);
cc.fillStyle = `rgba(${(nr >> 16) & 255},${(nr >> 8) & 255},${nr & 255},0.92)`;
for (const t of towers) {
cc.beginPath();
cc.moveTo(t.x0, t.bodyTop);
cc.lineTo(t.xc, t.apexY);
cc.lineTo(t.x1, t.bodyTop);
cc.closePath();
cc.fill();
}
};
stamped(0.366, castleFill);
// the stone's courses: vertical facet strokes, lit west / shaded east
{
const wF = Math.max(3, castleW * 0.030);
const nF = Math.ceil(castleW / wF) + 1;
for (let i = 0; i < nF; i++) {
const x = cxL + i * wF;
if (x > cxR) break;
const top = topAt(x);
const nSeg = Math.max(1, Math.ceil((castleBaseY - top) / (H * 0.035)));
const colOff = ((i % 2 === 0) === (litDir < 0) ? 0.14 : -0.16) + (rng() - 0.5) * 0.16;
for (let k = 0; k < nSeg; k++) {
const yMid = lerp(castleBaseY, top, (k + 0.5) / nSeg);
let col = mixHex(stoneShade, stoneAt(x, yMid), 0.75);
col = mixHex(col, mixHex(stoneLit, '#ffffff', 0.2), clamp01(colOff));
const len = (castleBaseY - top) / nSeg * (0.8 + rng() * 0.4);
const wd = wF * (0.7 + rng() * 0.5);
const px = x + (rng() - 0.5) * wF * 0.4;
const ang = -Math.PI / 2 + (rng() - 0.5) * 0.10;
const a = (0.44 + rng() * 0.28) * (misty ? 0.6 : 1) * softK;
stamped(0.370 + 0.030 * (i / nF) + 0.010 * (k / Math.max(1, nSeg)) + rng() * 0.003, (c, jr) => {
smear(c, jr, px, yMid, len, ang, wd, col, a, 0.6);
});
}
}
}
// ashlar shadow lines: thin dark horizontals binding the courses
for (let i = 0, n = Math.round(50 * covK); i < n; i++) {
const x = cxL + rng() * castleW;
const y = lerp(castleBaseY, topAt(x), rng());
const len = castleW * (0.01 + rng() * 0.03);
const ang = (rng() - 0.5) * 0.06;
stamped(0.402 + rng() * 0.010, (c, jr) => {
smear(c, jr, x, y, len, ang, Math.max(0.8, H * 0.0018),
mixHex(stoneShade, '#141210', 0.35), 0.10 * softK, 0.25);
});
}
// the roofs: shingle texture along the slopes, ridgelines pale
for (let ti = 0; ti < towers.length; ti++) {
const t = towers[ti];
const pR = 0.418 + 0.018 * (ti / towers.length);
const twW = t.x1 - t.x0;
for (let k = 0, n = 7; k < n; k++) { // shingles on both slopes
const u = (k + 0.5) / n;
for (const slope of [0, 1]) {
const x = slope === 0 ? lerp(t.x0, t.xc, u) : lerp(t.xc, t.x1, u);
const y = slope === 0 ? lerp(t.bodyTop, t.apexY, u) : lerp(t.apexY, t.bodyTop, u) + 1;
const lit = slope === 0 ? litDir < 0 : litDir > 0;
const len = twW * 0.30;
const ang = Math.PI / 2 + (lit ? -0.2 : 0.2);
const wd = Math.max(1.0, twW * 0.16);
const col = mixHex(roofC, lit ? ridgeC : '#10141c', lit ? 0.30 : 0.18);
const a = (0.30 + rng() * 0.20) * softK;
stamped(pR + 0.012 * u + rng() * 0.002, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.5);
});
}
}
// the ridge: one pale hair along the lit slope — plain, no hand may bend it
stamped(pR + 0.015, (c, jr) => {
const s0 = litDir < 0 ? [t.x0, t.bodyTop] : [t.xc, t.apexY];
const s1 = litDir < 0 ? [t.xc, t.apexY] : [t.x1, t.bodyTop];
rigger(c, jr, { x: s0[0], y: s0[1], length: Math.hypot(s1[0] - s0[0], s1[1] - s0[1]),
color: mixHex(ridgeC, '#fff4d8', 0.25), alpha: 0.55 * softK,
angle: Math.atan2(s1[1] - s0[1], s1[0] - s0[0]),
width: Math.max(0.6, twW * 0.05), bow: 0 });
});
// the finial
stamped(pR + 0.016, touch(t.xc, t.apexY - 1.5, Math.max(1.2, twW * 0.10),
mixHex(SIL, '#c8b890', 0.3), 0.6 * softK));
}
// the west faces' lit rim: a warm hair down the sunward tower edges
if (!night || nightLit) {
for (const t of towers) {
const ex = litDir < 0 ? t.x0 + (t.x1 - t.x0) * 0.12 : t.x1 - (t.x1 - t.x0) * 0.12;
const ry = lerp(t.bodyTop, castleBaseY, 0.5);
const len = (castleBaseY - t.bodyTop) * 0.7;
const wd = Math.max(1.0, (t.x1 - t.x0) * 0.10);
stamped(0.446, (c, jr) => {
smear(c, jr, ex, ry, len, Math.PI / 2, wd,
mixHex(stoneLitK, '#fff0d0', 0.40), 0.22 * softK, 0.35);
});
}
}
// WINDOWS: kindling warm as the dusk deepens — 4-12
{
const nWin = 4 + Math.floor(rng() * 9);
const litFrac = night ? 0.85 : dusk ? 0.65 : dawn ? 0.25 : 0.10;
for (let i = 0; i < nWin; i++) {
let wx, wy;
if (rng() < 0.55 && towers.length) { // on a tower body
const t = towers[Math.floor(rng() * towers.length)];
wx = t.x0 + (t.x1 - t.x0) * (0.3 + rng() * 0.4);
wy = lerp(t.bodyTop, castleBaseY, 0.2 + rng() * 0.55);
} else { // on the main mass
wx = cxL + castleW * (0.08 + rng() * 0.84);
wy = lerp(massTopY, castleBaseY, 0.25 + rng() * 0.5);
}
const lit = rng() < litFrac;
const col = lit ? WINGLIT : mixHex('#2c2824', stoneShade, 0.3);
const glow = Math.max(1.5, H * 0.006);
stamped(0.448 + rng() * 0.010, (c, jr) => {
touch(c, jr, wx, wy, Math.max(1.2, H * 0.004), col, (lit ? 0.85 : 0.4) * softK, 0, 0.6);
if (lit) dab(c, wx, wy, glow, WINGLIT, 0.10 * softK, 0, 0.8);
});
}
if (nightLit) { // the floodlight's pool climbing the tower bases
for (let i = 0, n = 18; i < n; i++) {
const t = towers[Math.floor(rng() * towers.length)];
const px = t.xc;
const py = castleBaseY - H * (0.006 + rng() * 0.035);
const len = castleW * 0.14;
const wd = H * 0.022;
stamped(0.458 + rng() * 0.004, veil(px, py, len, 0, wd, '#f0d0a0', 0.11 * softK, 0.4));
}
}
}
// -- THE MIRROR: the château descending, trembling, broken --------------------
{
const stillK = (night && !stormy && !rain) ? 1.25 : 1.0; // still water doubles longer
const maxLen = Math.max(8, riverRange * 0.62 * stillK);
const reflN = Math.round(300 * (night ? 1.15 : 1));
for (let i = 0; i < reflN; i++) {
const x = cxL + Math.pow(rng(), 0.9) * castleW;
const hAbove = castleBaseY - topAt(x);
const reflLen = Math.min(maxLen, hAbove * (0.55 + rng() * 0.45) * stillK);
if (reflLen < 3) continue;
const y0 = hzY + 2 + rng() * 4;
const depth = clamp01(((y0 + reflLen * 0.5) - hzY) / riverRange);
const roofR = inTower(x) && rng() < 0.5;
let col = roofR ? mixHex(roofC, waterAt(depth, x), 0.35)
: mixHex(stoneAt(x, castleBaseY - hAbove * rng()), waterAt(depth, x), 0.30 + rng() * 0.22);
if (nightLit && !roofR) col = mixHex(col, '#f0d0a0', 0.22);
const wob = (rng() - 0.5) * W * (0.004 + depth * 0.006) * (2 - stillK);
const len = reflLen * (0.55 + rng() * 0.45);
const ang = Math.PI / 2 + (rng() - 0.5) * (0.30 / stillK);
const wd = Math.max(1.4, castleW * (0.008 + rng() * 0.012));
const a = (0.13 + rng() * 0.11) * (night ? 1.2 : 1) * (misty ? 0.6 : 1) * softK;
// the doubled gesture is structural: the trembling verticals ride the
// plain house stroke so no wrist may blob the mirror away
stamped(0.472 + 0.036 * (x - cxL) / castleW + rng() * 0.004, (c, jr) => {
band(c, jr, x + wob, y0 + len * 0.5, len, ang, wd, col, a);
});
}
// the doubled gesture's soft body: broad translucent veils carrying the
// mass's tone straight down, so the mirror reads before its strokes do
for (const t of towers) {
const reflH = Math.min(maxLen, (castleBaseY - t.bodyTop) * 0.7);
for (let k = 0; k < 3; k++) {
const vx = t.xc + (rng() - 0.5) * (t.x1 - t.x0) * 0.4;
const vy = hzY + 2 + k * reflH * 0.30;
const vlen = reflH * 0.42;
const vcol = mixHex(roofC, waterAt(0.25, vx), 0.45);
stamped(0.476 + 0.004 * k, veil(vx, vy + vlen * 0.5, vlen, Math.PI / 2,
(t.x1 - t.x0) * 0.7, vcol, (0.10 + rng() * 0.06) * softK, 0.5));
}
}
{
const bodyH = Math.min(maxLen, (castleBaseY - massTopY) * 0.6);
for (let k = 0; k < 4; k++) {
const vx = cxL + castleW * (0.15 + 0.7 * rng());
const vy = hzY + 3 + k * bodyH * 0.22;
const vlen = bodyH * 0.30;
const vcol = mixHex(stoneLit, waterAt(0.3, vx), 0.55);
stamped(0.486 + 0.004 * k, veil(vx, vy + vlen * 0.5, vlen, Math.PI / 2,
castleW * 0.16, vcol, (0.08 + rng() * 0.05) * softK, 0.6));
}
}
// the water's strokes crossing it: the mirror breaks in horizontal courses
for (let i = 0, n = Math.round(170 * covK); i < n; i++) {
const t = rng() * 0.7;
const y = hzY + 4 + t * Math.max(6, Math.min(riverRange * 0.75, maxLen + 8));
const x = rng() < 0.75 ? cxL - castleW * 0.1 + rng() * castleW * 1.2 : rng() * W;
const cols = brokenColor(null, rng, waterAt(t + 0.1, x), 0.4);
const col = cols[Math.floor(rng() * cols.length)];
const len = W * (0.03 + rng() * 0.09);
const ang = (rng() - 0.5) * 0.05;
const wd = Math.max(1.4, H * (0.005 + rng() * 0.009) * (0.6 + t));
const a = (0.28 + rng() * 0.24) * softK;
stamped(0.518 + 0.030 * t + rng() * 0.004, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
// the near water darkens toward us: the shallows' hold before the bank
for (let i = 0, n = 46; i < n; i++) {
const t = 0.78 + rng() * 0.22;
const y = hzY + t * (riverRange - 2);
const x = -W * 0.02 + rng() * W * 1.04;
const col = mixHex(waterAt(0.9, x), deepC, 0.45);
const len = W * (0.04 + rng() * 0.10);
const ang = (rng() - 0.5) * 0.04;
const wd = Math.max(1.8, H * (0.007 + rng() * 0.012));
const a = (0.22 + rng() * 0.22) * softK;
stamped(0.552 + rng() * 0.004, (c, jr) => {
band(c, jr, x, y, len, ang, wd, col, a);
});
}
}
// -- THE NEAR BANK: the darkest horizontal, grasses to the water --------------
const bankEdge = [];
for (let i = 0; i <= 48; i++) {
const x = (i / 48) * W;
bankEdge.push(bankY + Math.sin(x * 0.01 + bankF * 9) * H * 0.006
+ (rng() - 0.5) * H * 0.008 + (rng() < 0.2 ? rng() * H * 0.010 : 0));
}
const bankFill = (c) => {
const c1 = mixHex(BNK_D[1], '#20241c', 0.30);
const n1 = parseInt(c1.slice(1), 16);
c.fillStyle = `rgba(${(n1 >> 16) & 255},${(n1 >> 8) & 255},${n1 & 255},0.55)`;
c.beginPath();
c.moveTo(0, H);
c.lineTo(0, bankEdge[0] + H * 0.012);
for (let i = 0; i < bankEdge.length; i++) c.lineTo((i / (bankEdge.length - 1)) * W, bankEdge[i] + H * 0.012);
c.lineTo(W, H);
c.closePath();
c.fill();
};
stamped(0.556, bankFill);
for (let i = 0, n = Math.round(300 * covK); i < n; i++) {
const f = Math.pow(rng(), 0.85);
const x = -W * 0.02 + rng() * W * 1.04;
const y = bankY + f * (H - bankY) + gauss() * H * 0.006;
const arr = f < 0.3 ? BNK_M : BNK_D;
const cols = brokenColor(null, rng, arr[Math.floor(rng() * arr.length)], 0.4);
const col = cols[Math.floor(rng() * cols.length)];
const len = W * (0.018 + rng() * 0.05) * (0.6 + f);
const ang = (rng() - 0.5) * 0.08;
const wd = Math.max(1.6, H * (0.006 + rng() * 0.010) * (0.6 + f));
const a = (0.22 + rng() * 0.20) * softK;
stamped(0.560 + 0.034 * f + rng() * 0.004, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.4);
});
}
// grasses at the waterline: vertical dark blades, the bank's fringe
for (let i = 0, n = Math.round(240 * covK); i < n; i++) {
const x = rng() * W;
const y = bankY + (rng() - 0.25) * H * 0.014;
const blade = rng() < 0.6;
const blen = H * (0.008 + rng() * 0.018);
const bang = -Math.PI / 2 + (rng() - 0.5) * 0.5;
const colD = BNK_D[Math.floor(rng() * BNK_D.length)];
const colM = BNK_M[Math.floor(rng() * BNK_M.length)];
stamped(0.604 + rng() * 0.026, (c, jr) => {
if (blade) {
smear(c, jr, x, y, blen, bang, Math.max(0.9, H * 0.0022), colD, 0.34 * softK, 0.4);
} else {
touch(c, jr, x, y, Math.max(1.2, H * 0.004), colM, 0.26 * softK, 0, 0.7);
}
});
}
// frost: winter's pale breath over the grass
if (snowy) {
for (let i = 0, n = 60; i < n; i++) {
const f = rng();
const x = rng() * W;
const y = bankY + f * (H - bankY);
const len = W * (0.012 + rng() * 0.03);
const ang = (rng() - 0.5) * 0.06;
const wd = Math.max(1.2, H * 0.004);
const a = (0.10 + rng() * 0.14) * softK;
const col = mixHex('#eef2f4', BNK_L[0], 0.4);
stamped(0.632 + rng() * 0.006, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, col, a, 0.35);
});
}
}
// -- BANK TREES: russet in autumn, green else, bare in winter ------------------
{
const autumn = season === 'autumn';
const RUSSET = ['#b06a38', '#c88a48', '#8a5230', '#a87c3c'];
const nTrees = 2 + Math.floor(rng() * 3);
const side = rng() < 0.5 ? 0 : 1;
for (let tI = 0; tI < nTrees; tI++) {
const tx = side === 0 ? W * (0.02 + rng() * 0.16) : W * (0.84 + rng() * 0.14);
const th = H * (0.08 + rng() * 0.09);
const ty = bankY + H * 0.01;
const pT = 0.638 + tI * 0.008;
const trunkAng = -Math.PI / 2 + (rng() - 0.5) * 0.2;
const trunkC = mixHex(SIL, '#4a3828', 0.5);
stamped(pT, (c, jr) => { // trunk
if (season !== 'winter') {
rigger(c, jr, { x: tx, y: ty - th * 0.35, length: th * 0.55, color: trunkC,
alpha: 0.7 * softK, angle: trunkAng, width: Math.max(1.2, th * 0.06), bow: th * 0.05 });
}
});
for (let k = 0, n = 20; k < n; k++) { // the crown, dark against the water
const u = rng() * TAU, r = Math.pow(rng(), 0.7) * th * 0.34;
const cx2 = tx + Math.cos(u) * r, cy2 = ty - th * 0.85 + Math.sin(u) * r * 0.8;
const cl = th * (0.12 + rng() * 0.16);
const ca = (rng() - 0.5) * 0.3;
const baseCol = season === 'winter' ? mixHex('#d8dee4', ridgeC, 0.4)
: mixHex(grade(autumn ? pickA(RUSSET) : pickA(BNK_M)), SIL, 0.30);
const crownCol = u > 0 && u < Math.PI ? mixHex(baseCol, GLINT, 0.10) : baseCol;
const ca0 = (0.34 + rng() * 0.20) * softK;
stamped(pT + 0.002 + 0.004 * (k / n) + rng() * 0.001, (c, jr) => {
if (season === 'winter') {
touch(c, jr, cx2, cy2, Math.max(1.0, th * 0.06), crownCol, 0.14 * softK, 0, 0.6);
} else {
smear(c, jr, cx2, cy2, cl, ca, Math.max(1.6, th * 0.11), crownCol, ca0, 0.5);
}
});
}
// the tree doubled in the river below
for (let k = 0, n = 6; k < n; k++) {
const rx = tx + gauss() * W * 0.004;
const ry = hzY + 4 + rng() * riverRange * 0.3;
const rl = H * (0.010 + rng() * 0.02);
const ra = Math.PI / 2 + (rng() - 0.5) * 0.4;
const reflCol = mixHex(autumn ? pickA(RUSSET) : pickA(BNK_M), waterAt(0.3, tx), 0.55);
stamped(pT + 0.007 + 0.0006 * k, (c, jr) => {
smear(c, jr, rx, ry, rl, ra, Math.max(1.0, H * 0.003), reflCol, 0.12 * softK, 0.3);
});
}
}
}
// -- MOORED ROWBOATS / FISHING STAKES (dice) -----------------------------------
{
const boatsOn = rng() < 0.65;
const nBoats = boatsOn ? 1 + Math.floor(rng() * 2) : 0;
for (let b = 0; b < nBoats; b++) {
const bx = W * (0.08 + rng() * 0.84);
const by = bankY - H * (0.004 + rng() * 0.020);
const bl = W * (0.035 + rng() * 0.03);
const bTilt = (rng() - 0.5) * 0.10;
const bTone = grade((['#2c3236', '#252e3c', '#3a3034'])[Math.floor(rng() * 3)], 0.9);
const pB = 0.672 + b * 0.012;
for (let k = 0; k < 5; k++) { // the hull, a shallow crescent
const t = (k / 4) * 2 - 1;
const hx = bx + t * bl * 0.5;
const hy = by + Math.pow(t * t, 1.2) * bl * 0.06;
stamped(pB + 0.002 * k, (c, jr) => {
smear(c, jr, hx, hy, bl * 0.28, bTilt, Math.max(1.2, bl * 0.09), bTone, 0.60 * softK, 0.5);
});
}
stamped(pB + 0.011, (c, jr) => { // the gunwale catching light
smear(c, jr, bx, by - bl * 0.04, bl * 0.85, bTilt, Math.max(0.7, bl * 0.05),
mixHex('#c4b696', bTone, 0.35), 0.4 * softK, 0.5);
});
for (let k = 0; k < 3; k++) { // its double, wobbling below
const rx = bx + gauss() * bl * 0.08;
const ry = by + bl * (0.10 + k * 0.05);
stamped(pB + 0.013 + 0.001 * k, (c, jr) => {
smear(c, jr, rx, ry, bl * (0.5 - k * 0.12), bTilt, Math.max(0.8, bl * 0.07),
mixHex(bTone, waterAt(0.7, bx), 0.55), (0.14 - k * 0.03) * softK, 0.3);
});
}
if ((night || dusk) && rng() < 0.5) {
stamped(pB + 0.016, touch(bx + bl * 0.2, by - bl * 0.08, Math.max(0.9, bl * 0.05), WINGLIT, 0.7 * softK));
}
}
const nStakes = boatsOn ? 0 : 3 + Math.floor(rng() * 4);
for (let s = 0; s < nStakes; s++) {
const sx = W * (0.06 + rng() * 0.88);
const sy = bankY - H * (0.004 + rng() * 0.016);
const sl = H * (0.02 + rng() * 0.035);
const sAng = -Math.PI / 2 + (rng() - 0.5) * 0.24;
const pS = 0.688 + s * 0.002;
stamped(pS, (c, jr) => {
rigger(c, jr, { x: sx, y: sy, length: sl, color: mixHex(SIL, '#3c3228', 0.4),
alpha: 0.65 * softK, angle: sAng, width: Math.max(1.0, sl * 0.06), bow: 0 });
});
stamped(pS + 0.001, (c, jr) => { // the stake's wobbling reflection
rigger(c, jr, { x: sx + 1.2, y: sy + 2, length: sl * 0.5,
color: mixHex(SIL, waterAt(0.75, sx), 0.55), alpha: 0.16 * softK,
angle: Math.PI / 2 + sAng * -0.2, width: Math.max(0.8, sl * 0.05), bow: 2 });
});
}
}
// -- THE ANGLER: one figure, under 15 strokes (dice 0-1) ------------------------
if (rng() < 0.5) {
const fx = W * (0.10 + rng() * 0.80);
const fy = bankY + H * (0.01 + rng() * 0.05);
const hh = H * (0.028 + rng() * 0.012);
const coat = grade((['#2e2a28', '#3a3230', '#4a4038'])[Math.floor(rng() * 3)], 0.9);
const rodAng = -Math.PI / 2 + (litDir > 0 ? -0.5 : 0.5);
const rodTipX = fx + hh * 0.1 + Math.cos(rodAng) * hh * 1.1;
const rodTipY = fy - hh * 0.55 + Math.sin(rodAng) * hh * 1.1;
stamped(0.704, (c, jr) => {
band(c, jr, fx, fy - hh * 0.45, hh * 0.50, -Math.PI / 2,
Math.max(1.4, hh * 0.28), coat, 0.85); // body
band(c, jr, fx - hh * 0.08, fy - hh * 0.08, hh * 0.22, -Math.PI / 2,
Math.max(0.8, hh * 0.10), mixHex(coat, '#14161c', 0.3), 0.7); // legs
band(c, jr, fx + hh * 0.08, fy - hh * 0.08, hh * 0.22, -Math.PI / 2,
Math.max(0.8, hh * 0.10), mixHex(coat, '#14161c', 0.3), 0.7);
dab(c, fx, fy - hh * 0.78, Math.max(0.9, hh * 0.13), mixHex(coat, '#d8b89c', 0.35), 0.75); // head
rigger(c, jr, { x: fx + hh * 0.1, y: fy - hh * 0.55, length: hh * 1.1, // the rod
color: mixHex(SIL, '#4a3828', 0.4), alpha: 0.7, angle: rodAng,
width: Math.max(0.7, hh * 0.05), bow: hh * 0.18 });
rigger(c, jr, { x: rodTipX, y: rodTipY, length: hh * 0.35, // the line
color: mixHex('#c8ccd4', waterAt(0.8, fx), 0.3), alpha: 0.4,
angle: Math.PI / 2, width: 0.6, bow: 0 });
smear(c, jr, fx, fy + hh * 0.10, hh * 0.5, Math.PI / 2, hh * 0.18, // his faint double
mixHex(coat, waterAt(0.85, fx), 0.55), 0.10 * softK, 0.3);
});
}
// -- THE HOUR'S SIGNATURES -------------------------------------------------------
if (dawn || misty) { // mist lying on the water, the far bank a ghost
for (let k = 0, n = dawn ? 30 : 20; k < n; k++) {
const t = 0.05 + rng() * 0.7;
const x = rng() * W;
const y = hzY + t * riverRange;
const len = W * (0.08 + rng() * 0.20);
const ang = (rng() - 0.5) * 0.06;
const wd = Math.max(2, H * (0.016 + rng() * 0.030));
stamped(0.716 + rng() * 0.018, veil(x, y, len, ang, wd,
mixHex('#e8ece8', skyAt(0.8), 0.3), (0.05 + rng() * 0.07) * softK, 0.22));
}
if (dawn) { // the château a grey ghost in it
stamped(0.726, veil(cxi, lerp(massTopY, castleBaseY, 0.5), castleW * 1.3, 0, H * 0.10,
'#e8ece8', 0.10 * softK, 0.35));
}
}
if (noon && !misty) { // heat shimmer at the waterline
for (let k = 0, n = 26; k < n; k++) {
const x = rng() * W;
const y = hzY + rng() * riverRange * 0.25;
const len = W * (0.02 + rng() * 0.05);
const ang = (rng() - 0.5) * 0.04;
const wd = Math.max(1.0, H * 0.003);
const a = (0.08 + rng() * 0.10) * softK;
stamped(0.730 + rng() * 0.014, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, mixHex('#fff8e8', hzC, 0.4), a, 0.25);
});
}
}
if (rain) { // the streaked mirror: vertical grey runnels in the water
for (let k = 0, n = 30; k < n; k++) {
const t = rng();
const x = rng() * W;
const y = hzY + t * riverRange;
const len = H * (0.010 + rng() * 0.022);
const ang = Math.PI / 2 + (rng() - 0.5) * 0.10;
const a = (0.08 + rng() * 0.10) * softK;
stamped(0.736 + rng() * 0.012, (c, jr) => {
smear(c, jr, x, y, len, ang, Math.max(0.8, H * 0.002),
mixHex('#b8c4cc', waterAt(t, W * 0.5), 0.4), a, 0.2);
});
}
}
if (stormy) { // dark water under the boiling sky
for (let k = 0, n = 24; k < n; k++) {
const t = rng();
const x = rng() * W;
const y = hzY + t * riverRange;
const len = W * (0.03 + rng() * 0.07);
const ang = (rng() - 0.5) * 0.08;
const wd = Math.max(1.4, H * 0.006);
const a = (0.10 + rng() * 0.12) * softK;
stamped(0.742 + rng() * 0.012, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, mixHex(deepC, '#20262e', 0.4), a, 0.3);
});
}
}
if (golden) { // the mirror runs gold: warm pool under the sun
for (let k = 0, n = 22; k < n; k++) {
const t = 0.1 + rng() * 0.8;
const x = sunX + gauss() * W * 0.06;
const y = hzY + t * riverRange;
const len = W * (0.015 + rng() * 0.04);
const ang = (rng() - 0.5) * 0.06;
const wd = Math.max(1.2, H * 0.004);
const a = (0.08 + rng() * 0.10) * softK;
stamped(0.748 + rng() * 0.012, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, mixHex('#f0c880', GLINT, 0.35), a, 0.3);
});
}
// the château blushing: the west faces struck once more
for (let k = 0, n = 18; k < n; k++) {
const x = litDir < 0 ? cxL + rng() * castleW * 0.4 : cxR - rng() * castleW * 0.4;
const y = lerp(topAt(x), castleBaseY, rng());
const len = H * (0.01 + rng() * 0.02);
const ang = Math.PI / 2 + (rng() - 0.5) * 0.2;
const wd = Math.max(1.0, H * 0.003);
const a = (0.10 + rng() * 0.12) * softK;
stamped(0.756 + rng() * 0.010, (c, jr) => {
smear(c, jr, x, y, len, ang, wd, mixHex('#f4c896', '#ffe0b0', 0.5), a, 0.4);
});
}
}
if (night) { // the reflection doubled in still water: a second, steadier pass
for (let k = 0, n = 40; k < n; k++) {
const x = cxL + rng() * castleW;
const hAbove = castleBaseY - topAt(x);
const reflLen = Math.min(riverRange * 0.6, hAbove * (0.5 + rng() * 0.4));
if (reflLen < 3) continue;
const y0 = hzY + 3 + rng() * 3;
const len = reflLen * (0.6 + rng() * 0.4);
const ang = Math.PI / 2 + (rng() - 0.5) * 0.12;
const a = (0.05 + rng() * 0.06) * softK;
stamped(0.764 + rng() * 0.012, (c, jr) => {
smear(c, jr, x + 1.2, y0, len, ang, Math.max(1.4, castleW * 0.012),
mixHex(nightLit ? stoneLitK : stoneShade, waterAt(0.4, x), 0.45), a, 0.35);
});
}
}
// -- the unifying breath ----------------------------------------------------------
for (let i = 0; i < 18; i++) {
const y = rng() * H;
const col = y < hzY ? mixHex(stops[1].color, hzC, rng() * 0.5)
: y < bankY ? waterAt(clamp01((y - hzY) / riverRange), W * 0.5)
: BNK_D[Math.floor(rng() * BNK_D.length)];
const x = rng() * W;
const len = W * (0.08 + rng() * 0.18);
const ang = (rng() - 0.5) * 0.07;
const wd = Math.max(2, H * (0.015 + rng() * 0.035));
const a = (0.03 + rng() * 0.045) * softK * (hand.mode === 'impasto' ? 0.45 : 1);
stamped(0.780 + rng() * 0.022, veil(x, y, len, ang, wd, col, a));
}
// -- the hand's last pass: water shimmer, stone sparkle ----------------------------
for (let i = 0, n = Math.round(110 * covK); i < n; i++) {
const t = 0.15 + rng() * 0.8;
const x = rng() * W;
const y = hzY + t * riverRange;
const sz = Math.max(1.0, H * 0.0035 * (0.6 + t));
const col = mixHex(waterAt(t, x), GLINT, 0.35 + rng() * 0.4);
const a = (0.14 + rng() * 0.16) * softK;
stamped(0.812 + rng() * 0.030, (c, jr) => {
touch(c, jr, x, y, sz, col, a, 0, 0.5);
});
}
for (let i = 0, n = 70; i < n; i++) { // the stone catching light, struck late
const x = cxL + rng() * castleW;
const y = lerp(topAt(x), castleBaseY, 0.15 + rng() * 0.8);
const nearSun = 1 - clamp01(Math.abs(x - sunX) / (W * 0.7));
if (!nightLit && nearSun < 0.45) continue;
const sz = Math.max(1.0, H * 0.0035);
const a = (0.16 + rng() * 0.20) * softK;
stamped(0.852 + rng() * 0.020, (c, jr) => {
touch(c, jr, x, y, sz, mixHex(stoneLitK, '#fffaf0', 0.5), a, 0, 0.6);
});
}
// -- GLINTS: the brightest notes, struck last ---------------------------------------
for (let i = 0, n = Math.round(56 * covK); i < n; i++) {
const t = 0.2 + Math.pow(rng(), 0.8) * 0.75;
const y = hzY + t * riverRange;
const x = rng() * W;
const dx = (rng() - 0.5) * W * 0.014;
const dy = (rng() - 0.5) * 2;
const wd = 0.9 + rng() * 1.3;
const ga = (0.16 + rng() * 0.22) * (night ? 0.5 : 1) * softK;
stamped(0.890 + rng() * 0.060, (c, jr) => {
stroke(c, jr, { path: { x0: x, y0: y, x1: x + dx, y1: y + dy },
brush: 'round', width: wd, color: mixHex(GLINT, '#ffffff', 0.2), alpha: ga, taper: 'both' });
});
}
// the roof ridges' last light, one hair each
if (!night || nightLit) {
for (const t of towers) {
stamped(0.962, touch(t.xc, t.apexY + (t.bodyTop - t.apexY) * 0.3,
Math.max(0.9, (t.x1 - t.x0) * 0.08), mixHex(ridgeC, GLINT, 0.4), 0.28 * softK, 0, 0.5));
}
}
// -- the final veil: mist and rain soften every edge one last time -----------------
if (misty || rain || snowy) {
const veilCol = mixHex('#e2e6e4', skyAt(0.9), night ? 0.55 : (dusk || dawn) ? 0.35 : 0.15);
for (let i = 0; i < 26; i++) {
const x = rng() * W;
const y = rng() * H;
const len = W * (0.15 + rng() * 0.30);
const ang = (rng() - 0.5) * 0.04;
const wd = H * (0.02 + rng() * 0.04);
const a = 0.03 + rng() * 0.05;
stamped(0.968 + rng() * 0.026, (c, jr) => {
band(c, jr, x, y, len, ang, wd, veilCol, a);
});
}
}
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);
}
},
};
...@@ -15,5 +15,8 @@ import tournesols from './tournesols.js'; ...@@ -15,5 +15,8 @@ import tournesols from './tournesols.js';
import verger from './verger.js'; import verger from './verger.js';
import port from './port.js'; import port from './port.js';
import moulin from './moulin.js'; import moulin from './moulin.js';
import loire from './loire.js';
import bois from './bois.js';
import marche from './marche.js';
export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols, verger, port, moulin]; export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols, verger, port, moulin, loire, bois, marche];
// Le Marché — a market morning in a town square or narrow street receding to
// a vanishing point, in the spirit of Caillebotte's markets and Pissarro's
// evening markets at Dieppe: the atelier's first true FIGURE scene. The town
// wakes between two ranks of warm façades — ochre, pink, cream, shuttered —
// and under striped canvas awnings (red-white, green-white, blue-white, dice)
// the stalls crowd with produce in POOLS OF PURE COLOR: tomato red, orange
// cascade, salad-crate greens, the violet of plums, lemons like dropped suns.
// Each awning throws a COLORED shaded strip beneath it — the color physics
// that sells the scene: shadow is never grey, it is the awning's own tint.
// The figures are the atelier's first true people: vendors half-risen behind
// their stalls in white or dark-blue aprons, shoppers in long coats with
// market baskets, a child darting, a dog. Six to fifteen strokes each — dark
// vertical mass, head dab, one color accent — NO faces, NO contour: figures
// as gestural color notes with presence, rational in scale through the
// Horizon ground plane (near stalls big and detailed, far stalls hinted).
//
// DICE (init stream, after the conditions stream). 3-6 awnings: side, depth
// span, stripe count, stripe pair. Cobbles wet or dusty. 8-20 figures, of
// which vendors ~1 in 3, a child or two, a dog. Café corner side (night).
//
// CONDITIONS. The hour transforms the market: dawn is setting up — half the
// stalls bare, vendors only, long blue shadows; morning is the canonical
// bustle; noon is the market ending, awnings coming down, some folded; rain
// drips from the awnings, thins the crowd, opens umbrellas; goldenHour closes
// the market in warm raking light with brooms out; night leaves the square
// empty and washed, one café lit at the corner — the market's ghost. Seasons
// change the produce and the light: spring asparagus and strawberries, summer
// tomatoes and peaches, autumn pumpkins and apples, winter cabbages and
// citrus. Mist ghosts the street's far end; clear lets the morning rake down.
//
// SKELETON AND HAND: the load-bearing strokes — the prime, the façade fills,
// the cobble field, the awning stripes' color architecture, the figures'
// bodies — ride the plain house stroke (bois/cafe precedent: masses plain,
// accents through the hand), because a pointillisme wrist must not starve the
// tonal spine. Every expressive touch — produce dabs, windows, awning shadows,
// heads, scarves, baskets, glints — routes through
// brushes.handFor(conditions.technique) + handDab.
//
// Contract: identical to scenes/bois.js — every stroke precomputed in init()
// with a fixed progress stamp, draw() paints exactly the stamps in [from, to),
// the persistent canvas accumulates, same seed -> same painting. Per-stroke
// jitter and weather touches ride their own mulberry32 streams; NOTHING else
// consumes randomness at draw time. TONAL SPINE (pixel-verified): sky and
// façades lightest, street mid, figures and awning-shadows darkest, produce
// pools the saturated accents.
import {
mulberry32, makeConditions, weatherOverlay, skyGradient, groundPalette,
mix as mixHex, clamp01, subtitle as subtitleFr,
} from '../scene-api.js';
import {
handFor, handDab, Horizon, brokenColor,
dab, roundDab, rigger, stroke,
} from '../brushes.js';
let strokes = null;
const TAU = Math.PI * 2;
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
export default {
id: 'marche',
title: 'Le Marché',
artist: 'Atelier Impressionniste',
durationMs: 55000,
conditions: (rng, constraints) => makeConditions(rng, constraints),
subtitle: (c) => 'Le marché, ' + subtitleFr(c),
init(ctx, W, H, rng, conditions) {
const cond = conditions || makeConditions(mulberry32(1889), null);
const tod = cond.timeOfDay, season = cond.season, weather = cond.weather;
const night = tod === 'night', dusk = tod === 'dusk', dawn = tod === 'dawn';
const golden = tod === 'goldenHour', noon = tod === 'noon', morning = tod === 'morning';
const misty = weather === 'mist', rainy = weather === 'rain';
const stormy = weather === 'overcast', snowy = weather === 'snow';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const list = [];
const add = (p, fn) => list.push({ p: Math.min(0.9999, Math.max(0, p)), fn });
const gauss = () => (rng() + rng() + rng() - 1.5) / 1.5;
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const lerp = (a, b, t) => a + (b - a) * t;
// one jitter stream per stroke; weather touches ride ~12% of strokes
const stamped = (p, fn) => {
const jr = mulberry32((0x6A8E1 + Math.floor(rng() * 0xffffff) * 691) >>> 0);
const wr = (weather !== 'clear' && rng() < 0.12)
? mulberry32((0x5EED1 + Math.floor(rng() * 0xffffff) * 3571) >>> 0) : null;
add(p, (c) => {
fn(c, jr);
if (wr) {
try { weatherOverlay(c, W, H, wr, cond, p, Math.min(1, p + 0.004)); }
catch { /* scene-api being polished; the scene keeps painting */ }
}
});
};
// -- THE HAND ----------------------------------------------------------
const hand = handFor(cond.technique);
const kMean = (hand.dabSize[0] + hand.dabSize[1]) / 2;
const slMean = (hand.strokeLength[0] + hand.strokeLength[1]) / 2;
const handW = (w) => hand.mode === 'plain' ? w
: hand.mode === 'glaze' ? (w * 0.5) / kMean
: hand.mode === 'dots' ? w / (1.5 * kMean)
: hand.mode === 'dry' ? (w * 0.67) / kMean
: w / kMean;
const handL = (len) => hand.mode === 'plain' ? len : len / slMean;
const covK = Math.min(1.6, Math.max(1, 1 / Math.max(0.25, slMean * kMean)));
const inkK = hand.alphaCurve === 'soft' ? 2.3 : hand.alphaCurve === 'bleed' ? 1.7
: hand.alphaCurve === 'matte' ? 1.25 : 1;
// touch and smear paint THROUGH the rolled hand
const touch = (cc, jr, x, y, w, c, a, ang = 0, squash = 1) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: 0, ang, w: handW(w), c, a: aa, squash,
plain: (g) => roundDab(g, jr, { x, y, size: Math.max(0.4, w * 0.5), color: c, alpha: aa, angle: ang, squash }),
});
};
const smear = (cc, jr, x, y, len, ang, w, c, a, squash = 1) => {
const aa = a * inkK;
handDab(cc, jr, hand, {
x, y, len: handL(len), ang, w: handW(w), c, a: aa, squash,
plain: (g) => band(g, jr, x, y, len, ang, w, c, aa),
});
};
// the soft air — mist, light spill, the unifying breath — stays translucent
const veil = (x, y, len, ang, w, c, a, squash = 0.5) => (cc) => {
const half = Math.max(1, len * 0.5);
const ab = hand.alphaBase;
dab(cc, x, y, half, c, a * ab, ang, clamp(squash, 0.06, 8));
dab(cc, x, y, half * 1.45, c, a * 0.34 * ab, ang, clamp(squash * 0.92, 0.06, 8));
};
// the skeleton stroke, hand-independent: the composition must survive
// every wrist
const band = (c, jr, x, y, len, ang, w, col, a) => stroke(c, jr, {
path: {
x0: x - Math.cos(ang) * len * 0.5, y0: y - Math.sin(ang) * len * 0.5,
x1: x + Math.cos(ang) * len * 0.5, y1: y + Math.sin(ang) * len * 0.5,
},
brush: 'round', width: Math.max(0.8, w), color: col, alpha: a, taper: 'none', spacing: 0.38,
});
// the skeleton slab: a SOLID bar (stroke()'s dab chains show their ribs
// when length dwarfs width — tables, sills and shadow strips must not)
const slab = (c, x, y, len, ang, w, col, a) => {
const x0 = x - Math.cos(ang) * len * 0.5, y0 = y - Math.sin(ang) * len * 0.5;
const x1 = x + Math.cos(ang) * len * 0.5, y1 = y + Math.sin(ang) * len * 0.5;
const d = Math.hypot(x1 - x0, y1 - y0);
const n = Math.max(2, Math.ceil(d / Math.max(0.7, w * 0.45)));
for (let k = 0; k <= n; k++) {
dab(c, lerp(x0, x1, k / n), lerp(y0, y1, k / n), Math.max(0.8, w * 0.62), col, a, 0, 1);
}
};
// -- THE GROUND PLANE ----------------------------------------------------
const vxN = 0.46 + rng() * 0.10; // vanishing x: the street's heart
const hzY = H * clamp01(cond.horizonY);
const vxW = vxN * W;
const horizon = new Horizon(hzY, vxW, 1.5, H);
const gp = (xn, d) => horizon.computeGroundPlane(xn * W, d, H);
const gOf = (d) => horizon.ground(d);
const recedeAng = (x, y) => Math.atan2(hzY - y, vxW - x); // toward the vanishing point
const fhNear = H * 0.085; // a person at depth 1
const aWNear = H * 0.24; // awning attach height at depth 1
const tHNear = H * 0.052; // stall table height at depth 1
const facadeHNear = H * (0.88 + rng() * 0.42); // near façades crop the frame top
// -- grading: façades stay warmer/lighter, ground sits a step down -------
const fac = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#232c42', 0.60 * k);
else if (dusk) o = mixHex(o, '#5c4a5e', 0.30 * k);
else if (dawn) o = mixHex(o, '#a8aec2', 0.18 * k);
else if (golden) o = mixHex(o, '#e0a860', 0.16 * k);
else if (noon) o = mixHex(o, '#f2ead2', 0.06 * k);
if (misty) o = mixHex(o, '#dcdedc', 0.10 * k);
if (rainy) o = mixHex(o, '#7c868e', 0.16 * k);
if (snowy) o = mixHex(o, '#d8dfe6', 0.12 * k);
return o;
};
const gnd = (h, k = 1) => {
let o = h;
if (night) o = mixHex(o, '#1a2233', 0.68 * k);
else if (dusk) o = mixHex(o, '#43384c', 0.30 * k);
else if (dawn) o = mixHex(o, '#8a92a8', 0.22 * k);
else if (golden) o = mixHex(o, '#d8a05c', 0.15 * k);
if (misty) o = mixHex(o, '#d6dad6', 0.08 * k);
if (rainy) o = mixHex(o, '#6a7886', 0.14 * k);
if (snowy) o = mixHex(o, '#dfe5ea', 0.30 * k);
return o;
};
const liftCol = night ? '#5c6478' : '#f4ecd8'; // walls' high register: cream by day, moonlit grey at night
const litCol = night ? '#8a94ac' : '#e8dcbe'; // the sun-side warm wash, cool at night
// the far haze: everything at depth dissolves toward the sky's breath
const hazeCol = gnd(fac('#cfd0cc', 0.9), 0.9);
const hazer = (h, k) => mixHex(h, hazeCol, clamp01(k));
// -- DICE ------------------------------------------------------------------
const wetCobbles = weather === 'rain' || rng() < 0.35;
const nAwn = 3 + Math.floor(rng() * 4); // 3-6 awnings
const nFigBase = 8 + Math.floor(rng() * 13); // 8-20 figures
const nChild = night ? 0 : Math.floor(rng() * 3); // 0-2 children darting
const hasDog = !night && rng() < 0.75;
const cafeSide = rng() < 0.5 ? -1 : 1; // night's lit café corner
// -- THE PRIME: sky above the horizon, the street's breath below ----------
const streetFar = gnd('#b4ab98', 0.8);
const streetMid = gnd('#948873', 0.9);
const streetNear = gnd('#7e7360', 1);
const prime = (c) => {
const g = c.createLinearGradient(0, 0, 0, H);
for (const s of skyGradient(cond)) {
if (s.at > hzY / H + 0.02) break; // the sky only; the street primes itself
g.addColorStop(clamp01(s.at), s.color);
}
g.addColorStop(clamp01(hzY / H), hazer(streetFar, 0.25));
g.addColorStop(clamp01(hzY / H + 0.10), mixHex(streetMid, streetFar, 0.55));
g.addColorStop(1, streetNear);
c.fillStyle = g;
c.fillRect(0, 0, W, H);
};
prime(ctx);
add(0.001, prime);
// -- THE SKY: broad smears above the roofline ------------------------------
const skyHi = night ? '#5c688a' : '#e6e3d8'; // moonlit grey at night, never cream
for (let i = 0; i < 380; i++) {
const x = -W * 0.03 + rng() * W * 1.06;
const hi = Math.pow(rng(), 0.9); // 0 high, 1 near the horizon
const y = hi * Math.max(4, hzY - H * 0.02);
const len = W * (0.04 + rng() * 0.17);
const wd = H * (0.006 + rng() * 0.024) * (0.75 + hi * 0.5);
const ang = (rng() - 0.5) * 0.26;
stamped(0.02 + 0.11 * hi + rng() * 0.014, (c, jr) => {
band(c, jr, x, y, len, ang, wd, mixHex(skyHi, '#b8c2cc', hi * 0.5 + rng() * 0.25), 0.08 + rng() * 0.15);
});
}
for (let i = 0; i < 9; i++) { // broad soft veils break the smear rhythm
stamped(0.10 + rng() * 0.03, veil(rng() * W, rng() * Math.max(4, hzY - H * 0.02),
W * (0.10 + rng() * 0.22), (rng() - 0.5) * 0.1, Math.max(3, H * (0.02 + rng() * 0.04)),
mixHex(skyHi, '#b8c2cc', rng() * 0.5), 0.030 + rng() * 0.030, 0.3));
}
// -- sun or moon: a modest disc, dice-placed by the conditions -------------
{
const sy = Math.max(H * 0.04, Math.min(cond.sunY * H, hzY - H * 0.05));
const sx = cond.sunX * W;
if (night) {
if (cond.moonVisible) {
stamped(0.142, (c) => {
dab(c, sx, sy, H * 0.014, '#e8ecf2', 0.75);
dab(c, sx - H * 0.004, sy - H * 0.003, H * 0.008, '#f4f6f8', 0.5);
});
for (let i = 0; i < 10; i++) {
const a = rng() * TAU, r = H * (0.02 + rng() * 0.04);
stamped(0.148 + rng() * 0.012, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.010 + rng() * 0.016), '#5a6680', 0.04 + rng() * 0.05));
}
}
} else {
const core = golden ? '#f4c882' : dawn ? '#f0d2a0' : '#f6e2b4';
const dim = cond.sunStrength == null ? 1 : cond.sunStrength; // the sun pales in rain
stamped(0.142, (c) => {
dab(c, sx, sy, H * 0.017, core, 0.55 * dim);
dab(c, sx, sy, H * 0.010, mixHex(core, '#fff8e4', 0.5), 0.6 * dim);
});
for (let i = 0; i < 10; i++) {
const a = rng() * TAU, r = Math.pow(rng(), 0.7) * H * 0.014;
stamped(0.146 + rng() * 0.010, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.004 + rng() * 0.005), core, (0.22 + rng() * 0.20) * dim));
}
for (let i = 0; i < 12; i++) {
const a = rng() * TAU, r = H * (0.026 + rng() * 0.05);
stamped(0.150 + rng() * 0.012, (c) => dab(c, sx + Math.cos(a) * r, sy + Math.sin(a) * r,
H * (0.012 + rng() * 0.018), mixHex(core, '#b8c2cc', 0.5), (0.04 + rng() * 0.05) * Math.max(0.35, dim)));
}
}
}
// -- THE FAR END: the vista closing — façades facing us across the street --
{
const farSpan = W * (0.075 + rng() * 0.035); // the street's width out there
const farTop = hzY - H * (0.10 + rng() * 0.11);
const farBase = hzY + H * 0.012;
const farCols = ['#c8a888', '#d0b098', '#b89c84', '#d8c0a0'].map((h) => fac(h, 0.8));
for (let i = 0, nFar = 200; i < nFar; i++) {
const x = vxW + (rng() - 0.5) * 2.1 * farSpan;
if (Math.abs(x - vxW) > farSpan && rng() < 0.5) continue; // a soft ragged edge
const v = rng();
const y = lerp(farBase, farTop, v);
let col = mixHex(pick(farCols), liftCol, Math.max(0, v - 0.5) * 0.4);
if (v < 0.2) col = mixHex(col, '#4a3c30', 0.30); // seated in street shadow
col = hazer(col, 0.28 + rng() * 0.18);
stamped(0.158 + 0.045 * rng(), (c, jr) => {
band(c, jr, x, y, W * (0.012 + rng() * 0.026), (rng() - 0.5) * 0.3,
Math.max(1.8, H * (0.008 + rng() * 0.012)), col, 0.34 + rng() * 0.24);
});
}
// a few window dots in the haze, a couple warm
for (let i = 0; i < 9; i++) {
const x = vxW + (rng() - 0.5) * 1.7 * farSpan;
const y = lerp(farTop + H * 0.02, farBase - H * 0.01, rng());
stamped(0.200 + rng() * 0.006, (c, jr) => {
touch(c, jr, x, y, Math.max(1.4, W * 0.004),
rng() < (night ? 0.7 : 0.15) ? '#e8b850' : mixHex('#2c2820', hazeCol, 0.55), 0.45);
});
}
// a church tower or clock rising over the roofs: the town waking
if (rng() < 0.65) {
const twW = farSpan * (0.42 + rng() * 0.20);
const twX = vxW + (rng() - 0.5) * farSpan;
const th = (farBase - farTop) * (1.10 + rng() * 0.30);
const st = (k) => 0.196 + k + rng() * 0.002;
stamped(st(0), (c, jr) => {
band(c, jr, twX, farBase - th * 0.5, th, -Math.PI / 2,
Math.max(2.4, twW), hazer(fac('#c0a488', 0.8), 0.34), 0.6);
band(c, jr, twX, farBase - th - th * 0.12, th * 0.24, -Math.PI / 2,
Math.max(1.8, twW * 0.62), hazer(fac('#8c6a50', 0.8), 0.36), 0.6); // the spire
});
stamped(st(0.01), (c, jr) => { // the clock, a pale coin
touch(c, jr, twX, farBase - th * 0.92, Math.max(1.4, twW * 0.36), mixHex('#efe8d4', hazeCol, 0.2), 0.7);
});
}
// the street's end burns with light: the raking morning wash, colorFn
if (!night) {
const wash0 = golden ? '#f0c078' : dawn ? '#d8d8e0' : '#f4e8c4';
for (let sI = 0; sI < 3; sI++) {
const off = (sI - 1) * W * 0.035;
stamped(0.205 + 0.004 * sI, (c, jr) => stroke(c, jr, {
path: { x0: vxW + off + (rng() - 0.5) * W * 0.04, y0: H * 1.02, x1: vxW + off * 0.2, y1: farBase - H * 0.01 },
brush: 'round', width: Math.max(6, W * 0.040),
colorFn: (t) => mixHex(wash0, hazeCol, 0.15 + t * 0.55),
alpha: 0.045 + rng() * 0.03, taper: 'end', spacing: 0.3,
}));
}
}
}
// -- THE FAÇADES: warm ochre/pink/cream rising on both sides ---------------
// Each wall is a vertical wedge: its base line runs from the frame corner
// to the vanishing point, and at every depth the wall rises from that base
// line. Strokes hug the wall line; windows and cornices are quads ON the
// wall plane, spanning a depth interval so they carry real screen width.
const FACADES = ['#d8b088', '#e0c098', '#d8a890', '#e8d0a8', '#c89878', '#e4c8ac', '#d4b49c'];
const SHUTTER = ['#5c6478', '#4c6258', '#6a5c68', '#585c6c'];
for (const side of [-1, 1]) {
const xnWall = side < 0 ? -0.02 - rng() * 0.02 : 1.02 + rng() * 0.02;
const nHouses = 3 + Math.floor(rng() * 3);
const dStart = 0.13, dEnd = 1.02;
const cuts = [];
for (let i = 0; i <= nHouses; i++) cuts.push(dStart + (dEnd - dStart) * Math.pow(i / nHouses, 1.15));
for (let hi = 0; hi < nHouses; hi++) {
const d0 = cuts[hi], d1 = cuts[hi + 1];
const colBase = fac(FACADES[Math.floor(rng() * FACADES.length)]);
const hHouse = facadeHNear * (0.8 + rng() * 0.3);
const pF = 0.222 + 0.085 * (side < 0 ? hi / nHouses : 1 - hi / nHouses);
const wallX = (d) => gp(xnWall, d).x;
// the wall's body: opaque vertical planes (bois precedent — masses
// plain), stepped along the street so the wedge is solid in every hand
const nCol = 34;
for (let i = 0; i < nCol; i++) {
const t = (i + 0.5) / nCol;
const d = lerp(d0, d1, t);
const g = gOf(d);
const x = wallX(d);
const yB = hzY + (H - hzY) * g;
const hgt = hHouse * g;
const cw = Math.max(2, W * 0.024 * (0.3 + g));
stamped(pF + 0.058 * t + rng() * 0.0016, (c, jr) => {
band(c, jr, x, yB - hgt * 0.08, hgt * 0.20, Math.PI / 2, cw,
hazer(mixHex(colBase, '#3c3428', 0.35), (1 - g) * 0.4), 0.5); // ground wash
band(c, jr, x, yB - hgt * 0.45, hgt * 0.58, Math.PI / 2, cw,
hazer(colBase, (1 - g) * 0.4), 0.5); // the body
band(c, jr, x, yB - hgt * 0.87, hgt * 0.38, Math.PI / 2, cw,
hazer(mixHex(colBase, liftCol, 0.32), (1 - g) * 0.4), 0.5); // sky-lit top
});
}
// the wall's skin: broken vertical smears over the planes
for (let i = 0, nW = 130; i < nW; i++) {
const t = Math.pow(rng(), 0.8);
const d = lerp(d0, d1, t);
const g = gOf(d);
const x = wallX(d) + gauss() * W * 0.004 * g;
const v = Math.pow(rng(), 1.05);
const y = hzY + (H - hzY) * g - hHouse * g * v;
let col = mixHex(colBase, liftCol, 0.15 + Math.max(0, v - 0.5) * 0.4);
const skinDice = rng();
if (skinDice < 0.22) col = mixHex(colBase, '#3c3428', 0.25); // grime streak
else if (skinDice < 0.34) col = mixHex(colBase, pick(SHUTTER), 0.30); // a painted band
col = hazer(col, (1 - g) * (weather === 'mist' ? 0.75 : 0.40));
stamped(pF + 0.060 + 0.030 * rng(), (c, jr) => {
smear(c, jr, x, y, H * (0.018 + rng() * 0.042) * (0.3 + g),
-Math.PI / 2 + (rng() - 0.5) * 0.12, Math.max(1.6, W * 0.008 * (0.4 + g)),
col, 0.22 + rng() * 0.24);
});
}
// the cornice and the roof: a continuous dark crown along the top edge
const nCR = 6;
for (let i = 0; i < nCR; i++) {
const da = lerp(d0, d1, i / nCR), db = lerp(d0, d1, (i + 1.15) / nCR);
const ga = gOf(da), gb = gOf(db);
const xa = wallX(da), xb = wallX(db);
const ya = hzY + (H - hzY) * ga - hHouse * ga;
const yb = hzY + (H - hzY) * gb - hHouse * gb;
const gl = gOf((da + db) / 2);
stamped(pF + 0.064 + 0.004 * (i / nCR), (c, jr) => {
band(c, jr, (xa + xb) / 2, (ya + yb) / 2, Math.hypot(xb - xa, yb - ya) * 1.05,
Math.atan2(yb - ya, xb - xa), Math.max(1.6, H * 0.011 * gl),
hazer(mixHex(colBase, '#2e2820', 0.40), (1 - gl) * 0.35), 0.45);
band(c, jr, (xa + xb) / 2, (ya + yb) / 2 - H * 0.012 * gl, Math.hypot(xb - xa, yb - ya) * 1.05,
Math.atan2(yb - ya, xb - xa), Math.max(1.6, H * 0.013 * gl),
hazer(mixHex('#7a4a38', colBase, 0.25), (1 - gl) * 0.35), 0.40); // the roof mass
});
}
// windows and shutters: crisp slabs on the wall plane — a depth
// interval each, so they carry real screen width and read as windows
const nWin = 2 + Math.floor(rng() * 3);
const warmPaneK = night ? 0.35 : dawn || dusk || golden ? 0.30 : 0.06;
const vFloor = 0.18 + rng() * 0.10;
for (let wi = 0; wi < nWin; wi++) {
const dWin = lerp(d0 + 0.02, d1 - 0.02, (wi + 0.5) / nWin);
const gWin = gOf(dWin);
const rows = 2 + Math.floor(rng() * 2);
for (let rI = 0; rI < rows; rI++) {
const v = vFloor + rI * 0.26 + (rng() - 0.5) * 0.03;
const target = Math.max(2, W * 0.016 * gWin); // screen width wanted
let d2 = dWin, step = 0.004;
for (let sI = 0; sI < 40 && Math.abs(wallX(d2) - wallX(dWin)) < target; sI++) {
d2 += step; step *= 1.25;
if (d2 > 1.05) break;
}
const x1 = wallX(dWin), x2 = wallX(Math.min(d2, 1.05));
const g2 = gOf(Math.min(d2, 1.05));
const y1 = hzY + (H - hzY) * gWin - hHouse * gWin * v;
const y2 = hzY + (H - hzY) * g2 - hHouse * g2 * v;
const wx = (x1 + x2) / 2, wwd = Math.max(1.6, Math.abs(x2 - x1));
const wy = (y1 + y2) / 2;
const wh = Math.max(2, W * 0.024 * gWin);
const wAng = Math.atan2(y2 - y1, x2 - x1); // along the wall line
const warm = rng() < warmPaneK;
const winP = pF + 0.070 + 0.012 * (1 - gWin) + rng() * 0.004;
if (warm) {
const lc = pick(['#f0c860', '#e8b850', '#f6dc86']);
stamped(winP, (c, jr) => {
touch(c, jr, wx, wy, wwd * 1.1, mixHex(lc, '#5a4a70', night ? 0.25 : 0.1), 0.75, 0, wh / wwd);
dab(c, wx, wy, wwd * 2.0, mixHex(lc, '#3a3050', 0.55), 0.09);
});
} else {
stamped(winP, (c, jr) => {
band(c, jr, wx, wy, wwd * 1.05, wAng, wh * 0.85,
mixHex('#1c2028', SHUTTER[Math.floor(rng() * SHUTTER.length)], rng() * 0.5), 0.55);
if (rng() < 0.7) band(c, jr, wx, wy - wh * 0.72, wwd * 1.05, wAng, Math.max(1, wh * 0.28),
hazer(pick(SHUTTER), 0.2), 0.5); // the lintel
});
}
}
}
// the contact shadow: ONE continuous dab chain per house — separate
// slabs double-darken into stair dashes where they overlap
stamped(pF + 0.084, (c, jr) => {
const nS = 22;
for (let k = 0; k <= nS; k++) {
const d = lerp(d0, d1, k / nS);
const g = gOf(d);
dab(c, wallX(d) - side * W * 0.004 * g, hzY + (H - hzY) * g - H * 0.002 * g,
Math.max(1.5, W * 0.014 * (0.4 + g)), gnd(mixHex('#3c3428', '#1e1a14', 0.3), 0.95), 0.30, 0, 1);
}
});
}
}
// -- mist ghosts the far end -------------------------------------------------
if (misty || dawn) {
for (let i = 0, nM = misty ? 26 : 12; i < nM; i++) {
const d = 0.05 + rng() * 0.38;
const p = gp(vxN, d);
stamped(0.382 + 0.020 * rng(), veil(p.x + (rng() - 0.5) * W * 0.5,
p.y - H * (0.04 + rng() * 0.20), W * (0.10 + rng() * 0.24), (rng() - 0.5) * 0.06,
Math.max(2, H * (0.016 + rng() * 0.03)), mixHex('#e2e6e2', '#c8d0d8', rng() * 0.5),
(0.05 + rng() * 0.07) * (misty ? 1 : 0.7), 0.25));
}
}
// -- THE STREET: cobblestones, wet or dusty, receding in perspective -------
const COBBLE = wetCobbles
? ['#6e6c66', '#5e5c58', '#787672', '#54524e']
: ['#a09380', '#948873', '#aca08a', '#877b66'];
const sunSide = cond.sunX < 0.5 ? 1 : -1; // lit flank
for (let i = 0, nCob = Math.round(1150 * covK); i < nCob; i++) {
const d = 1 - Math.pow(rng(), 1.55) * 0.97; // bias toward the near plane
const g = gOf(d);
const xn = -0.02 + rng() * 1.04;
const p = gp(xn, d);
let col = night ? mixHex(pick(COBBLE), '#8a94ac', 0.10 + rng() * 0.08)
: mixHex(pick(COBBLE), '#c8b090', 0.10 + rng() * 0.08);
if (xn < vxN) col = mixHex(col, litCol, sunSide < 0 ? 0.16 + rng() * 0.12 : 0.02);
else col = mixHex(col, litCol, sunSide > 0 ? 0.16 + rng() * 0.12 : 0.02);
col = mixHex(col, '#4a4e5c', (1 - g) * 0.22);
col = hazer(col, (1 - g) * (weather === 'mist' ? 0.70 : 0.35));
const rx = Math.max(1.2, W * (0.004 + rng() * 0.008) * (0.3 + g * 1.1));
const ang = recedeAng(p.x, p.y) * 0.35 + (rng() - 0.5) * 0.8;
stamped(0.415 + 0.155 * (1 - d) + rng() * 0.014, (c, jr) => {
band(c, jr, p.x, p.y, rx * (0.7 + rng() * 0.7), ang, rx * 0.8, col, 0.22 + rng() * 0.26);
});
}
// wet cobbles: glossy streaks doubling the sky and the lights
if (wetCobbles) {
for (let i = 0, nR = Math.round(90 * covK); i < nR; i++) {
const d = 0.35 + rng() * 0.62;
const g = gOf(d);
const p = gp(0.1 + rng() * 0.8, d);
const skyish = night ? '#4a5878' : mixHex('#d8dcda', '#a8b4c0', rng() * 0.5);
stamped(0.578 + rng() * 0.014, (c, jr) => {
band(c, jr, p.x, p.y, W * (0.008 + rng() * 0.022) * (0.4 + g), recedeAng(p.x, p.y) + (rng() - 0.5) * 0.1,
Math.max(0.8, W * 0.0025 * (0.4 + g)), mixHex(skyish, streetMid, 0.3), 0.10 + rng() * 0.12);
});
}
}
// -- SHADOWS: long, from the awnings and the figures -----------------------
const shDir = cond.sunX < vxN ? 1 : -1;
const shLenK = { dawn: 2.6, morning: 1.5, noon: 0.7, goldenHour: 2.4, dusk: 2.4, night: 0 }[tod] || 1.5;
const shAng = shDir > 0 ? 0.10 : Math.PI - 0.10;
const shadowCol = gnd(mixHex('#2c3044', dawn ? '#4a5878' : '#3a3a4c', dawn || dusk ? 0.4 : 0.15), 0.9);
// -- THE AWNINGS: the color architecture ------------------------------------
const AWN_COLORS = [
['#c8404a', '#ece6d6'], // red-white
['#3c7a4a', '#ece6d6'], // green-white
['#3c5c8c', '#ece6d6'], // blue-white
['#c06a28', '#ece6d6'], // rare warm ochre-white
];
const stalls = [];
for (let ai = 0; ai < nAwn; ai++) {
const side = rng() < 0.5 ? -1 : 1;
const d0 = 0.34 + rng() * 0.52; // along the street
const d1 = Math.min(0.97, d0 + 0.09 + rng() * 0.09);
const xnWall = side < 0 ? 0.045 + rng() * 0.05 : 0.95 - rng() * 0.05;
const proj = 0.10 + rng() * 0.05; // projects into the street
const [cA, cB] = pick(AWN_COLORS);
const nStripes = 6 + Math.floor(rng() * 5);
const folded = night ? true : noon ? rng() < 0.55 : false;
stalls.push({ side, d0, d1, xnWall, proj, cA, cB, nStripes, folded, dMid: (d0 + d1) / 2 });
}
stalls.sort((a, b) => a.dMid - b.dMid); // far awnings first
for (let si = 0; si < stalls.length; si++) {
const st = stalls[si];
const gM = gOf(st.dMid);
const slot = 0.622 + 0.050 * (si / stalls.length);
const sl = 0.046 / stalls.length;
const stP = (k) => slot + sl * clamp(k, 0, 0.98) + rng() * 0.0003;
// attach line (wall) and front edge (projected, sloping down)
const attach = (u) => {
const d = lerp(st.d0, st.d1, u);
const g = gOf(d);
const p = gp(st.xnWall, d);
return { x: p.x, y: p.y - aWNear * g, g, d, p };
};
const front = (u) => {
const d = lerp(st.d0, st.d1, u);
const g = gOf(d);
const p = gp(st.xnWall + st.side * -st.proj, d);
return { x: p.x, y: p.y - aWNear * g * 0.72, g, d, p };
};
if (!st.folded) {
// the striped body: each stroke runs one full slope line (wall to front
// edge); color alternates along the street, so the stripes read as the
// classic bands running the awning's length
const slope = (u) => {
const a = attach(u), f = front(u);
return { x: (a.x + f.x) / 2, y: (a.y + f.y) / 2, len: Math.hypot(f.x - a.x, f.y - a.y),
ang: Math.atan2(f.y - a.y, f.x - a.x) };
};
const gA = gOf(st.d0), gB = gOf(st.d1);
const stripeW = Math.max(1.4, ((H - hzY) * (gB - gA) / st.nStripes) * 1.7);
const nStr = Math.round(34 * covK);
for (let k = 0; k < nStr; k++) {
const u = rng();
const s = slope(u);
const stripeIdx = Math.floor(u * st.nStripes) % 2;
let col = stripeIdx === 0 ? st.cA : st.cB;
col = mixHex(col, '#f4ecd4', rng() * 0.14);
stamped(stP(0.02 + 0.44 * (k / nStr)), (c, jr) => {
band(c, jr, s.x, s.y, s.len * (0.75 + rng() * 0.3), s.ang + (rng() - 0.5) * 0.05,
stripeW, col, 0.55 + rng() * 0.3);
});
}
// scalloped front rim: alternating dabs
for (let k = 0, nSc = 8 + Math.floor(rng() * 5); k < nSc; k++) {
const u = (k + 0.5) / nSc;
const f = front(u);
stamped(stP(0.50 + 0.010 * (k / nSc)), (c, jr) => {
touch(c, jr, f.x, f.y + Math.max(1.2, H * 0.006 * f.g), Math.max(1.6, W * 0.008 * f.g),
(Math.floor(u * st.nStripes) % 2 === 0 ? st.cA : st.cB), 0.6, 0, 0.7);
});
}
// THE COLORED SHADE: the awning tints its own shadow on the ground
const tint = mixHex(st.cA, '#ffffff', 0.15);
for (let k = 0, nSh = 14; k < nSh; k++) {
const u = rng();
const d = lerp(st.d0, st.d1, u);
const g = gOf(d);
const pf = gp(st.xnWall + st.side * -st.proj, d);
const ext = shLenK * (0.5 + rng() * 0.8);
const x = pf.x + shDir * W * 0.02 * ext * g;
const y = pf.y + Math.abs(Math.sin(shAng)) * W * 0.012 * ext * g + H * 0.004;
stamped(stP(0.52 + 0.014 * (k / nSh)), (c, jr) => {
slab(c, x, y, W * (0.02 + rng() * 0.03) * (0.4 + g),
shAng + (rng() - 0.5) * 0.1, Math.max(1.6, H * 0.011 * (0.4 + g)),
gnd(mixHex('#3a3644', tint, 0.34), 0.95), 0.34 + rng() * 0.2);
});
}
// support poles at the front corners: the awning stands on the street
for (const u of [0.04, 0.96]) {
const f = front(u);
stamped(stP(0.55 + 0.008 * u), (c, jr) => {
rigger(c, jr, { x: f.x, y: f.y, length: (f.p.y - f.y) * 0.98, color: gnd('#332a20', 0.95),
alpha: 0.6, angle: Math.PI / 2, width: Math.max(0.8, W * 0.0022 * f.g), bow: 0 });
});
}
if (rainy) { // the awnings drip
for (let k = 0, nD = 7 + Math.floor(rng() * 5); k < nD; k++) {
const u = rng();
const f = front(u);
stamped(stP(0.565 + 0.006 * (k / nD)), (c, jr) => {
rigger(c, jr, { x: f.x + (rng() - 0.5) * 3, y: f.y + H * 0.006 * f.g,
length: H * (0.02 + rng() * 0.05) * f.g, color: mixHex(tint, '#dce6ec', 0.4),
alpha: 0.16 + rng() * 0.14, angle: Math.PI / 2 + (rng() - 0.5) * 0.1,
width: Math.max(0.7, W * 0.0016 * f.g), bow: 0 });
});
}
}
} else {
// FOLDED: a rolled canvas bundle along the attach line
for (let k = 0, nR = 12; k < nR; k++) {
const u = (k + rng() * 0.6) / nR;
const a = attach(u);
const col = Math.floor(u * st.nStripes) % 2 === 0
? mixHex(st.cA, '#241f22', 0.30) : mixHex(st.cB, '#241f22', 0.18);
stamped(stP(0.02 + 0.3 * (k / nR)), (c, jr) => {
band(c, jr, a.x, a.y, W * (st.d1 - st.d0) * 0.5 / nR * 1.2,
recedeAng(a.x, a.y), Math.max(1.6, W * 0.012 * a.g), gnd(col, 0.95), 0.55, 0.6);
});
}
for (let k = 0; k < 5; k++) { // its shadow line under the roll
const u = rng();
const a = attach(u);
stamped(stP(0.36 + 0.01 * k), (c, jr) => {
slab(c, a.x + shDir * W * 0.006 * a.g, a.y + H * 0.012 * a.g,
W * 0.02 * (0.4 + a.g), shAng, Math.max(1.2, H * 0.007 * a.g),
gnd('#2e2c34', 0.9), 0.30);
});
}
}
}
// -- THE STALLS: tables, cloths, and the produce in color pools -------------
const PRODUCE = {
spring: ['#d85868', '#e87c84', '#7ca24a', '#94ba52'],
summer: ['#d83a2a', '#e85a38', '#f0a050', '#f4bc68', '#e8c838'],
autumn: ['#e07820', '#e89038', '#c03828', '#d04c38', '#7a4a8a', '#8a5c9a'],
winter: ['#e8b020', '#f0c440', '#a8c070', '#bccb84', '#5c7a3c'],
};
const GPAL = groundPalette(season, cond.warmth); // the season's own greens
const SALAD = GPAL.mid.concat(GPAL.light);
const LEMON = '#f0c830';
const bareK = dawn ? 0.5 : 0; // dawn: half the stalls bare
const poolK = noon ? 0.55 : 1; // noon: the market ending
for (let si = 0; si < stalls.length; si++) {
const st = stalls[si];
if (st.folded) continue;
const gM = gOf(st.dMid);
const slot = 0.610 + 0.010 * (si / stalls.length);
const sl = 0.011 / stalls.length;
const stP = (k) => slot + sl * clamp(k, 0, 0.98) + rng() * 0.0003;
// the table: a solid slab along the stall's length, cloth or bare wood
const tablePts = [];
const nT = 5;
for (let k = 0; k < nT; k++) {
const u = (k + 0.5) / nT;
const d = lerp(st.d0, st.d1, u);
const g = gOf(d);
const pa = gp(st.xnWall + st.side * -st.proj * 0.15, d);
const pb = gp(st.xnWall + st.side * -st.proj * 0.92, d);
tablePts.push({ a: { x: pa.x, y: pa.y - tHNear * g }, b: { x: pb.x, y: pb.y - tHNear * g * 0.94 }, g, d });
}
const bare = rng() < bareK;
const cloth = rng() < 0.45;
stamped(stP(0.1), (c, jr) => {
for (const tp of tablePts) {
const ang = Math.atan2(tp.b.y - tp.a.y, tp.b.x - tp.a.x);
const len = Math.hypot(tp.b.x - tp.a.x, tp.b.y - tp.a.y);
const tw = Math.max(2.2, W * 0.016 * tp.g);
slab(c, (tp.a.x + tp.b.x) / 2, (tp.a.y + tp.b.y) / 2, len * 1.18, ang, tw,
gnd(mixHex('#4a3828', '#3c2e20', 0.4), 0.95), 0.7);
// the top surface: cloth-white or lit wood, one solid pass above
slab(c, (tp.a.x + tp.b.x) / 2, (tp.a.y + tp.b.y) / 2 - tw * 0.38, len * 1.10, ang, tw * 0.72,
gnd(cloth ? mixHex('#dcd6c4', st.cA, 0.10) : mixHex('#8a6c4c', '#f0e2c0', 0.25), 0.95), 0.65);
}
});
// table legs / front shadow
stamped(stP(0.5), (c, jr) => {
for (const tp of [tablePts[0], tablePts[tablePts.length - 1]]) {
const px = (tp.a.x + tp.b.x) / 2;
const py = (tp.a.y + tp.b.y) / 2;
band(c, jr, px, py + tHNear * tp.g * 0.5, tHNear * tp.g * 0.9, Math.PI / 2,
Math.max(1, W * 0.004 * tp.g), gnd('#241c14', 0.9), 0.55);
}
});
if (bare) continue;
// PRODUCE POOLS: dense saturated heaps in 2-3 tones, tinted by the awning
const tint = mixHex(st.cA, '#ffffff', 0.15);
const palette = PRODUCE[season] || PRODUCE.summer;
const nPools = Math.max(1, Math.round((2 + Math.floor(rng() * 2)) * poolK));
for (let pi = 0; pi < nPools; pi++) {
const u = (pi + 0.5 + (rng() - 0.5) * 0.3) / nPools;
const tp = tablePts[clamp(Math.floor(u * nT), 0, nT - 1)];
const cx = (tp.a.x + tp.b.x) / 2 + (rng() - 0.5) * W * 0.008 * tp.g;
const cy = (tp.a.y + tp.b.y) / 2;
const pr = Math.max(3.5, W * 0.026 * tp.g * (0.9 + rng() * 0.4));
// the pool's family: one season color + its broken neighbors, or salad greens
const salad = pi === 0 || rng() < 0.3;
const family = salad ? [pick(SALAD), pick(SALAD), pick(SALAD)]
: brokenColor(null, rng, pick(palette), 0.45).concat(pick(palette));
const pP = 0.628 + 0.045 * (si / stalls.length) + 0.010 * (pi / nPools);
const nD = Math.round((30 + rng() * 16) * covK);
for (let k = 0; k < nD; k++) {
const a = rng() * TAU, rr = Math.pow(rng(), 0.55) * pr;
const dx = cx + Math.cos(a) * rr;
const dy = cy + Math.sin(a) * rr * 0.40 - Math.pow(rng(), 2) * pr * 0.30; // the mound
const col = k % 5 === 4 && rng() < 0.4 ? LEMON : family[k % family.length];
const pcol = mixHex(col, tint, 0.14 + rng() * 0.12); // the awning's tint falls on it
stamped(pP + 0.030 * (k / nD) + rng() * 0.0008, (c, jr) => {
touch(c, jr, dx, dy, Math.max(1.6, pr * (0.26 + rng() * 0.26)), pcol,
0.62 + rng() * 0.28, (rng() - 0.5) * 0.5, 0.72);
});
}
// the pool's dark seat: the crate edge under the heap
stamped(pP + 0.033, (c, jr) => {
slab(c, cx, cy + pr * 0.50, pr * 2.2, 0, Math.max(1.6, pr * 0.30), gnd('#2c241a', 0.95), 0.45);
});
}
}
// -- produce highlights: lemons like dropped suns, struck last ---------------
for (let i = 0, nG = Math.round(40 * covK); i < nG && stalls.length; i++) {
const st = stalls[Math.floor(rng() * stalls.length)];
if (st.folded) continue;
const d = lerp(st.d0, st.d1, rng());
const g = gOf(d);
const p = gp(st.xnWall + st.side * -st.proj * 0.6, d);
stamped(0.752 + 0.012 * rng(), (c, jr) => {
touch(c, jr, p.x + gauss() * W * 0.012 * g, p.y - tHNear * g - gauss() * H * 0.004,
Math.max(0.9, W * 0.0028 * g), rng() < 0.6 ? LEMON : mixHex('#fff2c8', LEMON, 0.4),
0.5 + rng() * 0.3, 0, 0.7);
});
}
// -- THE FIGURES: the market's people ----------------------------------------
const COATS = ['#3a3644', '#2e3444', '#4a3c34', '#56423a', '#384454', '#5c4a52'];
const APRONS = ['#e4ded0', '#2c3a5c'];
const ACCENTS = ['#c03830', '#d89030', '#3868a8', '#8a4a78', '#c8a028'];
const openStalls = stalls.filter((s) => !s.folded);
const nFig = night ? 0
: dawn ? 5 + Math.floor(rng() * 4)
: rainy ? 6 + Math.floor(rng() * 5)
: nFigBase;
// vendors: half-figures behind the stalls, painted before the produce so
// the tables seat their waists
let vendorBudget = Math.round(nFig / 3);
for (const st of openStalls) {
if (vendorBudget <= 0) break;
const d = clamp(lerp(st.d0, st.d1, rng()), 0.3, 0.96);
const g = gOf(d);
const p = gp(st.xnWall + st.side * -st.proj * 0.45, d);
const fh = fhNear * g;
const x = p.x, yTop = p.y - tHNear * g * 0.92;
const apron = pick(APRONS);
const lean = (rng() - 0.5) * 0.14;
stamped(0.606 + rng() * 0.004, (c, jr) => {
// torso rising from behind the table
band(c, jr, x, yTop - fh * 0.28, fh * 0.52, -Math.PI / 2 + lean, fh * 0.34,
gnd(mixHex('#3c3630', apron, 0.35), 0.95), 0.78);
// the apron: white or dark blue, the vendor's sign
touch(c, jr, x + st.side * fh * 0.06, yTop - fh * 0.16, fh * 0.24,
gnd(apron, 0.95), 0.75, 0, 0.85);
// shoulders, then the head — a cap or bare hair, never a face
touch(c, jr, x, yTop - fh * 0.56, fh * 0.30, gnd('#463c34', 0.95), 0.7, 0, 0.55);
touch(c, jr, x + lean * fh * 0.4, yTop - fh * 0.68, fh * 0.14,
gnd(rng() < 0.5 ? '#5c4232' : '#26222a', 0.95), 0.85);
// one accent: a cap or neckerchief
touch(c, jr, x + lean * fh * 0.4, yTop - fh * 0.74, fh * 0.12,
fac(pick(ACCENTS), 0.7), 0.7, 0, 0.5);
// an arm reaching into the produce
if (rng() < 0.7) {
band(c, jr, x + st.side * -fh * 0.18, yTop - fh * 0.34, fh * 0.3,
Math.PI / 2 + st.side * -0.4 + lean, Math.max(1, fh * 0.10), gnd('#3c3630', 0.95), 0.65);
}
});
vendorBudget--;
}
// shoppers, children, the dog: depth-sorted, far first
const walkers = [];
for (let i = 0; i < nFig; i++) {
const d = 0.34 + Math.pow(rng(), 1.1) * 0.62;
const xn = 0.10 + rng() * 0.80;
walkers.push({ d, xn, kind: 'shopper' });
}
for (let i = 0; i < nChild; i++) {
walkers.push({ d: 0.55 + rng() * 0.4, xn: 0.2 + rng() * 0.6, kind: 'child' });
}
if (hasDog) walkers.push({ d: 0.5 + rng() * 0.45, xn: 0.15 + rng() * 0.7, kind: 'dog' });
walkers.sort((a, b) => a.d - b.d);
for (let wi = 0; wi < walkers.length; wi++) {
const wk = walkers[wi];
const g = gOf(wk.d);
const p = gp(wk.xn, wk.d);
const slot = 0.766 + 0.085 * (wi / Math.max(1, walkers.length));
const fh = fhNear * g * (wk.kind === 'child' ? 0.62 : 1);
const x = p.x, y = p.y;
// the figure's long shadow on the cobbles
stamped(slot, (c, jr) => {
band(c, jr, x + shDir * fh * 0.5 * shLenK, y + fh * 0.03,
fh * 1.15 * shLenK, shAng + (rng() - 0.5) * 0.08, Math.max(1.2, fh * 0.16),
shadowCol, 0.40, 0.30);
});
if (wk.kind === 'dog') {
stamped(slot + 0.004, (c, jr) => {
smear(c, jr, x, y - fh * 0.22, fh * 0.58, (rng() - 0.5) * 0.1, fh * 0.24,
gnd('#4c3c2c', 0.95), 0.78, 0.6);
touch(c, jr, x + fh * 0.32, y - fh * 0.32, fh * 0.13, gnd('#3c3024', 0.95), 0.8);
rigger(c, jr, { x: x - fh * 0.22, y: y - fh * 0.14, length: fh * 0.15,
color: gnd('#33281e', 0.95), alpha: 0.6, angle: Math.PI / 2, width: Math.max(0.6, fh * 0.06), bow: 0 });
rigger(c, jr, { x: x + fh * 0.20, y: y - fh * 0.14, length: fh * 0.15,
color: gnd('#33281e', 0.95), alpha: 0.6, angle: Math.PI / 2, width: Math.max(0.6, fh * 0.06), bow: 0 });
rigger(c, jr, { x: x - fh * 0.28, y: y - fh * 0.28, length: fh * 0.18,
color: gnd('#4c3c2c', 0.95), alpha: 0.55, angle: Math.PI + 0.6, width: Math.max(0.5, fh * 0.045), bow: fh * 0.06 });
});
continue;
}
const child = wk.kind === 'child';
const coat = child ? pick(['#8a4a3c', '#3c5a7c', '#5c6a3c']) : pick(COATS);
const lean = (rng() - 0.5) * (child ? 0.3 : 0.12);
const acc = pick(ACCENTS);
const umbrella = rainy && !child && rng() < 0.45;
stamped(slot + 0.004, (c, jr) => {
// legs under the hem
band(c, jr, x - fh * 0.09, y - fh * 0.13, fh * 0.26, -Math.PI / 2 + lean * 0.4,
Math.max(0.8, fh * 0.09), gnd('#26242c', 0.95), 0.7);
band(c, jr, x + fh * 0.09, y - fh * 0.13, fh * 0.26, -Math.PI / 2 + lean * 0.4,
Math.max(0.8, fh * 0.09), gnd('#26242c', 0.95), 0.7);
// the coat: a long dark mass, the figure's body
band(c, jr, x, y - fh * 0.42, fh * 0.56, -Math.PI / 2 + lean, fh * 0.34,
gnd(coat, 0.95), 0.78);
band(c, jr, x + fh * 0.05, y - fh * 0.36, fh * 0.40, -Math.PI / 2 + lean,
fh * 0.22, gnd(mixHex(coat, '#0e0c10', 0.22), 0.95), 0.55);
// shoulders
touch(c, jr, x, y - fh * 0.72, fh * 0.28, gnd(mixHex(coat, '#c8c2b4', 0.12), 0.95), 0.68, 0, 0.55);
// the head: a hat, a hood, bare hair — never a face
touch(c, jr, x + lean * fh * 0.3, y - fh * 0.88, fh * 0.15,
gnd(rng() < 0.5 ? '#1e1c22' : mixHex('#b88a68', '#3a3430', 0.4), 0.95), 0.85);
// one color accent: a scarf, a shawl
touch(c, jr, x, y - fh * 0.70, fh * 0.16, fac(acc, 0.65), 0.8, 0, 0.6);
// the market basket, with produce in it
const bx = x + (shDir > 0 ? -1 : 1) * fh * 0.30;
touch(c, jr, bx, y - fh * 0.40, fh * 0.22, gnd('#6a4c30', 0.95), 0.75, 0, 0.7);
if (rng() < 0.7) touch(c, jr, bx, y - fh * 0.50, fh * 0.13,
mixHex(pick(PRODUCE[season] || PRODUCE.summer), '#ffffff', 0.08), 0.85);
if (umbrella) { // rain: a dark disc overhead
touch(c, jr, x, y - fh * 1.12, fh * 0.52, gnd('#26242e', 0.95), 0.8, 0, 0.42);
rigger(c, jr, { x: x, y: y - fh * 1.05, length: fh * 0.16,
color: gnd('#1c1a22', 0.95), alpha: 0.6, angle: Math.PI / 2, width: Math.max(0.5, fh * 0.04), bow: 0 });
}
});
// goldenHour: a vendor sweeping — the broom out
if (golden && rng() < 0.14 && !child) {
stamped(slot + 0.012, (c, jr) => {
rigger(c, jr, { x: x + fh * 0.3, y: y - fh * 0.5, length: fh * 0.9,
color: gnd('#8a6c40', 0.95), alpha: 0.6, angle: Math.PI / 2 + shDir * -0.5,
width: Math.max(0.7, fh * 0.05), bow: fh * 0.04 });
touch(c, jr, x + fh * 0.55, y - fh * 0.05, fh * 0.16, gnd('#a8843c', 0.95), 0.65, 0, 0.5);
});
}
}
// -- NIGHT: the market's ghost — the square empty, one café lit --------------
if (night) {
const dC = 0.74 + rng() * 0.12;
const gC = gOf(dC);
const xnC = cafeSide < 0 ? 0.10 + rng() * 0.06 : 0.84 + rng() * 0.06;
const pC = gp(xnC, dC);
const fhC = fhNear * gC;
const st = (k) => 0.862 + k * 0.004 + rng() * 0.001;
// the café frontage: a warm lit band on the corner wall
stamped(st(0), (c, jr) => {
// the storefront's warm mass first: the wall itself holds the light
for (let k = 0; k < 12; k++) {
const wx = pC.x + (rng() - 0.5) * W * 0.09 * gC;
const wy = pC.y - fhC * (0.3 + rng() * 1.5);
touch(c, jr, wx, wy, Math.max(3, fhC * 0.34), mixHex('#8a5c28', '#d99a3e', rng() * 0.5), 0.30 + rng() * 0.2);
}
for (let k = 0; k < 18; k++) {
const wx = pC.x + (rng() - 0.5) * W * 0.09 * gC;
const wy = pC.y - fhC * (0.4 + rng() * 1.5);
touch(c, jr, wx, wy, Math.max(2, fhC * 0.16), pick(['#f0c860', '#e8b850', '#f6dc86']), 0.5 + rng() * 0.3);
}
for (let k = 0; k < 12; k++) { // the halo the night leans into
const a = rng() * TAU, r = fhC * (1 + rng() * 1.6);
dab(c, pC.x + Math.cos(a) * r, pC.y - fhC * 1.0 + Math.sin(a) * r * 0.8,
fhC * (0.3 + rng() * 0.4), mixHex('#d99a3e', '#3a4a66', 0.45), 0.06 + rng() * 0.07);
}
});
// the empty wet square carries the café's light in long streaks
stamped(st(1), (c, jr) => {
for (let k = 0, nR = 12; k < nR; k++) {
const rx = pC.x + (rng() - 0.5) * W * 0.08 * gC;
const ry = pC.y + rng() * H * 0.05 * gC;
rigger(c, jr, { x: rx, y: ry, length: H * (0.02 + rng() * 0.06) * gC,
color: '#d99a3e', alpha: 0.06 + rng() * 0.10,
angle: Math.PI / 2 + (rng() - 0.5) * 0.1, width: Math.max(0.8, 1.5 * gC) });
}
});
// one gas lamp, cold, further down the street
const dL = 0.4 + rng() * 0.15;
const gL = gOf(dL);
const pL = gp(vxN + (rng() - 0.5) * 0.2, dL);
const postH = fhNear * gL * 2.4;
stamped(st(2), (c, jr) => {
rigger(c, jr, { x: pL.x, y: pL.y, length: postH, color: gnd('#2a3040', 0.95),
alpha: 0.7, angle: -Math.PI / 2, width: Math.max(1.4, W * 0.0035 * gL) });
touch(c, jr, pL.x, pL.y - postH, Math.max(1.8, W * 0.006 * gL), '#f0d888', 0.85);
dab(c, pL.x, pL.y - postH, W * 0.024 * gL, mixHex('#d99a3e', '#2c3860', 0.55), 0.14);
});
}
// -- the unifying breath -------------------------------------------------------
for (let i = 0; i < 16; i++) {
const y = rng() * H;
const col = y < hzY ? mixHex('#e6e3d8', '#b8c2cc', rng() * 0.4)
: gnd(mixHex(pick(COBBLE), '#3a3428', rng() * 0.3));
stamped(0.888 + 0.012 * rng(), veil(rng() * W, y, W * (0.08 + rng() * 0.18), (rng() - 0.5) * 0.08,
Math.max(2, H * (0.015 + rng() * 0.03)), col, 0.03 + rng() * 0.04,
hand.mode === 'impasto' ? 0.45 : 0.3));
}
// -- the final veil: mist and rain soften every edge one last time --------------
if (misty || rainy || snowy) {
const veilCol = mixHex('#e2e6e4', night ? '#3a4456' : dawn ? '#c8d2da' : '#d8ded8', night ? 0.5 : 0.25);
for (let i = 0; i < 12; i++) {
stamped(0.965 + 0.020 * rng(), (c, jr) => {
band(c, jr, rng() * W, rng() * H, W * (0.14 + rng() * 0.28), (rng() - 0.5) * 0.04,
H * (0.02 + rng() * 0.04), veilCol, 0.03 + rng() * 0.04);
});
}
}
strokes = list.sort((a, b) => a.p - b.p);
},
draw(ctx, W, H, _rng, from, to, _conditions) {
if (!strokes) return;
for (let i = 0; i < strokes.length; i++) {
const s = strokes[i];
if (s.p >= from && s.p < to) s.fn(ctx);
}
},
};
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