/* ========================================================================== * render.js — 单帧合成:世界 → 晶体 → 章节层(交叉溶解)→ 章节标题 → HUD → 字幕 → 后期 * 纯函数:render(ctx, t, L, opts) 在任意时刻得到确定画面(预览与导出共用) * ========================================================================== */ (() => { const { C, E } = K; const W = window.WORLD; const TL = K.TL; function helper(ch, L, t) { const at = (n) => (n == null ? ch.start : TL.mark(ch.id, n)); return { at, m: (n) => t - at(n), lf: (a, b, i = 0.6, o = 0.5) => K.life(t, at(a), b == null ? Infinity : at(b), i, o), bx: (lx, ly, px, py) => { const B = L.box; return L.portrait ? [B.x + B.w * px, B.y + B.h * py] : [B.x + B.w * lx, B.y + B.h * ly]; }, }; } /* ---------- 后期素材 ---------- */ let grain = null; function grainCanvas() { if (grain) return grain; const c = document.createElement('canvas'); c.width = c.height = 256; const g = c.getContext('2d'); const id = g.createImageData(256, 256); const r = K.rng(9); for (let i = 0; i < id.data.length; i += 4) { const v = 110 + r() * 40; id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 255; } g.putImageData(id, 0, 0); grain = c; return c; } /* ---------- 章节标题(跃迁时出现) ---------- */ function chapterCard(ctx, L, ch, t) { if (ch.index === 0) return; const lt = t - ch.start; const first = ch.lines[0].start - ch.start; const k = K.life(lt, -0.4, first - 0.35, 0.7, 0.5); if (k <= 0) return; const x = L.portrait ? L.cx : L.W / 2, y = L.portrait ? L.cy - 40 : L.cy; const pal = W.palette(t); ctx.save(); ctx.globalAlpha *= k; const yr = ch.year; const s = L.portrait ? 210 : 260; const rise = (1 - E.out5((lt + 0.4) / 1.1)) * 60; K.text(ctx, yr, x, y - 30 + rise, { size: s, fam: 'num', weight: 700, color: K.rgba(C.white, 0.95), glow: pal[0], glowR: 60, ls: 6 + 20 * (1 - k) }); K.text(ctx, String(ch.index).padStart(2, '0') + ' ' + ch.title, x, y + s * 0.5 + 10, { size: L.portrait ? 54 : 60, fam: 'title', weight: 400, color: pal[0], ls: 10 }); K.neon(ctx, [[x - 220 * k, y + s * 0.5 + 64], [x + 220 * k, y + s * 0.5 + 64]], pal[1], 2, 0.9); ctx.restore(); } /* ---------- HUD ---------- */ function hud(ctx, L, t, ch) { const pal = W.palette(t); const P = L.portrait; const fadeIn = K.clamp((t - 6) / 1.5); if (fadeIn <= 0) return; ctx.save(); ctx.globalAlpha *= fadeIn; const bx = P ? 60 : 60, by = P ? 256 : 58; K.glow(ctx, bx + 14, by, 40, C.eth, 0.6); K.neon(ctx, [[bx + 14, by - 20], [bx + 26, by], [bx + 14, by + 7], [bx + 2, by], [bx + 14, by - 20]], C.eth2, 1.5, 1); K.neon(ctx, [[bx + 2, by + 4], [bx + 14, by + 21], [bx + 26, by + 4]], C.eth2, 1.5, 1); K.text(ctx, '以太坊简史', bx + 48, by + 2, { size: 30, fam: 'title', weight: 400, align: 'left', color: C.white }); K.text(ctx, String(ch.index).padStart(2, '0') + ' / ' + String(TL.chapters.length - 1).padStart(2, '0') + ' ' + ch.title, bx + 4, by + 46, { size: 24, align: 'left', color: K.rgba(C.white, 0.65), weight: 500 }); // 年份(滚动) const yx = P ? L.W - 60 : L.W - 60, yy = P ? 270 : 70; const cur = ch.year, prev = ch.index > 0 ? TL.chapters[ch.index - 1].year : cur; const roll = E.io3((t - ch.start + 0.6) / 1.2); ctx.save(); ctx.beginPath(); ctx.rect(yx - 260, yy - 40, 270, 80); ctx.clip(); if (roll < 1 && prev !== cur) K.text(ctx, prev, yx, yy - roll * 70, { size: 64, fam: 'num', align: 'right', color: K.rgba(C.white, 0.9 * (1 - roll)) }); K.text(ctx, cur, yx, yy + (1 - roll) * 70, { size: 64, fam: 'num', align: 'right', color: C.white, glow: pal[0], glowR: 24 }); ctx.restore(); // 时间线 const x0 = P ? 60 : 120, x1 = P ? 1020 : 1800, ly = P ? 360 : 1052; ctx.strokeStyle = 'rgba(255,255,255,.12)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x0, ly); ctx.lineTo(x1, ly); ctx.stroke(); const prog = t / TL.total; K.neon(ctx, [[x0, ly], [x0 + (x1 - x0) * prog, ly]], pal[0], 1.4, 0.9); TL.chapters.forEach((c, i) => { const px = x0 + (x1 - x0) * c.start / TL.total; const on = t >= c.start; ctx.fillStyle = on ? K.rgba(pal[1], 0.95) : 'rgba(255,255,255,.3)'; ctx.beginPath(); ctx.arc(px, ly, on ? 4 : 3, 0, K.TAU); ctx.fill(); }); K.glow(ctx, x0 + (x1 - x0) * prog, ly, 26, C.white, 0.9); ctx.restore(); } /* ---------- 卡拉 OK 字幕 ---------- */ function subtitles(ctx, L, t) { const ln = TL.lineAt(t); if (!ln) return; const a = K.clamp((t - ln.start + 0.1) / 0.2) * K.clamp((ln.end + 0.45 - t) / 0.3); if (a <= 0) return; const S = L.sub; const lt = t - ln.start; const text = ln.text; // 每个字符的状态 const st = new Array(text.length).fill(0); // 0 未读 1 正在读 2 已读 let last = 0; for (const [c0, c1, t0, t1] of ln.words) { const s = lt >= t1 ? 2 : lt >= t0 ? 1 : 0; for (let i = c0; i < c1; i++) st[i] = s; } for (let i = 0; i < text.length; i++) { if (ln.words.some((w) => i >= w[0] && i < w[1])) last = st[i]; else st[i] = last === 1 ? 2 : last; } if (lt > ln.dur - 0.05) st.fill(2); ctx.save(); ctx.globalAlpha *= a; ctx.font = K.font('body', S.size, 700); // 断行 const rows = []; let row = '', rs = 0, start = 0; for (let i = 0; i < text.length; i++) { const ch = text[i]; const w = ctx.measureText(row + ch).width; if (w > S.maxW && row && !/[,。!?、:;”),.!?]/.test(ch)) { // 不在英文单词/数字中间断行 let cut = row.length; if (/[A-Za-z0-9.\-]/.test(ch)) { let j = row.length; while (j > 0 && /[A-Za-z0-9.\-]/.test(row[j - 1])) j--; if (j > 0) cut = j; } rows.push([row.slice(0, cut), start]); start = start + cut; row = row.slice(cut) + ch; } else row += ch; } rows.push([row, start]); if (rows.length === 2) { // 两行均衡 const half = Math.ceil(text.length / 2); let cut = half; for (let d = 0; d < 6; d++) { if (/[,。:;、]/.test(text[half + d - 1] || '')) { cut = half + d; break; } if (/[,。:;、]/.test(text[half - d - 1] || '')) { cut = half - d; break; } } const r1 = text.slice(0, cut), r2 = text.slice(cut); const midWord = /[A-Za-z0-9]/.test(text[cut - 1] || '') && /[A-Za-z0-9]/.test(text[cut] || ''); if (!midWord && ctx.measureText(r1).width <= S.maxW && ctx.measureText(r2).width <= S.maxW) { rows.length = 0; rows.push([r1, 0], [r2, cut]); } } const lh = S.size * 1.4; const y0 = S.y - (L.portrait ? 0 : (rows.length - 1) * lh / 2); // 底衬 const bandH = rows.length * lh + 40; const g = ctx.createLinearGradient(0, y0 - lh / 2 - 20, 0, y0 - lh / 2 - 20 + bandH); g.addColorStop(0, 'rgba(5,6,15,0)'); g.addColorStop(0.3, 'rgba(5,6,15,.55)'); g.addColorStop(0.7, 'rgba(5,6,15,.55)'); g.addColorStop(1, 'rgba(5,6,15,0)'); ctx.fillStyle = g; ctx.fillRect(0, y0 - lh / 2 - 20, L.W, bandH); ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; rows.forEach(([r, s0], ri) => { const w = ctx.measureText(r).width; let x = S.cx - w / 2; const y = y0 + ri * lh; for (let i = 0; i < r.length; i++) { const ch = r[i]; const state = st[s0 + i]; const cw = ctx.measureText(ch).width; ctx.lineWidth = S.size * 0.16; ctx.strokeStyle = 'rgba(5,6,15,.9)'; ctx.lineJoin = 'round'; ctx.strokeText(ch, x, y); if (state === 1) { ctx.shadowColor = C.cyan; ctx.shadowBlur = 18; ctx.fillStyle = '#C9F6FF'; } else { ctx.shadowBlur = 0; ctx.fillStyle = state === 2 ? '#FFFFFF' : 'rgba(255,255,255,.5)'; } ctx.fillText(ch, x, y); ctx.shadowBlur = 0; x += cw; } }); ctx.restore(); } function guides(ctx, L) { ctx.save(); if (L.safe) { const s = L.safe; ctx.fillStyle = 'rgba(255,40,90,.2)'; ctx.fillRect(0, 0, L.W, s.top); ctx.fillRect(0, s.bottom, L.W, L.H - s.bottom); ctx.fillRect(s.right, L.H * 0.45, L.W - s.right, s.bottom - L.H * 0.45); ctx.font = '28px sans-serif'; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText('状态栏 / 刘海 / 顶部导航', L.W / 2, s.top / 2); ctx.fillText('账号 / 文案 / 音乐 / 进度条', L.W / 2, s.bottom + 140); ctx.save(); ctx.translate(s.right + 70, L.H * 0.66); ctx.rotate(Math.PI / 2); ctx.fillText('点赞 · 评论 · 收藏 · 分享', 0, 0); ctx.restore(); } else { ctx.setLineDash([14, 10]); ctx.strokeStyle = 'rgba(57,228,255,.8)'; ctx.lineWidth = 2; ctx.strokeRect(L.W * 0.05, L.H * 0.05, L.W * 0.9, L.H * 0.9); } ctx.restore(); } /* ---------- 主渲染 ---------- */ function render(ctx, t, L, opts = {}) { t = K.clamp(t, 0, TL.total - 1e-3); const z = opts.scale || 1; ctx.setTransform(z, 0, 0, z, 0, 0); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; const ch = TL.chapterAt(t); // 序章开头:星空渐显 const intro = K.clamp(t / 1.5); W.background(ctx, L, t, { neb: 0.3 + 0.7 * intro * K.clamp((t - 1) / 3), stars: intro }); // 晶体 const pose = W.crystalPose(t, L); const co = { x: pose.x, y: pose.y + Math.sin(t * 0.9) * pose.s * 0.06, s: pose.s, ry: t * 0.55, rx: -0.2 + Math.sin(t * 0.3) * 0.08, alpha: 1, build: 1, fill: 1 }; const H = helper(ch, L, t); const mod = window.CHAPTERS[ch.id] && window.CHAPTERS[ch.id].crystal; if (mod) mod(co, t, H); // 跨章节:前一章的晶体修改在 1.2 秒内淡出 if (ch.index > 0) { const pc = TL.chapters[ch.index - 1]; const pm = window.CHAPTERS[pc.id] && window.CHAPTERS[pc.id].crystal; const k = K.clamp((t - ch.start) / 1.2); if (pm && k < 1) { const po = Object.assign({}, co); pm(po, t, helper(pc, L, t)); ['alpha', 'split', 'glow', 'x', 'y', 's'].forEach((f) => { if (typeof po[f] === 'number' && typeof co[f] === 'number') co[f] = K.lerp(po[f], co[f], E.io3(k)); }); if (po.tint && k < 0.5) co.tint = po.tint; } } W.crystal(ctx, co); // 章节层(与上一章交叉溶解) if (ch.index > 0 && t - ch.start < 0.9) { const pc = TL.chapters[ch.index - 1]; ctx.save(); ctx.globalAlpha = 1 - E.io3((t - ch.start) / 0.9); try { window.CHAPTERS[pc.id].draw(ctx, L, t, helper(pc, L, t)); } catch (e) { console.error(pc.id, e); } ctx.restore(); } ctx.save(); ctx.globalAlpha = ch.index > 0 ? K.clamp((t - ch.start) / 0.6) : 1; try { window.CHAPTERS[ch.id].draw(ctx, L, t, H); } catch (e) { console.error(ch.id, e); } ctx.restore(); chapterCard(ctx, L, ch, t); // 后期:暗角 + 颗粒 const vg = ctx.createRadialGradient(L.W / 2, L.H / 2, Math.min(L.W, L.H) * 0.35, L.W / 2, L.H / 2, Math.hypot(L.W, L.H) * 0.62); vg.addColorStop(0, 'rgba(5,6,15,0)'); vg.addColorStop(1, 'rgba(5,6,15,.55)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, L.W, L.H); ctx.save(); ctx.globalAlpha = 0.05; ctx.globalCompositeOperation = 'overlay'; const gc = grainCanvas(); const ox = Math.floor(K.hash(Math.floor(t * 24)) * 256), oy = Math.floor(K.hash(Math.floor(t * 24) + 7) * 256); ctx.translate(-ox, -oy); ctx.fillStyle = ctx.createPattern(gc, 'repeat'); ctx.fillRect(ox, oy, L.W, L.H); ctx.restore(); hud(ctx, L, t, ch); subtitles(ctx, L, t); // 片尾渐黑 const endK = K.clamp((t - (TL.total - 1.2)) / 1.2); if (endK > 0) { ctx.fillStyle = `rgba(5,6,15,${endK})`; ctx.fillRect(0, 0, L.W, L.H); } if (opts.guides) guides(ctx, L); } /* ---------- 封面 ---------- */ function renderCover(ctx, L, z = 1) { ctx.setTransform(z, 0, 0, z, 0, 0); const t = 12.5; const P = L.portrait; W.background(ctx, L, TL.byId.origin.start - 0.2, {}); const cx = L.portrait ? L.W / 2 : L.W * 0.76, cy = L.portrait ? L.H * 0.475 : L.H * 0.48; const s = P ? 185 : 205; W.globe(ctx, { x: cx, y: cy, r: s * 2.2, t: 3, ry: 0.6, arcs: 18, alpha: 0.5, color: C.eth, color2: C.mag }); W.crystal(ctx, { x: cx, y: cy, s, ry: 0.65, rx: -0.2, alpha: 1, glow: 1.4 }); if (P) { K.text(ctx, '以太坊简史', L.W / 2, 330, { size: 150, fam: 'title', weight: 400, color: C.white, glow: C.eth, glowR: 50, ls: 6 }); K.text(ctx, '从一份白皮书,到世界计算机', L.W / 2, 450, { size: 50, color: K.rgba(C.white, 0.9), ls: 4 }); K.text(ctx, '2013 — 2026', L.W / 2, 1330, { size: 64, fam: 'num', color: C.eth2, ls: 14, glow: C.eth }); ['众筹 0.31 美元', 'The DAO 危机', '合并 · 能耗 -99.95%', 'ETF 时代'].forEach((s2, i) => { const x = 270 + (i % 2) * 540, y = 1450 + Math.floor(i / 2) * 110; K.glass(ctx, x - 240, y - 44, 480, 88, { r: 44 }); K.text(ctx, s2, x, y + 2, { size: 36, color: C.white }); }); } else { K.text(ctx, '以太坊简史', 120, 380, { size: 170, fam: 'title', weight: 400, color: C.white, align: 'left', glow: C.eth, glowR: 50, ls: 6 }); K.text(ctx, '从一份白皮书,到世界计算机', 128, 520, { size: 54, color: K.rgba(C.white, 0.9), align: 'left', ls: 4 }); K.text(ctx, '2013 — 2026', 128, 610, { size: 52, fam: 'num', color: C.eth2, align: 'left', ls: 14, glow: C.eth }); ['0.31 美元众筹', 'The DAO 危机', '合并 -99.95%', 'ETF 时代'].forEach((s2, i) => { const x = 128 + (i % 2) * 320, y = 700 + Math.floor(i / 2) * 104; K.glass(ctx, x, y, 300, 84, { r: 42 }); K.text(ctx, s2, x + 150, y + 43, { size: 32, color: C.white }); }); } const vg = ctx.createRadialGradient(L.W / 2, L.H / 2, Math.min(L.W, L.H) * 0.4, L.W / 2, L.H / 2, Math.hypot(L.W, L.H) * 0.65); vg.addColorStop(0, 'rgba(5,6,15,0)'); vg.addColorStop(1, 'rgba(5,6,15,.5)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, L.W, L.H); } window.RENDER = { render, renderCover }; })();