SOURCE

console 命令行工具 X clear

                    
>
console
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>遇见 · 116 秒</title>
<style>
  :root { color-scheme: dark; }
  * { box-sizing: border-box; }
  html, body {
    width: 100%;
    height: 100%;
    margin: 0;
    overflow: hidden;
    background:
      radial-gradient(ellipse at 50% 45%, #101823 0%, #070a0f 55%, #030407 100%);
  }
  body {
    display: grid;
    place-items: center;
    -webkit-tap-highlight-color: transparent;
  }
  #stage {
    display: block;
    width: 100vw;
    height: 100vh;
    outline: none;
    cursor: default;
    touch-action: manipulation;
  }
  noscript {
    position: fixed;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    color: #f0dfc4;
    font: 16px/1.8 "Microsoft YaHei", sans-serif;
    text-align: center;
  }

  /* ---- 顶部极简悬浮控制条(仅 UI,不参与画面绘制) ---- */
  #transport {
    position: fixed;
    top: max(12px, env(safe-area-inset-top));
    left: 50%;
    z-index: 20;
    width: min(720px, calc(100vw - 28px));
    height: 44px;
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 0 15px;
    transform: translateX(-50%);
    color: rgba(236, 240, 242, 0.84);
    background: rgba(30, 34, 38, 0.38);
    border: 1px solid rgba(236, 240, 242, 0.13);
    border-radius: 999px;
    box-shadow:
      0 8px 28px rgba(0, 0, 0, 0.13),
      inset 0 1px 0 rgba(255, 255, 255, 0.05);
    -webkit-backdrop-filter: blur(14px) saturate(108%);
    backdrop-filter: blur(14px) saturate(108%);
    font-family: "Cascadia Mono", "SFMono-Regular", "Consolas", monospace;
    opacity: 0.9;
    transition: opacity 180ms ease, background-color 180ms ease, border-color 180ms ease;
    -webkit-user-select: none;
    user-select: none;
  }
  #transport:hover,
  #transport:focus-within {
    opacity: 1;
    background: rgba(36, 40, 44, 0.48);
    border-color: rgba(236, 240, 242, 0.2);
  }
  #transportToggle {
    flex: 0 0 auto;
    width: 28px;
    height: 28px;
    display: grid;
    place-items: center;
    padding: 0;
    color: rgba(242, 245, 246, 0.82);
    background: transparent;
    border: 0;
    border-radius: 50%;
    cursor: pointer;
    transition: background-color 140ms ease, color 140ms ease;
  }
  #transportToggle:hover {
    background: rgba(255, 255, 255, 0.09);
    color: rgba(255, 255, 255, 0.98);
  }
  #transportToggle:active {
    background: rgba(255, 255, 255, 0.14);
  }
  #transportToggle:focus-visible,
  #transportSeek:focus-visible {
    outline: 1px solid rgba(235, 239, 241, 0.5);
    outline-offset: 3px;
  }
  #transportToggle svg {
    width: 13px;
    height: 13px;
    display: block;
    fill: currentColor;
    pointer-events: none;
  }
  #transportToggle .icon-play { display: none; }
  #transportToggle .icon-pause { display: block; }
  #transportToggle.is-paused .icon-play { display: block; }
  #transportToggle.is-paused .icon-pause { display: none; }

  #transportSeek {
    --seek: 0%;
    flex: 1 1 auto;
    min-width: 90px;
    height: 28px;
    margin: 0;
    padding: 0;
    background: transparent;
    -webkit-appearance: none;
    appearance: none;
    cursor: pointer;
  }
  #transportSeek::-webkit-slider-runnable-track {
    width: 100%;
    height: 2px;
    border-radius: 2px;
    background: linear-gradient(
      to right,
      rgba(236, 240, 242, 0.72) 0%,
      rgba(236, 240, 242, 0.72) var(--seek),
      rgba(236, 240, 242, 0.16) var(--seek),
      rgba(236, 240, 242, 0.16) 100%
    );
  }
  #transportSeek::-webkit-slider-thumb {
    width: 10px;
    height: 10px;
    margin-top: -4px;
    border: 0;
    border-radius: 50%;
    background: rgba(244, 246, 247, 0.92);
    box-shadow: 0 0 0 3px rgba(244, 246, 247, 0.08);
    -webkit-appearance: none;
    appearance: none;
  }
  #transportSeek::-moz-range-track {
    width: 100%;
    height: 2px;
    border: 0;
    border-radius: 2px;
    background: rgba(236, 240, 242, 0.16);
  }
  #transportSeek::-moz-range-progress {
    height: 2px;
    border-radius: 2px;
    background: rgba(236, 240, 242, 0.72);
  }
  #transportSeek::-moz-range-thumb {
    width: 10px;
    height: 10px;
    border: 0;
    border-radius: 50%;
    background: rgba(244, 246, 247, 0.92);
    box-shadow: 0 0 0 3px rgba(244, 246, 247, 0.08);
  }
  #transportTime {
    flex: 0 0 auto;
    min-width: 94px;
    color: rgba(226, 231, 234, 0.64);
    font-size: 10px;
    line-height: 1;
    letter-spacing: 0.035em;
    text-align: right;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }
  @media (max-width: 560px) {
    #transport {
      height: 40px;
      gap: 8px;
      padding: 0 11px;
    }
    #transportTime {
      min-width: 83px;
      font-size: 9px;
    }
  }
</style>
</head>
<body>
<div id="transport" role="group" aria-label="播放控制">
  <button id="transportToggle" type="button" aria-label="暂停" title="暂停">
    <svg class="icon-play" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2.6v10.8L13 8 4 2.6z"/></svg>
    <svg class="icon-pause" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 3h3v10H4V3zm5 0h3v10H9V3z"/></svg>
  </button>
  <input id="transportSeek" type="range" min="0" max="116" step="0.01" value="0" aria-label="播放进度">
  <output id="transportTime" aria-live="off">00:00 / 01:56</output>
</div>
<canvas id="stage" tabindex="0" aria-label="《遇见》116 秒二维具象叙事动画"></canvas>
<noscript>请启用 JavaScript 以观看《遇见》116 秒动画。</noscript>
<script>
(() => {
  'use strict';

  // ============================================================
  //  遇见 · 116 秒
  //  单文件 Canvas 2D 叙事动画
  //
  //  确定性约定:
  //  · 不使用 Math.random()
  //  · 不读取 Date / 系统时钟
  //  · 所有粒子、光斑、窗灯、雨丝均由 hash(n, seed) 公式生成
  //  · 画面完全由 renderFrame(t) 驱动,任意 t 均可复现同一帧
  // ============================================================

  const canvas = document.getElementById('stage');
  const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });

  const W = 1600;
  const H = 900;
  const DURATION = 116;
  const TAU = Math.PI * 2;
  const F_SERIF = '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif';
  const F_SANS = '"Noto Sans SC","Microsoft YaHei","PingFang SC","Hiragino Sans GB",sans-serif';
  const F_MONO = '"Cascadia Mono","SFMono-Regular","Consolas",monospace';

  let dpr = 1;
  let cssW = W;
  let cssH = H;
  let stageScale = 1;
  let stageX = 0;
  let stageY = 0;

  // ---------- deterministic helpers ----------
  function clamp(v, a = 0, b = 1) { return v < a ? a : (v > b ? b : v); }
  function lerp(a, b, t) { return a + (b - a) * t; }
  function smoothstep(a, b, x) {
    const t = clamp((x - a) / (b - a || 1e-6));
    return t * t * (3 - 2 * t);
  }
  function easeInOutCubic(t) {
    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }
  function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
  function easeOutBack(t) {
    const c1 = 1.70158;
    const c3 = c1 + 1;
    return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
  }
  function mod(n, m) { return ((n % m) + m) % m; }
  function hash(n, seed = 0) {
    const x = Math.sin(n * 127.1 + seed * 311.7 + 74.7) * 43758.5453123;
    return x - Math.floor(x);
  }
  function pulse(t, speed, phase = 0) { return 0.5 + 0.5 * Math.sin(t * speed + phase); }
  function rgba(hex, a) {
    const h = hex.replace('#', '');
    const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
    return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
  }

  function roundRectPath(x, y, w, h, r) {
    const rr = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
    ctx.beginPath();
    ctx.moveTo(x + rr, y);
    ctx.lineTo(x + w - rr, y);
    ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
    ctx.lineTo(x + w, y + h - rr);
    ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
    ctx.lineTo(x + rr, y + h);
    ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
    ctx.lineTo(x, y + rr);
    ctx.quadraticCurveTo(x, y, x + rr, y);
    ctx.closePath();
  }
  function fillRoundRect(x, y, w, h, r, fill) {
    roundRectPath(x, y, w, h, r);
    ctx.fillStyle = fill;
    ctx.fill();
  }
  function strokeRoundRect(x, y, w, h, r, stroke, lw = 1) {
    roundRectPath(x, y, w, h, r);
    ctx.strokeStyle = stroke;
    ctx.lineWidth = lw;
    ctx.stroke();
  }

  function radialGlow(x, y, r, stops) {
    const g = ctx.createRadialGradient(x, y, 0, x, y, r);
    for (const s of stops) g.addColorStop(s[0], s[1]);
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, TAU);
    ctx.fill();
  }
  function softShadow(x, y, rx, ry, color) {
    const g = ctx.createRadialGradient(x, y, 0, x, y, Math.max(rx, ry));
    g.addColorStop(0, color);
    g.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.save();
    ctx.translate(x, y);
    ctx.scale(1, ry / Math.max(rx, 1));
    ctx.translate(-x, -y);
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(x, y, rx, 0, TAU);
    ctx.fill();
    ctx.restore();
  }

  function setFont(size, weight = 400, family = F_SERIF) {
    ctx.font = `${weight} ${size}px ${family}`;
  }
  function textWidth(text, spacing = 0) {
    let total = 0;
    for (const ch of text) total += ctx.measureText(ch).width + spacing;
    return total - (text.length ? spacing : 0);
  }
  function trackedText(text, x, y, spacing = 0, align = 'center', fill = '#fff', alpha = 1) {
    const total = textWidth(text, spacing);
    let cursor = align === 'center' ? x - total / 2 : align === 'right' ? x - total : x;
    ctx.save();
    ctx.globalAlpha *= alpha;
    ctx.fillStyle = fill;
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    for (const ch of text) {
      const cw = ctx.measureText(ch).width;
      if (ch !== ' ') ctx.fillText(ch, cursor, y);
      cursor += cw + spacing;
    }
    ctx.restore();
    return total;
  }
  function plainText(text, x, y, fill, alpha = 1, align = 'center') {
    ctx.save();
    ctx.globalAlpha *= alpha;
    ctx.fillStyle = fill;
    ctx.textAlign = align;
    ctx.textBaseline = 'middle';
    ctx.fillText(text, x, y);
    ctx.restore();
  }

  // ---------- scene / lyric data ----------
  const SCENES = [
    { start: 0,     end: 35.50, num: '01', zh: '深冬 · 苏醒', en: 'WINTER AWAKENING' },
    { start: 35.50, end: 53.82, num: '02', zh: '未来', en: 'THE FUTURE' },
    { start: 53.82, end: 64.29, num: '03', zh: '车窗外', en: 'OUTSIDE THE WINDOW' },
    { start: 64.29, end: 74.73, num: '04', zh: '擦肩', en: 'PASSING BY' },
    { start: 74.73, end: 90.38, num: '05', zh: '人海', en: 'THE CROWD' },
    { start: 90.38, end: 96.32, num: '06', zh: '号码牌', en: 'THE NUMBER' },
    { start: 96.32, end: 116.00, num: '07', zh: '遇见', en: 'ENCOUNTER' }
  ];
  const SCENE_STARTS = SCENES.map(s => s.start);

  // 精确人声时间轴。每句 0.7s 淡入;句间保留 0.5s 淡出/淡入重叠。
  const LYRICS = [
    { start: 24.76, end: 30.56, text: '听见 冬天的离开' },
    { start: 30.06, end: 36.00, text: '我在某年某月 醒过来' },
    { start: 35.50, end: 40.85, text: '我想 我等 我期待' },
    { start: 40.35, end: 47.20, text: '未来却不能因此安排' },
    { start: 53.82, end: 59.47, text: '阴天 傍晚 车窗外' },
    { start: 58.97, end: 64.79, text: '未来有一个人在等待' },
    { start: 64.29, end: 70.04, text: '向左 向右 向前看' },
    { start: 69.54, end: 75.23, text: '爱要拐几个弯才来' },
    { start: 74.73, end: 80.48, text: '我遇见谁 会有怎样的对白' },
    { start: 79.98, end: 85.60, text: '我等的人 他在多远的未来' },
    { start: 85.10, end: 90.88, text: '我听见风来自地铁和人海' },
    { start: 90.38, end: 97.20, fadeOutStart: 96.32, text: '我排着队 拿着爱的号码牌' }
  ];

  // ---------- precomputed scenery ----------
  function makeSkyline(seed, count, minW, maxW, minH, maxH) {
    const out = [];
    let x = -120;
    for (let i = 0; i < count; i++) {
      const w = minW + hash(i + seed * 19, 1.7) * (maxW - minW);
      const h = minH + hash(i + seed * 23, 4.3) * (maxH - minH);
      const style = Math.floor(hash(i + seed * 29, 8.1) * 4);
      out.push({ x, w, h, style, seed: seed * 100 + i });
      x += w + 2 + hash(i + seed * 31, 2.4) * 9;
    }
    return out;
  }

  const cityLayers = [
    { base: 648, color: '#101b2a', alpha: 0.95, buildings: makeSkyline(1, 35, 42, 92, 60, 185), windows: 0.0 },
    { base: 690, color: '#0b1622', alpha: 1.0, buildings: makeSkyline(2, 30, 58, 122, 95, 245), windows: 0.42 },
    { base: 758, color: '#070d15', alpha: 1.0, buildings: makeSkyline(3, 24, 82, 168, 75, 195), windows: 0.16 }
  ];

  const platformBuildings = makeSkyline(8, 30, 60, 134, 85, 230);

  function makeCityWindows(layers) {
    const out = [];
    for (const layer of layers) {
      for (const b of layer.buildings) {
        const top = layer.base - b.h;
        const cols = Math.max(2, Math.floor(b.w / 15));
        const rows = Math.max(2, Math.floor(b.h / 19));
        for (let r = 0; r < rows; r++) {
          for (let c = 0; c < cols; c++) {
            const q = hash(b.seed * 97 + r * 43 + c * 7, 6.2);
            if (q > 0.52) continue;
            const wx = b.x + 8 + c * (b.w - 16) / Math.max(1, cols - 1);
            const wy = top + 11 + r * (b.h - 18) / Math.max(1, rows - 1);
            out.push({
              x: wx, y: wy,
              w: 2.2 + q * 2.6,
              h: 3.2 + hash(r + c, 2.2) * 4.2,
              phase: q * 50,
              speed: 0.45 + hash(b.seed + r, 4.4) * 1.4,
              lit: 0.28 + hash(b.seed + c, 3.3) * 0.72
            });
          }
        }
      }
    }
    return out;
  }

  const nightCityLayers = [
    { base: 676, color: '#111d31', alpha: 0.72, buildings: makeSkyline(21, 34, 48, 108, 75, 235), winAlpha: 0.68 },
    { base: 726, color: '#091523', alpha: 0.92, buildings: makeSkyline(22, 30, 62, 136, 105, 305), winAlpha: 0.9 },
    { base: 806, color: '#040911', alpha: 1.0, buildings: makeSkyline(23, 24, 88, 180, 100, 255), winAlpha: 0.58 }
  ];
  const nightWindows = makeCityWindows(nightCityLayers);

  // ---------- atmosphere pattern ----------
  let grainPattern = null;
  function buildGrain() {
    const g = document.createElement('canvas');
    g.width = 256;
    g.height = 256;
    const gc = g.getContext('2d');
    const img = gc.createImageData(256, 256);
    for (let i = 0; i < 256 * 256; i++) {
      const v = Math.floor(72 + hash(i, 13.1) * 184);
      const a = Math.floor(hash(i, 9.7) * 34);
      img.data[i * 4] = v;
      img.data[i * 4 + 1] = v;
      img.data[i * 4 + 2] = v;
      img.data[i * 4 + 3] = a;
    }
    gc.putImageData(img, 0, 0);
    grainPattern = ctx.createPattern(g, 'repeat');
  }
  buildGrain();

  // ============================================================
  //  Shared drawing pieces
  // ============================================================

  function drawRain(t, amount = 210, alpha = 0.2) {
    ctx.save();
    ctx.lineCap = 'round';
    for (let i = 0; i < amount; i++) {
      const speed = 460 + hash(i, 22.1) * 420;
      const x0 = hash(i, 3.6) * (W + 220) - 110;
      const y0 = hash(i, 8.2) * (H + 120) - 60;
      const x = mod(x0 + t * 46 - y0 * 0.03, W + 220) - 110;
      const y = mod(y0 + t * speed, H + 120) - 60;
      const len = 8 + hash(i, 14.4) * 23;
      const a = (0.12 + hash(i, 18.1) * 0.28) * alpha;
      ctx.strokeStyle = `rgba(181,208,221,${a})`;
      ctx.lineWidth = 0.7 + hash(i, 10.3) * 1.1;
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(x - len * 0.22, y + len);
      ctx.stroke();
    }
    ctx.restore();
  }

  function drawSnow(t, amount = 55, alpha = 0.22) {
    ctx.save();
    for (let i = 0; i < amount; i++) {
      const speed = 15 + hash(i, 32.2) * 28;
      const x = mod(hash(i, 4.8) * W + Math.sin(t * 0.45 + i) * 24 + t * 7, W);
      const y = mod(hash(i, 6.2) * H + t * speed, H);
      const r = 0.7 + hash(i, 1.9) * 2.1;
      ctx.fillStyle = `rgba(210,232,238,${(0.08 + hash(i, 9.1) * 0.24) * alpha})`;
      ctx.beginPath();
      ctx.arc(x, y, r, 0, TAU);
      ctx.fill();
    }
    ctx.restore();
  }

  function drawDust(t, amount = 42, color = 'rgba(242,201,151,', alpha = 0.18) {
    ctx.save();
    for (let i = 0; i < amount; i++) {
      const x = mod(hash(i, 17.2) * W + Math.sin(t * 0.25 + i * 1.7) * 40, W);
      const y = mod(hash(i, 25.5) * H - t * (3 + hash(i, 4.1) * 8), H);
      const r = 0.7 + hash(i, 7.7) * 2.2;
      ctx.fillStyle = color + ((0.05 + hash(i, 3.3) * 0.22) * alpha) + ')';
      ctx.beginPath();
      ctx.arc(x, y, r, 0, TAU);
      ctx.fill();
    }
    ctx.restore();
  }

  function drawLamp(x, ground, top, warm = true) {
    ctx.save();
    ctx.strokeStyle = '#05090d';
    ctx.lineWidth = 7;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(x, ground);
    ctx.lineTo(x, top + 20);
    ctx.quadraticCurveTo(x, top, x + 35, top);
    ctx.stroke();
    ctx.fillStyle = warm ? '#ffd89d' : '#c4e3e8';
    ctx.beginPath();
    ctx.arc(x + 40, top + 2, 6, 0, TAU);
    ctx.fill();

    ctx.save();
    ctx.globalCompositeOperation = 'lighter';
    radialGlow(x + 40, top + 2, 74, [
      [0, warm ? 'rgba(255,221,164,0.70)' : 'rgba(207,245,250,0.6)'],
      [0.18, warm ? 'rgba(255,193,114,0.26)' : 'rgba(125,215,229,0.18)'],
      [1, 'rgba(0,0,0,0)']
    ]);
    ctx.restore();

    // soft light pool / wet reflection
    ctx.save();
    ctx.translate(x + 40, ground - 2);
    ctx.scale(1, 0.18);
    radialGlow(0, 0, 165, [
      [0, warm ? 'rgba(255,197,124,0.20)' : 'rgba(147,221,233,0.16)'],
      [1, 'rgba(0,0,0,0)']
    ]);
    ctx.restore();
    ctx.fillStyle = warm ? 'rgba(255,185,105,0.08)' : 'rgba(144,217,232,0.07)';
    ctx.fillRect(x + 34, top + 8, 13, ground - top - 8);
    ctx.restore();
  }

  function drawTree(x, ground, h, color) {
    ctx.save();
    ctx.fillStyle = color;
    ctx.fillRect(x - 2, ground - h * 0.6, 4, h * 0.6);
    ctx.beginPath();
    ctx.ellipse(x, ground - h * 0.7, h * 0.24, h * 0.32, 0, 0, TAU);
    ctx.ellipse(x - h * 0.14, ground - h * 0.57, h * 0.17, h * 0.22, 0, 0, TAU);
    ctx.ellipse(x + h * 0.16, ground - h * 0.58, h * 0.17, h * 0.21, 0, 0, TAU);
    ctx.fill();
    ctx.restore();
  }

  function drawWindowRows(layer, base, t, litFactor = 0.4, warm = '255,201,137') {
    ctx.save();
    for (const b of layer.buildings) {
      const top = base - b.h;
      const cols = Math.max(2, Math.floor(b.w / 18));
      const rows = Math.max(2, Math.floor(b.h / 22));
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          const q = hash(b.seed * 41 + r * 17 + c * 31, 5.5);
          if (q > litFactor) continue;
          const wx = b.x + 9 + c * (b.w - 18) / Math.max(1, cols - 1);
          const wy = top + 12 + r * (b.h - 20) / Math.max(1, rows - 1);
          const fl = 0.55 + 0.45 * Math.sin(t * (0.5 + q * 1.6) + q * 31);
          ctx.fillStyle = `rgba(${warm},${0.18 + fl * 0.36})`;
          ctx.fillRect(wx, wy, 2.5, 4.5);
        }
      }
    }
    ctx.restore();
  }

  function drawSkyline(layer, t, windows = true) {
    ctx.save();
    for (const b of layer.buildings) {
      const top = layer.base - b.h;
      ctx.fillStyle = layer.color;
      ctx.globalAlpha = layer.alpha;
      ctx.fillRect(b.x, top, b.w, b.h + 25);
      if (b.style === 0) {
        ctx.fillRect(b.x + b.w * 0.32, top - 15, b.w * 0.36, 15);
      } else if (b.style === 1) {
        ctx.fillRect(b.x + b.w * 0.72, top - 30, 3, 30);
        ctx.fillStyle = 'rgba(255,95,77,0.55)';
        ctx.fillRect(b.x + b.w * 0.72 - 1, top - 33, 5, 4);
        ctx.fillStyle = layer.color;
      } else if (b.style === 2) {
        ctx.fillRect(b.x + b.w * 0.18, top - 9, b.w * 0.64, 9);
      }
      if (windows && layer.windows > 0) {
        const cols = Math.max(2, Math.floor(b.w / 16));
        const rows = Math.max(2, Math.floor(b.h / 20));
        for (let r = 0; r < rows; r++) {
          for (let c = 0; c < cols; c++) {
            const q = hash(b.seed * 31 + r * 13 + c * 7, 7.2);
            if (q > layer.windows) continue;
            const fl = 0.7 + 0.3 * Math.sin(t * (0.6 + q * 1.2) + q * 20);
            ctx.fillStyle = `rgba(255,198,126,${0.22 + fl * 0.25})`;
            ctx.fillRect(b.x + 10 + c * (b.w - 20) / Math.max(1, cols - 1), top + 12 + r * (b.h - 18) / Math.max(1, rows - 1), 2, 3.5);
          }
        }
      }
    }
    ctx.restore();
  }

  // A side-view silhouette. Origin is at the feet.
  function sidePersonPath(height, opts = {}) {
    const s = height / 220;
    const coat = opts.coat || '#0b0d10';
    const stride = opts.walking ? Math.sin(opts.phase || 0) * 19 : 2;
    const bob = opts.walking ? Math.abs(Math.sin(opts.phase || 0)) * 2.6 : 0;
    ctx.save();
    ctx.translate(0, -bob);
    ctx.fillStyle = coat;
    ctx.strokeStyle = coat;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    // legs
    ctx.lineWidth = 13 * s;
    ctx.beginPath();
    ctx.moveTo(-5 * s, -92 * s);
    ctx.lineTo(-7 * s + stride * s, -45 * s);
    ctx.lineTo(-11 * s + stride * 1.3 * s, 0);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(5 * s, -92 * s);
    ctx.lineTo(7 * s - stride * s, -45 * s);
    ctx.lineTo(11 * s - stride * 1.3 * s, 0);
    ctx.stroke();

    // coat / torso
    ctx.beginPath();
    ctx.moveTo(-18 * s, -166 * s);
    ctx.quadraticCurveTo(0, -176 * s, 18 * s, -166 * s);
    ctx.lineTo(27 * s, -92 * s);
    ctx.quadraticCurveTo(26 * s, -68 * s, 15 * s, -61 * s);
    ctx.lineTo(6 * s, -76 * s);
    ctx.lineTo(-2 * s, -58 * s);
    ctx.lineTo(-10 * s, -77 * s);
    ctx.lineTo(-21 * s, -64 * s);
    ctx.quadraticCurveTo(-27 * s, -91 * s, -24 * s, -119 * s);
    ctx.closePath();
    ctx.fill();

    // neck + head, rotating very slightly around the neck for a quiet tilt
    ctx.save();
    ctx.translate(0, -183 * s);
    ctx.rotate(opts.headTilt || 0);
    ctx.translate(0, 183 * s);
    ctx.fillRect(-4 * s, -183 * s, 8 * s, 18 * s);
    ctx.beginPath();
    ctx.arc(0, -196 * s, 14.5 * s, 0, TAU);
    ctx.fill();
    // profile nose, facing right before direction transform
    ctx.beginPath();
    ctx.moveTo(12 * s, -201 * s);
    ctx.lineTo(19 * s, -196 * s);
    ctx.lineTo(12 * s, -192 * s);
    ctx.closePath();
    ctx.fill();

    if (opts.hair === 'bun') {
      ctx.beginPath();
      ctx.arc(-11 * s, -205 * s, 8 * s, 0, TAU);
      ctx.fill();
    } else if (opts.hair === 'short') {
      ctx.beginPath();
      ctx.ellipse(-2 * s, -208 * s, 14 * s, 8 * s, -0.2, 0, TAU);
      ctx.fill();
    }

    ctx.strokeStyle = rgba(opts.rim || '#ffc889', opts.rimAlpha == null ? 0.44 : opts.rimAlpha);
    ctx.lineWidth = 1.7 * s;
    ctx.beginPath();
    ctx.arc(0, -196 * s, 14.1 * s, Math.PI * 1.03, Math.PI * 1.64);
    ctx.stroke();
    ctx.restore();

    // arm
    const armSwing = opts.walking ? Math.sin((opts.phase || 0) + Math.PI) * 12 : 0;
    ctx.lineWidth = 9 * s;
    ctx.beginPath();
    ctx.moveTo(-3 * s, -157 * s);
    ctx.quadraticCurveTo(14 * s, -137 * s, 9 * s + armSwing * s, -108 * s);
    ctx.stroke();

    if (opts.bag) {
      ctx.fillStyle = opts.bagColor || '#17191c';
      fillRoundRect(-36 * s, -112 * s, 25 * s, 28 * s, 4 * s, opts.bagColor || '#17191c');
      ctx.strokeStyle = rgba(opts.rim || '#f0b477', 0.45);
      ctx.lineWidth = 1.1 * s;
      ctx.beginPath();
      ctx.moveTo(-33 * s, -111 * s);
      ctx.quadraticCurveTo(-22 * s, -132 * s, -12 * s, -112 * s);
      ctx.stroke();
    }

    if (opts.scarf) {
      ctx.fillStyle = opts.scarfColor || '#b45b48';
      ctx.beginPath();
      ctx.moveTo(-9 * s, -178 * s);
      ctx.quadraticCurveTo(4 * s, -170 * s, 13 * s, -178 * s);
      ctx.lineTo(12 * s, -165 * s);
      ctx.quadraticCurveTo(0, -158 * s, -11 * s, -167 * s);
      ctx.closePath();
      ctx.fill();
      const flap = opts.walking ? Math.sin((opts.phase || 0) * 0.9) * 8 : 0;
      ctx.beginPath();
      ctx.moveTo(-9 * s, -172 * s);
      ctx.bezierCurveTo(-30 * s, -167 * s + flap, -42 * s, -154 * s, -54 * s, -161 * s + flap);
      ctx.bezierCurveTo(-40 * s, -143 * s, -27 * s, -150 * s, -10 * s, -158 * s);
      ctx.closePath();
      ctx.fill();
    }

    // rim highlights
    ctx.strokeStyle = rgba(opts.rim || '#ffc889', opts.rimAlpha == null ? 0.44 : opts.rimAlpha);
    ctx.lineWidth = 1.7 * s;
    ctx.beginPath();
    ctx.moveTo(-17 * s, -164 * s);
    ctx.quadraticCurveTo(-27 * s, -137 * s, -24 * s, -110 * s);
    ctx.stroke();

    ctx.restore();
  }

  function drawSidePerson(x, ground, height, dir, phase, opts = {}) {
    const s = height / 220;
    // ground shadow
    ctx.save();
    ctx.fillStyle = 'rgba(0,0,0,0.25)';
    ctx.beginPath();
    ctx.ellipse(x + 4 * s, ground + 2, 31 * s, 5.5 * s, 0, 0, TAU);
    ctx.fill();
    ctx.restore();

    // reflection
    if (opts.reflection !== false) {
      ctx.save();
      ctx.globalAlpha *= (opts.reflectionAlpha == null ? 0.09 : opts.reflectionAlpha);
      ctx.translate(x, ground + 3);
      ctx.scale(dir * s, -0.34 * s);
      ctx.translate(0, -2);
      sidePersonPath(220, { ...opts, walking: false });
      ctx.restore();
    }

    ctx.save();
    ctx.translate(x, ground);
    ctx.scale(dir * s, s);
    sidePersonPath(220, { ...opts, walking: opts.walking, phase });
    ctx.restore();
  }

  function drawCrowdFigure(x, ground, h, phase, color, alpha = 1) {
    const s = h / 120;
    const stride = Math.sin(phase) * 11 * s;
    const bob = Math.abs(Math.sin(phase)) * 3 * s;
    ctx.save();
    ctx.globalAlpha *= alpha;
    ctx.translate(x, ground - bob);
    ctx.fillStyle = color;
    ctx.strokeStyle = color;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    ctx.lineWidth = 8 * s;
    ctx.beginPath();
    ctx.moveTo(-5 * s, -42 * s);
    ctx.lineTo(-7 * s + stride, 0);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(5 * s, -42 * s);
    ctx.lineTo(7 * s - stride, 0);
    ctx.stroke();

    ctx.beginPath();
    ctx.moveTo(-16 * s, -98 * s);
    ctx.quadraticCurveTo(0, -108 * s, 16 * s, -98 * s);
    ctx.lineTo(21 * s, -40 * s);
    ctx.quadraticCurveTo(0, -31 * s, -21 * s, -40 * s);
    ctx.closePath();
    ctx.fill();

    ctx.beginPath();
    ctx.arc(0, -112 * s, 9.5 * s, 0, TAU);
    ctx.fill();
    ctx.restore();
  }

  // ============================================================
  //  01 · 雨中的城  0–12 s
  // ============================================================
  function drawCityRain(lt) {
    const t = Math.max(0, lt);
    const dawn = smoothstep(7.8, 12, t);

    const sky = ctx.createLinearGradient(0, 0, 0, 760);
    sky.addColorStop(0, '#040914');
    sky.addColorStop(0.46, '#09131f');
    sky.addColorStop(0.8, '#14222d');
    sky.addColorStop(1, '#28333a');
    ctx.fillStyle = sky;
    ctx.fillRect(0, 0, W, H);

    // distant dawn warming through the rain
    if (dawn > 0) {
      radialGlow(1110, 625, 580, [
        [0, `rgba(205,133,84,${0.22 * dawn})`],
        [0.42, `rgba(161,100,70,${0.12 * dawn})`],
        [1, 'rgba(0,0,0,0)']
      ]);
    }

    // moon behind cloud
    ctx.save();
    ctx.globalAlpha = 0.5;
    radialGlow(1280, 180, 150, [
      [0, 'rgba(206,224,231,0.18)'],
      [0.22, 'rgba(170,192,204,0.09)'],
      [1, 'rgba(0,0,0,0)']
    ]);
    ctx.fillStyle = 'rgba(215,230,234,0.23)';
    ctx.beginPath();
    ctx.arc(1280, 180, 38, 0, TAU);
    ctx.fill();
    ctx.restore();

    // slow cloud bands
    for (let i = 0; i < 7; i++) {
      const x = mod(hash(i, 4.7) * W + t * (3 + i * 0.8), W + 500) - 250;
      const y = 90 + hash(i, 8.2) * 420;
      const w = 260 + hash(i, 2.9) * 480;
      ctx.fillStyle = `rgba(151,176,190,${0.025 + hash(i, 5.3) * 0.032})`;
      ctx.beginPath();
      ctx.ellipse(x, y, w, 25 + i * 4, -0.03, 0, TAU);
      ctx.fill();
    }

    // skyline
    for (const layer of cityLayers) drawSkyline(layer, t, true);

    // thin haze at the base of buildings
    const haze = ctx.createLinearGradient(0, 585, 0, 765);
    haze.addColorStop(0, 'rgba(136,161,174,0)');
    haze.addColorStop(0.45, 'rgba(126,151,164,0.09)');
    haze.addColorStop(1, 'rgba(18,27,34,0)');
    ctx.fillStyle = haze;
    ctx.fillRect(0, 570, W, 220);

    // wet street
    const road = ctx.createLinearGradient(0, 716, 0, 900);
    road.addColorStop(0, '#0a1119');
    road.addColorStop(0.38, '#080d13');
    road.addColorStop(1, '#05080c');
    ctx.fillStyle = road;
    ctx.fillRect(0, 716, W, 184);
    ctx.fillStyle = 'rgba(190,211,217,0.16)';
    ctx.fillRect(0, 716, W, 1);

    // street lamps
    const lampXs = [155, 468, 842, 1224, 1508];
    for (let i = 0; i < lampXs.length; i++) drawLamp(lampXs[i], 742, 554 - (i % 2) * 12);

    // long wet reflections
    for (let i = 0; i < lampXs.length; i++) {
      const x = lampXs[i] + 40;
      const fl = 0.85 + 0.15 * Math.sin(t * 1.2 + i * 1.9);
      const rg = ctx.createLinearGradient(0, 742, 0, 882);
      rg.addColorStop(0, `rgba(255,180,99,${0.17 * fl})`);
      rg.addColorStop(0.5, `rgba(239,148,76,${0.05 * fl})`);
      rg.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.fillStyle = rg;
      ctx.beginPath();
      ctx.moveTo(x - 10, 742);
      ctx.lineTo(x + 9, 742);
      ctx.lineTo(x + 34, 882);
      ctx.lineTo(x - 32, 882);
      ctx.closePath();
      ctx.fill();
    }

    // road markings
    ctx.save();
    ctx.strokeStyle = 'rgba(205,220,214,0.09)';
    ctx.lineWidth = 2;
    ctx.setLineDash([34, 46]);
    ctx.beginPath();
    ctx.moveTo(0, 850);
    ctx.lineTo(W, 842);
    ctx.stroke();
    ctx.restore();

    // a small umbrella walker
    const ux = 1150 + t * 9.5;
    const uy = 760;
    ctx.save();
    ctx.fillStyle = 'rgba(4,7,10,0.95)';
    ctx.beginPath();
    ctx.arc(ux, uy - 54, 7, 0, TAU);
    ctx.fill();
    ctx.beginPath();
    ctx.moveTo(ux - 6, uy - 46);
    ctx.lineTo(ux + 7, uy - 46);
    ctx.lineTo(ux + 12, uy - 10);
    ctx.lineTo(ux - 10, uy - 10);
    ctx.closePath();
    ctx.fill();
    ctx.strokeStyle = 'rgba(5,8,11,0.95)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(ux + 1, uy - 58);
    ctx.lineTo(ux + 1, uy - 82);
    ctx.stroke();
    ctx.begin