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