POLS / ethereum /js /render.js
Ethscriptions's picture
E6-E8: 以太坊简史 DSP 配乐音效 + 播放器/导出 + 封面 + 发布文案
908a5b6 verified
Raw History Blame Contribute Delete
13.8 kB
/* ==========================================================================
* 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 };
})();