← 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&nbsp;<b>S</b>&nbsp;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>