// Decorative blackflame card — the animated centrepiece (dark heart, crimson // licks, rising embers). Canvas particle flame ported from the approved mock. // Instanceable via the factory; stop() tears down the rAF loop + observer. import { el } from '../../dom.js'; const TAU = Math.PI * 2; function startFlame(canvas) { const ctx = canvas.getContext('2d'); let W = 0, H = 0, DPR = 1, raf = 0, t = 0; function resize() { DPR = Math.min(2, window.devicePixelRatio || 1); const r = canvas.getBoundingClientRect(); W = r.width; H = r.height; canvas.width = Math.max(1, Math.round(W * DPR)); canvas.height = Math.max(1, Math.round(H * DPR)); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } const tongues = Array.from({ length: 5 }, (_, i) => ({ phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5, x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05 })); const spawn = () => ({ x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2, vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012, r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5 }); const embers = Array.from({ length: 46 }, spawn); function blob(x, y, r, c0, c1) { if (r <= 0) return; const g = ctx.createRadialGradient(x, y, 0, x, y, r); g.addColorStop(0, c0); g.addColorStop(1, c1); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill(); } function frame() { t += 0.016; ctx.globalCompositeOperation = 'source-over'; ctx.clearRect(0, 0, W, H); ctx.globalCompositeOperation = 'lighter'; const baseY = H * 0.92; for (const tg of tongues) { const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway); const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase)); const steps = 26; for (let s = 0; s < steps; s++) { const f = s / steps; const y = baseY - f * height; const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f); const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6; const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9)); const a = (1 - f) * 0.5; if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)'); else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)'); else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)'); } } for (const e of embers) { e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004; if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn()); const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max; const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`; ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e'; blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)'); } ctx.shadowBlur = 0; // carve the dark heart back in (the "black" of black-flame) ctx.globalCompositeOperation = 'source-over'; const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20; const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr); core.addColorStop(0, 'rgba(4,4,8,0.96)'); core.addColorStop(0.55, 'rgba(6,5,10,0.7)'); core.addColorStop(1, 'rgba(8,7,12,0)'); ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill(); ctx.globalCompositeOperation = 'lighter'; blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)'); raf = requestAnimationFrame(frame); } resize(); const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; ro?.observe(canvas); raf = requestAnimationFrame(frame); return () => { cancelAnimationFrame(raf); ro?.disconnect(); }; } export function blackflameCard(id) { let teardown = null; return { id, type: 'blackflame', title: '', decorative: true, size: 'l', mount(body) { const canvas = el('canvas', { class: 'sv-flame-canvas' }); const crest = el('div', { class: 'sv-flame-crest' }); const label = el('div', { class: 'sv-flame-label' }, 'The Void'); body.append(canvas, crest, label); // defer one frame so the canvas has a measured size requestAnimationFrame(() => { teardown = startFlame(canvas); }); }, start() {}, stop() { teardown && teardown(); teardown = null; } }; }