← back to Graphics Agentabrams
graphics/living-fractal-tree/LivingFractalTree.aa
386 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Living Fractal Tree</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #0d1420;
font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
canvas { display: block; }
/* season badge — top centre */
#season {
position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 3;
background: rgba(10, 16, 28, 0.66); backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 999px;
padding: 7px 18px; color: #eef3fa; letter-spacing: 0.09em; text-transform: uppercase;
display: flex; align-items: center; gap: 9px; user-select: none;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35);
}
#season .dot { width: 9px; height: 9px; border-radius: 50%;
box-shadow: 0 0 10px currentColor; transition: color .8s; }
#hint { position: fixed; top: 20px; right: 20px; z-index: 3;
color: rgba(255, 255, 255, 0.28); font-size: 11px; letter-spacing: 0.04em; user-select: none; }
/* control dock — bottom centre */
#ui {
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); z-index: 3;
background: rgba(10, 16, 28, 0.78); backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 16px;
padding: 13px 20px; color: #dfe8f2;
display: flex; align-items: center; gap: 20px; flex-wrap: wrap; justify-content: center;
max-width: 94vw; box-shadow: 0 10px 38px rgba(0, 0, 0, 0.45); user-select: none;
}
#ui .grp { display: flex; align-items: center; gap: 9px; }
#ui label { color: #c5cfda; white-space: nowrap; }
#ui .val { min-width: 2.4ch; text-align: right; font-variant-numeric: tabular-nums; color: #9fd977; }
#ui .sep { width: 1px; height: 24px; background: rgba(255, 255, 255, 0.1); }
input[type="range"] { width: 130px; accent-color: #7fb26a; cursor: pointer; vertical-align: middle; }
#ui button { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.13);
color: #dfe8f2; padding: 7px 13px; border-radius: 9px; cursor: pointer; letter-spacing: 0.02em;
transition: background .15s, border-color .15s; white-space: nowrap; font-size: 12.5px; }
#ui button:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.22); }
#ui button.on { background: rgba(127, 178, 106, 0.22); border-color: rgba(127, 178, 106, 0.5); color: #bfe6bf; }
@media (max-width: 560px) {
#ui { gap: 11px; padding: 11px 13px; } input[type="range"] { width: 92px; } #hint { display: none; }
}
</style>
</head>
<body>
<div id="season"><span class="dot" id="seasonDot"></span><span id="seasonName">Spring</span></div>
<div id="hint">press <b>S</b> for next season</div>
<div id="ui">
<div class="grp">
<label>Branch depth</label>
<input type="range" id="depth" min="4" max="12" step="1" value="9">
<span class="val" id="depthVal">9</span>
</div>
<div class="sep"></div>
<div class="grp">
<label>Wind</label>
<input type="range" id="wind" min="0" max="200" step="1" value="100">
<span class="val" id="windVal">1.0</span>
</div>
<div class="sep"></div>
<div class="grp">
<button id="seasonBtn">Next season</button>
<button id="autoBtn" class="on">Auto cycle</button>
<button id="regrowBtn">Regrow</button>
</div>
</div>
<canvas id="c"></canvas>
<script>
'use strict';
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const depthSlider = document.getElementById('depth');
const depthVal = document.getElementById('depthVal');
const windSlider = document.getElementById('wind');
const windVal = document.getElementById('windVal');
const seasonNameEl = document.getElementById('seasonName');
const seasonDotEl = document.getElementById('seasonDot');
let W = 0, H = 0;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth; H = window.innerHeight;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// ---------- deterministic per-branch randomness (stable tree shape) ----------
// Hashing the branch seed (instead of Math.random each frame) is what keeps the
// tree from strobing: identical structure every frame, only wind animates it.
function rnd(seed) { const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
// ---------- tunables ----------
let maxDepth = 9;
let windStrength = 1.0;
const GROW_SPEED = 1.9; // depth levels per second
const LEN_RATIO = 0.72;
const SPREAD = 0.52; // base branching angle (radians)
const SEASON_LEN = 13; // seconds per season (auto cycle)
const SEASONS = ['Spring', 'Summer', 'Autumn', 'Winter'];
const SEASON_DOT = ['#9fd977', '#4e9e46', '#ef7d2a', '#cfe4f2'];
// leaf palettes per season [light, dark]
const LEAF_COLORS = [
['#9fd977', '#6fae4e'], // spring
['#4e9e46', '#2f7a35'], // summer
['#e8a13c', '#c65b28'], // autumn
['#c3d0d6', '#9aabb3'], // winter (sparse, pale)
];
// leaf density on the tree + fall rate (particles/sec) per season
const LEAF_DENSITY = [0.85, 1.0, 0.8, 0.10];
const FALL_RATE = [3, 4, 48, 34]; // winter is high but spawns SNOW, not leaves
const SNOW_SEASON = 3;
// ---------- state ----------
let grow = 0; // rises toward maxDepth+1; new depths animate in
let tips = []; // leaf tip positions collected each frame
const particles = []; // falling leaves / snow
const pile = []; // leaves settled on the ground
const MAX_PARTICLES = 460;
const MAX_PILE = 460;
let spawnCarry = 0;
// season timeline — advances only while auto-cycling, so a manual pick holds
let seasonClock = 0;
let autoCycle = true;
function lerp(a, b, t) { return a + (b - a) * t; }
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function hexToRgb(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; }
function mixHex(a, b, t) {
const ca = hexToRgb(a), cb = hexToRgb(b);
return `rgb(${ca.map((v, i) => Math.round(lerp(v, cb[i], t))).join(',')})`;
}
// ---------- wind ----------
let gust = 0;
function windAt(t) {
return (Math.sin(t * 0.55) * 0.45 + Math.sin(t * 0.23 + 1.7) * 0.35 + Math.sin(t * 1.4 + 0.4) * 0.12) * windStrength;
}
// ---------- tree ----------
function branch(x, y, angle, len, width, depth, seed, t) {
const lg = clamp(grow - depth, 0, 1);
if (lg <= 0) return;
const ease = lg * lg * (3 - 2 * lg); // smoothstep growth
// thinner/higher branches sway more; each branch has its own phase
const flex = Math.pow((depth + 1) / (maxDepth + 1), 1.7);
const sway = gust * 0.22 * flex * (0.7 + rnd(seed) * 0.6)
+ Math.sin(t * (1.6 + rnd(seed + 9) * 1.4) + seed) * 0.012 * flex;
angle += sway;
const L = len * ease;
const x2 = x + Math.cos(angle) * L;
const y2 = y + Math.sin(angle) * L;
ctx.strokeStyle = mixHex('#5a4330', '#2e2118', depth / (maxDepth || 1));
ctx.lineWidth = Math.max(width * ease, 0.5);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.stroke();
if (depth >= maxDepth) {
if (ease > 0.85) tips.push(x2, y2);
return;
}
const spread = SPREAD * (0.85 + rnd(seed + 1) * 0.35);
const bend = (rnd(seed + 2) - 0.5) * 0.25;
const nl = len * LEN_RATIO * (0.92 + rnd(seed + 3) * 0.16);
const nw = width * 0.67;
branch(x2, y2, angle - spread + bend, nl, nw, depth + 1, seed * 2 + 1, t);
branch(x2, y2, angle + spread + bend, nl, nw, depth + 1, seed * 2 + 2, t);
// occasional third middle branch for fullness
if (rnd(seed + 4) > 0.72 && depth > 1) {
branch(x2, y2, angle + bend * 2, nl * 0.8, nw * 0.8, depth + 1, seed * 3 + 3, t);
}
}
function drawLeaves(t, seasonIdx, seasonMix) {
const density = lerp(LEAF_DENSITY[seasonIdx], LEAF_DENSITY[(seasonIdx + 1) % 4], seasonMix);
const [liA, daA] = LEAF_COLORS[seasonIdx];
const [liB, daB] = LEAF_COLORS[(seasonIdx + 1) % 4];
for (let i = 0; i < tips.length; i += 2) {
const seed = i * 0.731 + 5;
if (rnd(seed) > density) continue;
const x = tips[i], y = tips[i + 1];
const s = 2.2 + rnd(seed + 1) * 2.6;
const wob = Math.sin(t * 2.1 + i) * 1.4 * Math.abs(gust);
ctx.fillStyle = mixHex(rnd(seed + 2) > 0.5 ? liA : daA, rnd(seed + 2) > 0.5 ? liB : daB, seasonMix);
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.ellipse(x + wob, y, s, s * 0.62, rnd(seed + 3) * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// ---------- falling particles: leaves shed from tips, or snow from the sky ----
function spawnParticles(dt, seasonIdx, seasonMix) {
const isSnow = seasonIdx === SNOW_SEASON;
if (!isSnow && tips.length === 0) return;
const rate = lerp(FALL_RATE[seasonIdx], FALL_RATE[(seasonIdx + 1) % 4], seasonMix);
spawnCarry += rate * dt;
while (spawnCarry >= 1 && particles.length < MAX_PARTICLES) {
spawnCarry -= 1;
if (isSnow) {
particles.push({
x: Math.random() * W, y: -10,
vx: (Math.random() - 0.5) * 8, vy: 18 + Math.random() * 22,
rot: 0, vr: 0, size: 1.3 + Math.random() * 2.4,
color: Math.random() > 0.5 ? '#eef5fb' : '#cddced',
phase: Math.random() * Math.PI * 2, life: 1, snow: true,
});
} else {
const i = (Math.random() * (tips.length / 2) | 0) * 2;
const [li, da] = LEAF_COLORS[seasonIdx];
particles.push({
x: tips[i], y: tips[i + 1],
vx: (Math.random() - 0.5) * 12, vy: 6 + Math.random() * 14,
rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 4,
size: 2.4 + Math.random() * 3,
color: Math.random() > 0.5 ? li : da,
phase: Math.random() * Math.PI * 2, life: 1, snow: false,
});
}
}
spawnCarry = Math.min(spawnCarry, 4);
}
function updateParticles(dt, t) {
const ground = H - 8;
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
if (p.y < ground) {
const term = p.snow ? 40 : 46;
p.vy = Math.min(p.vy + (p.snow ? 12 : 26) * dt, term);
p.vx += (gust * (p.snow ? 20 : 34) + Math.sin(t * 3 + p.phase) * (p.snow ? 8 : 16)) * dt;
p.vx *= 0.985;
p.x += p.vx * dt;
p.y += p.vy * dt + Math.sin(t * 4 + p.phase) * (p.snow ? 0.2 : 0.35);
p.rot += p.vr * dt;
} else {
// landed: leaves join the ground pile, snow just melts away
if (!p.snow && pile.length < MAX_PILE) {
pile.push({ x: p.x, y: ground - Math.random() * 5, size: p.size, color: p.color });
}
particles.splice(i, 1);
continue;
}
if (p.x < -30) p.x = W + 30; else if (p.x > W + 30) p.x = -30;
}
}
function drawParticles() {
for (const p of particles) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.globalAlpha = 0.9;
ctx.fillStyle = p.color;
ctx.beginPath();
if (p.snow) {
ctx.arc(0, 0, p.size, 0, Math.PI * 2);
} else {
ctx.rotate(p.rot);
ctx.ellipse(0, 0, p.size, p.size * 0.55, 0, 0, Math.PI * 2);
}
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
}
function drawPile() {
ctx.globalAlpha = 0.6;
for (const p of pile) {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.size * 1.25, p.size * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// ---------- background ----------
const SKY_TOP = ['#1a2a45', '#1c3050', '#2a2438', '#141c2b'];
const SKY_BOT = ['#3c5a72', '#41628a', '#6a4a4c', '#2c3a4d'];
function drawBackground(seasonIdx, seasonMix) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, mixHex(SKY_TOP[seasonIdx], SKY_TOP[(seasonIdx + 1) % 4], seasonMix));
g.addColorStop(1, mixHex(SKY_BOT[seasonIdx], SKY_BOT[(seasonIdx + 1) % 4], seasonMix));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(20, 26, 22, 0.85)';
ctx.fillRect(0, H - 8, W, 8);
}
// ---------- season helpers ----------
function currentSeason() {
const phase = (seasonClock / SEASON_LEN) % 4;
const idx = phase | 0;
// cross-blend only in the last 20% of a season, and only while auto-cycling
const mix = autoCycle ? clamp((phase - idx - 0.8) / 0.2, 0, 1) : 0;
return { idx, mix };
}
function updateSeasonBadge(idx) {
seasonNameEl.textContent = SEASONS[idx];
seasonDotEl.style.color = SEASON_DOT[idx];
}
function nextSeason() {
// snap to the start of the next season so the pick reads cleanly
seasonClock = (Math.floor(seasonClock / SEASON_LEN) + 1) * SEASON_LEN;
}
// ---------- main loop ----------
const t0 = performance.now();
let last = t0;
let badgeIdx = -1;
function frame(now) {
const t = (now - t0) / 1000;
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
grow = Math.min(grow + GROW_SPEED * dt, maxDepth + 1);
gust = windAt(t);
if (autoCycle) seasonClock += dt;
const { idx: seasonIdx, mix: seasonMix } = currentSeason();
if (seasonIdx !== badgeIdx) { badgeIdx = seasonIdx; updateSeasonBadge(seasonIdx); }
drawBackground(seasonIdx, seasonMix);
drawPile();
tips = [];
ctx.lineCap = 'round';
const baseLen = Math.min(H * 0.24, 210);
branch(W / 2, H - 8, -Math.PI / 2, baseLen, Math.max(baseLen * 0.075, 6), 0, 1, t);
drawLeaves(t, seasonIdx, seasonMix);
spawnParticles(dt, seasonIdx, seasonMix);
updateParticles(dt, t);
drawParticles();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// ---------- controls ----------
depthSlider.addEventListener('input', () => {
maxDepth = +depthSlider.value;
depthVal.textContent = depthSlider.value;
grow = 0; // regrow so new depths animate in cleanly
pile.length = 0;
});
windSlider.addEventListener('input', () => {
windStrength = +windSlider.value / 100;
windVal.textContent = windStrength.toFixed(1);
});
document.getElementById('seasonBtn').addEventListener('click', nextSeason);
const autoBtn = document.getElementById('autoBtn');
autoBtn.addEventListener('click', () => {
autoCycle = !autoCycle;
autoBtn.classList.toggle('on', autoCycle);
});
document.getElementById('regrowBtn').addEventListener('click', () => {
grow = 0; particles.length = 0; pile.length = 0; spawnCarry = 0;
});
window.addEventListener('keydown', (e) => {
if (e.key === 's' || e.key === 'S') nextSeason();
});
</script>
</body>
</html>