POLS / taurus /js /card.js
Ethscriptions's picture
taurus: 画面引擎、十幕动画、合成器(step 3/4)
c19dd43
Raw History Blame Contribute Delete
11.4 kB
// ♉ 金牛座 · 鎏金珍藏卡(视频第 9 幕 + 页面单独导出共用,全新编写)
import { TAU, clamp, lerp, seg, ease, h1, col, C, FONT, gilt, halo, sparkle, text, rrect, flower, leaf, bullHead, taurusSign, venusSign } from './canvas-kit.js';
const CW = 630, CH = 880, R = 36;
function archPath(g, x, y, w, h) { // 拱形窗
g.beginPath(); g.moveTo(x, y + h); g.lineTo(x, y + w / 2); g.arc(x + w / 2, y + w / 2, w / 2, Math.PI, 0); g.lineTo(x + w, y + h); g.closePath();
}
function back(g, t) {
rrect(g, -CW / 2, -CH / 2, CW, CH, R); const gr = g.createLinearGradient(0, -CH / 2, 0, CH / 2); gr.addColorStop(0, '#0f3b2e'); gr.addColorStop(1, '#071a14'); g.fillStyle = gr; g.fill();
g.lineWidth = 10; g.strokeStyle = gilt(g, -CW / 2, -CH / 2, CW / 2, CH / 2, 'rose'); g.stroke();
g.save(); rrect(g, -CW / 2 + 26, -CH / 2 + 26, CW - 52, CH - 52, 22); g.clip();
g.strokeStyle = col(C.gold, .22); g.lineWidth = 1.2;
for (let i = -20; i <= 20; i++) { g.beginPath(); g.moveTo(i * 40 - 400, -460); g.lineTo(i * 40 + 400, 460); g.stroke(); g.beginPath(); g.moveTo(i * 40 + 400, -460); g.lineTo(i * 40 - 400, 460); g.stroke(); }
g.restore();
rrect(g, -CW / 2 + 26, -CH / 2 + 26, CW - 52, CH - 52, 22); g.strokeStyle = gilt(g, -300, -400, 300, 400); g.lineWidth = 2.5; g.stroke();
g.fillStyle = '#0b2b21'; g.beginPath(); g.arc(0, -10, 150, 0, TAU); g.fill(); g.lineWidth = 6; g.strokeStyle = gilt(g, -150, -160, 150, 140); g.stroke();
g.lineWidth = 1.5; g.beginPath(); g.arc(0, -10, 130, 0, TAU); g.stroke();
for (let i = 0; i < 36; i++) { const a = i / 36 * TAU; g.fillStyle = col(C.gold, .8); g.beginPath(); g.arc(Math.cos(a) * 140, -10 + Math.sin(a) * 140, 2.5, 0, TAU); g.fill(); }
taurusSign(g, 0, -4, 1.25, 1, { width: 12 });
text(g, 'TAURUS', 0, 220, { size: 46, font: FONT.latin, weight: 700, gilt: 'gold', spacing: 14 });
text(g, '金牛座 · 鎏金珍藏', 0, 280, { size: 26, color: col(C.gold, .85), weight: 700, spacing: 6 });
void t;
}
function front(g, st) {
const t = st.t ?? 0, F = st.fields ?? 1;
// 外框
rrect(g, -CW / 2, -CH / 2, CW, CH, R); g.fillStyle = gilt(g, -CW / 2, -CH / 2, CW / 2, CH / 2, 'rose'); g.fill();
rrect(g, -CW / 2 + 16, -CH / 2 + 16, CW - 32, CH - 32, R - 12); const pg = g.createLinearGradient(0, -CH / 2, 0, CH / 2); pg.addColorStop(0, '#0c2c23'); pg.addColorStop(.6, '#081c17'); pg.addColorStop(1, '#130a12'); g.fillStyle = pg; g.fill();
g.lineWidth = 2; g.strokeStyle = gilt(g, -300, -400, 300, 400); g.stroke();
// 拱窗插画
const ax = -238, ay = -392, aw = 476, ah = 430;
g.save(); archPath(g, ax, ay, aw, ah); g.clip();
const sg = g.createLinearGradient(0, ay, 0, ay + ah); sg.addColorStop(0, '#2a1838'); sg.addColorStop(.55, '#7a3c58'); sg.addColorStop(1, '#f2b49a'); g.fillStyle = sg; g.fillRect(ax, ay, aw, ah);
for (let i = 0; i < 60; i++) { const x = ax + h1(i * 3) * aw, y = ay + h1(i * 7) * ah * .6; g.fillStyle = col([255, 245, 230], .4 + .5 * Math.sin(t * 2 + i) ** 2); g.beginPath(); g.arc(x, y, .8 + h1(i) * 1.8, 0, TAU); g.fill(); }
// 昴星团小点
[[150, -330], [162, -318], [140, -312], [170, -300], [152, -300], [160, -340]].forEach(([x, y], i) => sparkle(g, x, y, 2.2, C.sky, .7 + .3 * Math.sin(t * 3 + i), .6));
// 远山与花丛
g.fillStyle = 'rgba(40,20,40,.55)'; g.beginPath(); g.moveTo(ax, ay + ah); for (let x = 0; x <= aw; x += 20) g.lineTo(ax + x, ay + ah - 70 - Math.sin(x * .02) * 18 - Math.sin(x * .05) * 8); g.lineTo(ax + aw, ay + ah); g.fill();
halo(g, 0, -170, 260, [255, 210, 150], .7);
bullHead(g, 0, -168, 1.02, { k: 1 });
for (const [x, y, r, i] of [[-200, 10, 46, 0], [-150, 30, 34, 1], [-225, -40, 28, 2], [200, 10, 46, 3], [150, 30, 34, 4], [225, -40, 28, 5]]) {
leaf(g, x, y + 10, 50, (x < 0 ? -.6 : -2.5) + h1(i) * .4, 1); flower(g, x, y, r, 1, { rot: i, inner: i % 2 ? [214, 92, 120] : [230, 150, 120], outer: i % 2 ? [255, 214, 222] : [255, 236, 200] });
}
g.restore();
archPath(g, ax, ay, aw, ah); g.lineWidth = 5; g.strokeStyle = gilt(g, ax, ay, ax + aw, ay + ah); g.stroke();
// 角标
g.fillStyle = '#0c2c23'; g.beginPath(); g.arc(-246, -350, 44, 0, TAU); g.fill(); g.lineWidth = 3; g.strokeStyle = gilt(g, -290, -394, -202, -306); g.stroke();
text(g, 'No.', -246, -366, { size: 18, font: FONT.latin, weight: 700, color: col(C.gold) });
text(g, '02', -246, -340, { size: 34, font: FONT.latin, weight: 700, gilt: 'gold' });
g.fillStyle = '#0c2c23'; g.beginPath(); g.arc(246, -350, 44, 0, TAU); g.fill(); g.stroke();
taurusSign(g, 246, -346, .42, 1, { width: 9, blur: 6 });
// 名称缎带
const ry = 48; g.save();
for (const d of [-1, 1]) { g.fillStyle = '#0d4a37'; g.beginPath(); g.moveTo(d * 270, ry - 22); g.lineTo(d * 312, ry - 22); g.lineTo(d * 296, ry); g.lineTo(d * 312, ry + 22); g.lineTo(d * 270, ry + 22); g.fill(); }
const rg = g.createLinearGradient(0, ry - 34, 0, ry + 34); rg.addColorStop(0, '#1f7a5c'); rg.addColorStop(1, '#0d4a37'); g.fillStyle = rg; g.shadowColor = 'rgba(0,0,0,.5)'; g.shadowBlur = 14;
g.beginPath(); g.moveTo(-282, ry - 32); g.quadraticCurveTo(0, ry - 46, 282, ry - 32); g.lineTo(282, ry + 32); g.quadraticCurveTo(0, ry + 18, -282, ry + 32); g.closePath(); g.fill(); g.shadowBlur = 0;
g.strokeStyle = gilt(g, -280, ry, 280, ry); g.lineWidth = 2.5; g.stroke(); g.restore();
text(g, '金牛座 · TAURUS', 0, ry - 6, { size: 40, gilt: 'gold', weight: 900, spacing: 6, glow: 'rgba(0,0,0,.5)', blur: 6 });
// 属性
const rows = [['守护星', '金星 ♀'], ['元素', '土 · 固定'], ['宫位', '黄道第二宫'], st.birthday ? ['生日', st.birthday] : ['星座季', '4.20 — 5.20']];
rows.forEach(([k, v], i) => {
const q = ease.out(seg(F, i * .14, i * .14 + .3)); if (q <= 0) return;
const x = i % 2 ? 140 : -140, y = 132 + Math.floor(i / 2) * 82;
g.save(); g.globalAlpha *= q; rrect(g, x - 128, y - 32, 256, 64, 12); g.fillStyle = 'rgba(255,240,210,.06)'; g.fill(); g.strokeStyle = col(C.gold, .45); g.lineWidth = 1.2; g.stroke(); g.restore();
text(g, k, x, y - 13, { size: 17, font: FONT.sans, weight: 600, color: col(C.rose), alpha: q, spacing: 4 });
text(g, v, x, y + 13, { size: 27, weight: 900, gilt: 'gold', alpha: q });
});
// 品质宝石
const qs = ['坚定', '温柔', '可靠'];
qs.forEach((s, i) => {
const q = ease.back(seg(F, .55 + i * .12, .75 + i * .12)); if (q <= 0) return;
const x = (i - 1) * 168, y = 306;
g.save(); g.translate(x, y); g.scale(q, q); rrect(g, -70, -26, 140, 52, 26); const cg = g.createLinearGradient(0, -26, 0, 26); cg.addColorStop(0, '#f6c9c4'); cg.addColorStop(1, '#c97f74'); g.fillStyle = cg; g.fill();
g.strokeStyle = gilt(g, -70, -26, 70, 26); g.lineWidth = 2; g.stroke(); g.restore();
text(g, s, x, y + 1, { size: 28 * q, weight: 900, color: '#4a1420' });
});
// 署名
const nk = seg(F, .85, 1);
text(g, '大地守护者', 0, 370, { size: 34, weight: 900, gilt: 'gold', spacing: 10, alpha: nk });
const holder = (st.holder || '').trim();
text(g, holder ? `珍藏人 · ${holder}` : '珍藏人 · 每一位金牛座', 0, 410, { size: 22, weight: 700, color: col(C.ivory, .92), alpha: nk, spacing: 2 });
text(g, 'GUARDIAN OF THE EARTH · LIMITED №0420', 0, 440, { size: 13, font: FONT.latin, weight: 700, color: col(C.gold, .8), alpha: nk, spacing: 3 });
venusSign(g, -262, 404, .32, nk, { width: 7 });
g.save(); g.globalAlpha *= nk; taurusSign(g, 262, 400, .3, 1, { width: 8, blur: 4 }); g.restore();
}
// 镭射全息:彩虹带随 shine 扫过 + 细闪
function holo(g, shine, t, strength = 1) {
g.save(); rrect(g, -CW / 2, -CH / 2, CW, CH, R); g.clip();
g.globalCompositeOperation = 'overlay';
const x = lerp(-900, 900, shine);
const gr = g.createLinearGradient(x - 380, -440, x + 380, 440);
['rgba(255,80,160,0)', 'rgba(255,120,170,.55)', 'rgba(255,230,120,.6)', 'rgba(120,255,200,.55)', 'rgba(120,170,255,.55)', 'rgba(200,120,255,0)'].forEach((c, i, a) => gr.addColorStop(i / (a.length - 1), c));
g.globalAlpha = strength; g.fillStyle = gr; g.fillRect(-CW / 2, -CH / 2, CW, CH);
g.globalCompositeOperation = 'lighter';
const wg = g.createLinearGradient(x - 90, -440, x + 90, 440); wg.addColorStop(0, 'rgba(255,255,255,0)'); wg.addColorStop(.5, 'rgba(255,250,235,.35)'); wg.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = wg; g.fillRect(-CW / 2, -CH / 2, CW, CH);
for (let i = 0; i < 40; i++) { const px = (h1(i * 3) - .5) * CW, py = (h1(i * 5) - .5) * CH; const d = Math.abs(px - x * .7) / 300; if (d < 1) sparkle(g, px, py, 2 + h1(i) * 2, [255, 240, 255], (1 - d) * (.5 + .5 * Math.sin(t * 6 + i)), .8); }
g.restore();
}
function stamp(g, k) {
if (k <= 0) return;
const s = lerp(2.4, 1, ease.out5(k)), a = clamp(k * 3);
g.save(); g.translate(196, 290); g.rotate(-.22); g.scale(s, s); g.globalAlpha *= a * .92;
g.strokeStyle = '#c4302b'; g.fillStyle = 'rgba(196,48,43,.12)'; g.lineWidth = 5; g.beginPath(); g.arc(0, 0, 74, 0, TAU); g.fill(); g.stroke();
g.lineWidth = 2; g.beginPath(); g.arc(0, 0, 62, 0, TAU); g.stroke();
text(g, '认证', 0, -6, { size: 40, weight: 900, color: '#c4302b' });
text(g, '大地守护者', 0, 32, { size: 17, weight: 900, color: '#c4302b', spacing: 2 });
for (let i = 0; i < 5; i++) { const an = -Math.PI * .8 + i * .4; g.fillStyle = '#c4302b'; g.beginPath(); g.arc(Math.cos(an) * 48, Math.sin(an) * 48 - 2, 3, 0, TAU); g.fill(); }
g.restore();
}
/**
* st: { t, flip 0..1, fields 0..1, shine 0..1|undefined, stamp 0..1, holder, birthday, tilt }
*/
export function drawCard(g, cx, cy, height, st = {}) {
const s = height / CH, flip = st.flip ?? 1, ang = flip * Math.PI, sx = Math.cos(ang);
g.save(); g.translate(cx, cy); g.rotate(st.tilt ?? 0); g.scale(s * Math.max(.02, Math.abs(sx)), s);
// 投影
g.save(); g.shadowColor = 'rgba(0,0,0,.55)'; g.shadowBlur = 60 * s; g.shadowOffsetY = 30 * s; rrect(g, -CW / 2, -CH / 2, CW, CH, R); g.fillStyle = '#000'; g.fill(); g.restore();
if (sx > 0) back(g, st.t); else { g.scale(-1, 1); g.scale(-1, 1); front(g, st); }
if (sx <= 0 && st.shine !== undefined) holo(g, st.shine, st.t ?? 0, st.holo ?? 1);
if (sx <= 0) stamp(g, st.stamp ?? 0);
// 翻转时的明暗
const shade = 1 - Math.abs(sx); if (shade > .02) { rrect(g, -CW / 2, -CH / 2, CW, CH, R); g.fillStyle = `rgba(255,240,210,${shade * .35})`; g.fill(); }
g.restore();
}
// 单独的海报(壁纸 9:16 / 头像 1:1 / 收藏卡 3:4)
export function drawCardPoster(g, W, H, opts, paint) {
paint(g, W, H);
const S = Math.min(W, H) / 1080, portrait = H > W * 1.25;
const ch = portrait ? Math.min(H * .66, W * .9 / CW * CH) : H * .86;
const cy = portrait ? H * .48 : H / 2;
halo(g, W / 2, cy, ch * .8, [255, 200, 150], .7);
drawCard(g, W / 2, cy, ch, { t: 1.7, flip: 1, fields: 1, shine: .62, holo: .55, stamp: 1, holder: opts.name, birthday: opts.birthday });
if (portrait) {
text(g, '♉ 金牛座 · 慢慢来,也能抵达美好', W / 2, H * .08, { size: 40 * S, weight: 700, color: col(C.gold), spacing: 4 * S });
text(g, '愿你按照自己的节奏,稳稳地发光', W / 2, H * .9, { size: 36 * S, weight: 700, color: col(C.ivory), spacing: 2 * S });
text(g, '星座文化 · 一张属于金牛座的珍藏卡', W / 2, H * .94, { size: 24 * S, font: FONT.sans, weight: 400, color: col(C.rose, .9) });
}
}
export const CARD_RATIO = CW / CH;