POLS / aries /src /stage.js
Ethscriptions's picture
aries: 渲染引擎与十幕场景、荣耀铭牌
a97ef0b verified
Raw History Blame Contribute Delete
6.07 kB
// ♈ 白羊座 —— 合成器:着色器背景 + 场景 + 转场 + 字幕 + 角标
import { TIMELINE } from './timeline.js';
import { ACTS } from './scenes.js';
import { createSky, TONES } from './gl.js';
import { clamp01, mix, span, E, rgba, SANS, SERIF, DISPLAY, bloom, say, wrap, ariesGlyph, gold, rr } from './kit.js';
const XF = .8;
const HEAT = { ember: 1, dawn: .45, night: .15, sea: .1, grove: .2, royal: .55 };
const STAR = { ember: .7, dawn: .8, night: 1.3, sea: 1, grove: .9, royal: 1 };
export function actIndex(t) { const a = TIMELINE.acts; for (let i = 0; i < a.length; i++) if (t < a[i].t1) return i; return a.length - 1; }
export function createStage() {
let sky = null;
try { sky = createSky(); } catch (e) { console.warn('WebGL2 不可用,使用 Canvas 背景', e); }
function background(ctx, t, W, H, i) {
const act = TIMELINE.acts[i], prev = TIMELINE.acts[Math.max(0, i - 1)];
const k = i === 0 ? 1 : E.sine(span(t, act.t0 - .2, act.t0 + 1.6));
const heat = mix(HEAT[prev.tone], HEAT[act.tone], k), stars = mix(STAR[prev.tone], STAR[act.tone], k);
if (sky) {
const cv = sky.render(t, W, H, prev.tone, act.tone, k, { heat, stars, scale: W * H > 1.5e6 ? .5 : .6 });
ctx.drawImage(cv, 0, 0, W, H);
} else {
const A = TONES[prev.tone], B = TONES[act.tone];
const c = j => [0, 1, 2].map(n => Math.round(mix(A[j][n], B[j][n], k) * 255));
const g = ctx.createRadialGradient(W / 2, H * .55, 0, W / 2, H * .55, Math.max(W, H) * .8);
g.addColorStop(0, rgba(c(1))); g.addColorStop(1, rgba(c(0))); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
}
function subtitles(ctx, t, W, H) {
const P = H > W, S = Math.min(W, H) / 1080;
let cur = null;
for (const a of TIMELINE.acts) for (const l of a.lines) if (t >= l.a - .08 && t < l.b + .15) cur = l;
if (!cur) return;
const al = Math.min(E.sine(span(t, cur.a - .08, cur.a + .18)), 1 - E.sine(span(t, cur.b - .02, cur.b + .15)));
const size = (P ? 50 : 44) * S;
const lines = wrap(ctx, cur.s, P ? W * .84 : W * .66, size);
const lh = size * 1.42, y0 = (P ? H * .875 : H * .895) - (lines.length - 1) * lh / 2;
ctx.save(); ctx.font = `600 ${size}px ${SANS}`;
const w = Math.max(...lines.map(l => ctx.measureText(l).width)) + 70 * S;
ctx.globalAlpha = al * .78;
const g = ctx.createLinearGradient(W / 2 - w / 2, 0, W / 2 + w / 2, 0);
g.addColorStop(0, 'rgba(20,4,6,0)'); g.addColorStop(.12, 'rgba(20,4,6,.85)'); g.addColorStop(.88, 'rgba(20,4,6,.85)'); g.addColorStop(1, 'rgba(20,4,6,0)');
ctx.fillStyle = g; ctx.fillRect(W / 2 - w / 2, y0 - lh * .66, w, lines.length * lh + lh * .3);
ctx.globalAlpha = al * .9; ctx.fillStyle = gold(ctx, W / 2 - w / 2, 0, W / 2 + w / 2, 0);
ctx.fillRect(W / 2 - w * .3, y0 - lh * .66, w * .6, 1.5 * S); ctx.fillRect(W / 2 - w * .3, y0 - lh * .66 + lines.length * lh + lh * .3, w * .6, 1.5 * S);
ctx.restore();
lines.forEach((l, i) => say(ctx, l, W / 2, y0 + i * lh, { size, font: SANS, weight: 600, fill: '#fff6e4', alpha: al, stroke: 'rgba(30,6,8,.85)', strokeW: size * .12 }));
}
function hud(ctx, t, W, H, i) {
const P = H > W, S = Math.min(W, H) / 1080, a = .9 * span(t, 1.5, 3);
if (a <= 0) return;
const x = (P ? 46 : 56) * S, y = (P ? 86 : 56) * S;
ariesGlyph(ctx, x + 16 * S, y + 2 * S, .17 * S, 1, { width: 16, blur: 8 });
say(ctx, '白羊座 · 春天从这里出发', x + 46 * S, y - 8 * S, { size: 24 * S, align: 'left', fill: '#ffdca0', alpha: a, font: SERIF });
const act = TIMELINE.acts[i]; const ka = Math.min(span(t, act.t0, act.t0 + .8), 1 - span(t, act.t1 - .8, act.t1));
say(ctx, `${String(i + 1).padStart(2, '0')} · ${act.title}`, x + 46 * S, y + 24 * S, { size: 19 * S, align: 'left', fill: 'rgba(255,236,214,.75)', alpha: a * ka, font: SANS, weight: 400, track: 1 });
// 顶部火焰进度线
const pw = W - 2 * x, py = (P ? 40 : 24) * S, k = t / TIMELINE.length;
ctx.save(); ctx.globalAlpha = a * .5; ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x, py, pw, 2 * S);
ctx.fillStyle = gold(ctx, x, 0, x + pw, 0); ctx.fillRect(x, py, pw * k, 2 * S); ctx.restore();
bloom(ctx, x + pw * k, py + S, 14 * S, [255, 170, 80], a);
}
function draw(ctx, t, W, H, opts = {}) {
t = Math.max(0, Math.min(TIMELINE.length, t));
const i = actIndex(t), act = TIMELINE.acts[i];
const S = Math.min(W, H) / 1080, P = H > W;
ctx.save();
background(ctx, t, W, H, i);
const run = (k, alpha) => {
if (alpha <= .002) return; const a = TIMELINE.acts[k];
const X = { S, P, dur: a.t1 - a.t0, opts, cue: n => (a.lines[n] ? a.lines[n].a - a.t0 : 0) };
ctx.save(); ctx.globalAlpha = alpha;
try { ACTS[a.id](ctx, Math.max(0, t - a.t0), W, H, X); } catch (e) { console.error(a.id, e); }
ctx.restore(); ctx.globalAlpha = 1;
};
const kin = i ? E.sine(span(t, act.t0, act.t0 + XF)) : 1;
if (i && kin < 1) run(i - 1, 1 - kin);
run(i, kin);
// 转场:横向镜头光晕扫过
const d = t - act.t0;
if (i && d > -.3 && d < 1.1) {
const k = clamp01((d + .3) / 1.4), x = mix(-W * .3, W * 1.3, E.sine(k)), al = Math.sin(k * Math.PI) * .6;
ctx.save(); ctx.globalCompositeOperation = 'lighter';
const g = ctx.createLinearGradient(x - 300 * S, 0, x + 300 * S, 0); g.addColorStop(0, 'rgba(255,170,80,0)'); g.addColorStop(.5, rgba([255, 190, 110], .35 * al)); g.addColorStop(1, 'rgba(255,170,80,0)');
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); ctx.restore();
bloom(ctx, x, H * .45, 240 * S, [255, 210, 150], al);
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.fillStyle = rgba([255, 230, 190], al * .8); ctx.fillRect(0, H * .45 - 1.5 * S, W, 3 * S); ctx.restore();
}
if (opts.subtitles !== false) subtitles(ctx, t, W, H);
if (opts.hud !== false) hud(ctx, t, W, H, i);
const fi = 1 - span(t, 0, 1.4); if (fi > 0) { ctx.fillStyle = rgba([3, 1, 2], fi); ctx.fillRect(0, 0, W, H); }
ctx.restore();
}
return { draw, hasGL: !!sky, sky };
}