"use client"; import type { CrashScene } from "@spinza/game-core/client"; /** * Procedural 2D-canvas scenes for the ten Risk Games. Each painter receives * the normalised progress `p` (0 at 1.00×, →1 near very high multipliers, * log scale), the elapsed time, the current multiplier and the round phase. * All drawing is original and parametric — no image assets. */ export interface SceneFrame { ctx: CanvasRenderingContext2D; w: number; h: number; /** log-scale progress 0..1 (1 ≈ 1000×). */ p: number; /** Seconds since round start (0 when idle). */ t: number; multiplier: number; phase: "idle" | "running" | "cashed" | "crashed"; /** Seconds since the phase changed (for crash/cash animations). */ phaseT: number; palette: { primary: string; secondary: string; glow: string; bg: string; surface: string }; /** Active event label, if any. */ event: string | null; /** Elevator floor (steps curves). */ floor: number; reduceMotion: boolean; } type Painter = (f: SceneFrame) => void; const TAU = Math.PI * 2; function hashNoise(i: number, salt = 0): number { const x = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453; return x - Math.floor(x); } function withAlpha(hex: string, a: number): string { const n = parseInt(hex.replace("#", ""), 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } function mix(a: string, b: string, t: number): string { const na = parseInt(a.replace("#", ""), 16); const nb = parseInt(b.replace("#", ""), 16); const ch = (s: number) => Math.round(((na >> s) & 255) * (1 - t) + ((nb >> s) & 255) * t); return `rgb(${ch(16)},${ch(8)},${ch(0)})`; } function gradient(ctx: CanvasRenderingContext2D, w: number, h: number, stops: [number, string][]) { const g = ctx.createLinearGradient(0, 0, 0, h); for (const [o, c] of stops) g.addColorStop(o, c); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); } function stars(f: SceneFrame, count: number, alpha: number, drift = 0) { const { ctx, w, h, t } = f; ctx.fillStyle = `rgba(255,255,255,${alpha})`; for (let i = 0; i < count; i++) { const x = (hashNoise(i, 1) * w + drift * t * (0.3 + hashNoise(i, 4))) % w; const y = (hashNoise(i, 2) * h + drift * t * 2 * (0.3 + hashNoise(i, 5))) % h; const r = 0.6 + hashNoise(i, 3) * 1.6; ctx.beginPath(); ctx.arc(x < 0 ? x + w : x, y < 0 ? y + h : y, r, 0, TAU); ctx.fill(); } } function shake(f: SceneFrame, amount: number) { if (f.reduceMotion) return; f.ctx.translate((hashNoise(Math.floor(f.t * 60), 9) - 0.5) * amount, (hashNoise(Math.floor(f.t * 60), 10) - 0.5) * amount); } function crashFlash(f: SceneFrame, color: string) { if (f.phase !== "crashed") return; const a = Math.max(0, 0.75 - f.phaseT * 1.2); f.ctx.fillStyle = withAlpha(color, a); f.ctx.fillRect(0, 0, f.w, f.h); } function cashGlow(f: SceneFrame) { if (f.phase !== "cashed") return; const a = Math.max(0, 0.35 - f.phaseT * 0.5); const g = f.ctx.createRadialGradient(f.w / 2, f.h / 2, 0, f.w / 2, f.h / 2, Math.max(f.w, f.h) * 0.7); g.addColorStop(0, `rgba(201,169,97,${a})`); g.addColorStop(1, "rgba(201,169,97,0)"); f.ctx.fillStyle = g; f.ctx.fillRect(0, 0, f.w, f.h); } /* ------------------------------------------------------------------ sky */ const sky: Painter = (f) => { const { ctx, w, h, p, t } = f; // Sky colour from dawn blue → storm grey → deep indigo → black space. const top = p < 0.35 ? mix("#1d4ed8", "#334155", p / 0.35) : p < 0.7 ? mix("#334155", "#0b1226", (p - 0.35) / 0.35) : mix("#0b1226", "#000000", (p - 0.7) / 0.3); const bottom = p < 0.5 ? mix("#7dd3fc", "#475569", p / 0.5) : mix("#475569", "#020617", (p - 0.5) / 0.5); gradient(ctx, w, h, [[0, top], [1, bottom]]); if (p > 0.6) stars(f, 90, Math.min(0.9, (p - 0.6) * 2.5), 0); // Clouds scrolling down as we climb. const cloudAlpha = p < 0.55 ? 0.55 : Math.max(0, 0.55 - (p - 0.55) * 3); for (let i = 0; i < 9; i++) { const y = ((hashNoise(i, 21) * h + t * (60 + i * 12) * (1 + p * 3)) % (h + 80)) - 40; const x = hashNoise(i, 22) * w; ctx.fillStyle = `rgba(255,255,255,${cloudAlpha * (0.5 + hashNoise(i, 23) * 0.5)})`; for (let k = 0; k < 4; k++) { ctx.beginPath(); ctx.arc(x + k * 26 - 40, y + (k % 2) * 8, 18 + hashNoise(i + k, 24) * 16, 0, TAU); ctx.fill(); } } // Storm lightning in the middle band. if (p > 0.3 && p < 0.65 && hashNoise(Math.floor(t * 4), 30) > 0.93) { ctx.fillStyle = "rgba(255,255,255,0.18)"; ctx.fillRect(0, 0, w, h); } // Jet. const cx = w * 0.5; const cy = h * (0.62 - p * 0.25) + Math.sin(t * 3) * 3; ctx.save(); ctx.translate(cx, cy); if (f.phase === "crashed") { ctx.rotate(Math.min(1.2, f.phaseT * 2.5)); ctx.translate(f.phaseT * 60, f.phaseT * 160); ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.8); } ctx.rotate(-0.25); // Exhaust. const trail = ctx.createLinearGradient(-30, 0, -140, 0); trail.addColorStop(0, withAlpha(f.palette.secondary, 0.8)); trail.addColorStop(1, withAlpha(f.palette.secondary, 0)); ctx.fillStyle = trail; ctx.fillRect(-140, -3, 110, 6); // Fuselage. ctx.fillStyle = "#e5e7eb"; ctx.beginPath(); ctx.moveTo(40, 0); ctx.lineTo(-30, -9); ctx.lineTo(-40, 0); ctx.lineTo(-30, 9); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#94a3b8"; ctx.beginPath(); ctx.moveTo(-5, -4); ctx.lineTo(-40, -30); ctx.lineTo(-22, -2); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(-5, 4); ctx.lineTo(-40, 30); ctx.lineTo(-22, 2); ctx.closePath(); ctx.fill(); ctx.fillStyle = f.palette.primary; ctx.fillRect(10, -3, 14, 6); ctx.restore(); crashFlash(f, "#ffffff"); cashGlow(f); }; /* ---------------------------------------------------------------- ocean */ const ocean: Painter = (f) => { const { ctx, w, h, p, t } = f; const top = mix("#0e7490", "#020617", Math.min(1, p * 1.4)); const bottom = mix("#064e6a", "#000000", Math.min(1, p * 1.2)); gradient(ctx, w, h, [[0, top], [1, bottom]]); // Light rays near surface. if (p < 0.5) { ctx.fillStyle = `rgba(255,255,255,${0.06 * (1 - p * 2)})`; for (let i = 0; i < 5; i++) { const x = w * (0.1 + i * 0.2) + Math.sin(t * 0.6 + i) * 20; ctx.beginPath(); ctx.moveTo(x - 20, 0); ctx.lineTo(x + 20, 0); ctx.lineTo(x + 90, h); ctx.lineTo(x - 90, h); ctx.fill(); } } // Rising bubbles. ctx.strokeStyle = "rgba(255,255,255,0.35)"; for (let i = 0; i < 24; i++) { const y = h - ((hashNoise(i, 40) * h + t * (40 + i * 6) * (1 + p * 2)) % (h + 20)); const x = hashNoise(i, 41) * w + Math.sin(t * 2 + i) * 6; ctx.beginPath(); ctx.arc(x, y, 1.5 + hashNoise(i, 42) * 3, 0, TAU); ctx.stroke(); } // Depth marks. ctx.fillStyle = "rgba(255,255,255,0.15)"; ctx.font = "600 11px Geist, system-ui"; for (let i = 0; i < 6; i++) { const y = ((i * h) / 5 - ((t * 30 * (1 + p * 3)) % (h / 5)) + h) % h; ctx.fillRect(0, y, w, 1); } // Leviathan silhouette during event. if (f.event) { ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.beginPath(); const ex = ((t * 120) % (w + 400)) - 200; ctx.ellipse(ex, h * 0.3, 170, 45, 0.1, 0, TAU); ctx.fill(); ctx.fillStyle = withAlpha(f.palette.secondary, 0.8); ctx.beginPath(); ctx.arc(ex + 120, h * 0.28, 5, 0, TAU); ctx.fill(); } // Submarine. const cx = w * 0.5 + Math.sin(t * 1.2) * 6; const cy = h * (0.35 + p * 0.3); ctx.save(); ctx.translate(cx, cy); if (f.phase === "crashed") { ctx.scale(1 + f.phaseT * 0.6, 1 - Math.min(0.9, f.phaseT * 0.9)); ctx.globalAlpha = Math.max(0, 1 - f.phaseT); } ctx.fillStyle = "#fbbf24"; ctx.beginPath(); ctx.ellipse(0, 0, 54, 18, 0, 0, TAU); ctx.fill(); ctx.fillStyle = "#d97706"; ctx.fillRect(-12, -32, 22, 16); ctx.fillRect(-2, -42, 3, 12); ctx.fillStyle = "#0ea5e9"; for (const dx of [-24, -6, 12]) { ctx.beginPath(); ctx.arc(dx, -2, 5, 0, TAU); ctx.fill(); } // Propeller wash. ctx.fillStyle = "rgba(255,255,255,0.25)"; ctx.fillRect(-70 - (t * 200) % 20, -2, 14, 4); ctx.restore(); crashFlash(f, "#38bdf8"); cashGlow(f); }; /* --------------------------------------------------------------- rocket */ const rocket: Painter = (f) => { const { ctx, w, h, p, t } = f; gradient(ctx, w, h, [[0, mix("#1e1b4b", "#000000", Math.min(1, p * 1.3))], [1, mix("#7c2d12", "#0a0612", Math.min(1, p * 1.6))]]); stars(f, 120, 0.3 + p * 0.6, 8 + p * 40); // Ground at the start. if (p < 0.25) { ctx.fillStyle = `rgba(60,30,20,${1 - p * 4})`; ctx.fillRect(0, h * 0.85 + p * h, w, h); } // Mars grows near the end. if (p > 0.55) { const r = (p - 0.55) * 400; ctx.fillStyle = "#c2410c"; ctx.beginPath(); ctx.arc(w * 0.8, h * 0.2, r, 0, TAU); ctx.fill(); ctx.fillStyle = "rgba(0,0,0,0.25)"; ctx.beginPath(); ctx.arc(w * 0.8 + r * 0.3, h * 0.2 - r * 0.2, r * 0.3, 0, TAU); ctx.fill(); } const cx = w * 0.5; const cy = h * 0.6 - Math.sin(t * 5) * 2; ctx.save(); ctx.translate(cx, cy); if (f.phase === "crashed") { shake(f, 14); ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9); ctx.rotate(f.phaseT * 3); } // Flame. const flame = ctx.createLinearGradient(0, 40, 0, 40 + 80 + p * 60); flame.addColorStop(0, "#fff7ed"); flame.addColorStop(0.3, f.palette.primary); flame.addColorStop(1, "rgba(251,146,60,0)"); ctx.fillStyle = flame; ctx.beginPath(); ctx.moveTo(-14, 40); ctx.lineTo(14, 40); ctx.lineTo(Math.sin(t * 30) * 6, 40 + 80 + p * 60 + Math.sin(t * 25) * 10); ctx.closePath(); ctx.fill(); // Body. ctx.fillStyle = "#f1f5f9"; ctx.beginPath(); ctx.moveTo(0, -60); ctx.bezierCurveTo(30, -20, 26, 20, 20, 40); ctx.lineTo(-20, 40); ctx.bezierCurveTo(-26, 20, -30, -20, 0, -60); ctx.fill(); ctx.fillStyle = f.palette.secondary; ctx.beginPath(); ctx.moveTo(-20, 15); ctx.lineTo(-40, 50); ctx.lineTo(-20, 40); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(20, 15); ctx.lineTo(40, 50); ctx.lineTo(20, 40); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#0ea5e9"; ctx.beginPath(); ctx.arc(0, -15, 8, 0, TAU); ctx.fill(); ctx.restore(); crashFlash(f, "#fb923c"); cashGlow(f); }; /* ----------------------------------------------------------------- bank */ const bank: Painter = (f) => { const { ctx, w, h, p, t } = f; gradient(ctx, w, h, [[0, "#0b0a08"], [1, "#1b1811"]]); // Vault wall with deposit boxes. const cols = Math.ceil(w / 60); const rows = Math.ceil(h / 40); for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++) { ctx.fillStyle = `rgba(201,169,97,${0.04 + hashNoise(i * 31 + j, 50) * 0.04})`; ctx.fillRect(i * 60 + 3, j * 40 + 3, 54, 34); } // Police light sweep intensifies with progress. const siren = Math.max(0, p - 0.25) * 1.3; if (siren > 0) { const flash = Math.sin(t * 8) > 0; ctx.fillStyle = flash ? `rgba(239,68,68,${siren * 0.18})` : `rgba(59,130,246,${siren * 0.18})`; ctx.fillRect(0, 0, w, h); } // Growing bag. const size = 50 + p * 120; const cx = w * 0.5; const cy = h * 0.62; ctx.save(); ctx.translate(cx, cy); if (f.phase === "crashed") { ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9); ctx.translate(0, f.phaseT * 80); } const wobble = 1 + Math.sin(t * 6) * 0.02; ctx.scale(wobble, 1 / wobble); ctx.fillStyle = "#b08d57"; ctx.beginPath(); ctx.moveTo(-size * 0.7, size * 0.6); ctx.bezierCurveTo(-size * 1.1, -size * 0.4, -size * 0.3, -size * 0.7, 0, -size * 0.65); ctx.bezierCurveTo(size * 0.3, -size * 0.7, size * 1.1, -size * 0.4, size * 0.7, size * 0.6); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#8a6d2e"; ctx.fillRect(-size * 0.3, -size * 0.95, size * 0.6, size * 0.3); ctx.fillStyle = "#1a1408"; ctx.font = `800 ${Math.round(size * 0.5)}px Geist, system-ui`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("SC", 0, size * 0.1); ctx.restore(); // Bills flying. ctx.fillStyle = withAlpha(f.palette.primary, 0.6); for (let i = 0; i < 12 * p + 2; i++) { const x = (hashNoise(i, 60) * w + t * 40) % w; const y = (hashNoise(i, 61) * h + t * 90) % h; ctx.save(); ctx.translate(x, y); ctx.rotate(t * 2 + i); ctx.fillRect(-9, -4, 18, 8); ctx.restore(); } crashFlash(f, "#ef4444"); cashGlow(f); }; /* -------------------------------------------------------------- volcano */ const volcano: Painter = (f) => { const { ctx, w, h, p, t } = f; gradient(ctx, w, h, [[0, mix("#1c0a05", "#3b0a05", p)], [1, mix("#3b0a05", "#7f1d1d", p)]]); // Cave walls. ctx.fillStyle = "#0a0503"; ctx.beginPath(); ctx.moveTo(0, 0); for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.18 + Math.sin(y / 60 + t * 0.2) * 20, y); ctx.lineTo(0, h); ctx.fill(); ctx.beginPath(); ctx.moveTo(w, 0); for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.82 + Math.cos(y / 70 + t * 0.2) * 20, y); ctx.lineTo(w, h); ctx.fill(); // Rising lava level (visual tension) — rises with progress + wobble. const lavaY = h * (1 - Math.min(0.95, 0.15 + p * 0.75)) + Math.sin(t * 2) * 6; const lava = ctx.createLinearGradient(0, lavaY, 0, h); lava.addColorStop(0, "#fde68a"); lava.addColorStop(0.15, f.palette.primary); lava.addColorStop(1, "#7f1d1d"); ctx.fillStyle = lava; ctx.beginPath(); ctx.moveTo(0, lavaY); for (let x = 0; x <= w; x += 20) ctx.lineTo(x, lavaY + Math.sin(x / 40 + t * 3) * 6); ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.fill(); // Crystals hanging. for (let i = 0; i < 8; i++) { const x = w * (0.25 + hashNoise(i, 70) * 0.5); const y = ((hashNoise(i, 71) * h + t * 25 * (1 + p)) % (h * 0.9)); ctx.fillStyle = withAlpha(f.palette.secondary, 0.85); ctx.beginPath(); ctx.moveTo(x, y - 14); ctx.lineTo(x + 8, y); ctx.lineTo(x, y + 14); ctx.lineTo(x - 8, y); ctx.closePath(); ctx.fill(); } // Explorer. const cx = w * 0.5; const cy = h * 0.4 + Math.sin(t * 2) * 3; ctx.save(); ctx.translate(cx, cy); if (f.phase === "crashed") { shake(f, 18); ctx.globalAlpha = Math.max(0, 1 - f.phaseT); } ctx.strokeStyle = "#e5e7eb"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(0, -300); ctx.lineTo(0, -20); ctx.stroke(); // rope ctx.fillStyle = "#f59e0b"; ctx.beginPath(); ctx.arc(0, -14, 9, 0, TAU); ctx.fill(); // helmet ctx.fillStyle = "#e5e7eb"; ctx.fillRect(-7, -6, 14, 22); ctx.fillStyle = "#fde68a"; ctx.beginPath(); ctx.arc(0, -14, 3, 0, TAU); ctx.fill(); // lamp ctx.restore(); if (p > 0.6) shake(f, (p - 0.6) * 10); crashFlash(f, "#ff5c3d"); cashGlow(f); }; /* ------------------------------------------------------------ blackhole */ const blackhole: Painter = (f) => { const { ctx, w, h, p, t } = f; gradient(ctx, w, h, [[0, "#050308"], [1, "#0f0a1e"]]); stars(f, 140, 0.7, 2 + p * 30); const cx = w * 0.72; const cy = h * 0.42; const r = 40 + p * 120; // Accretion disk. for (let i = 0; i < 5; i++) { ctx.strokeStyle = withAlpha(i % 2 ? f.palette.primary : f.palette.secondary, 0.35 - i * 0.05); ctx.lineWidth = 6 - i; ctx.beginPath(); ctx.ellipse(cx, cy, r * (1.5 + i * 0.3), r * (0.45 + i * 0.1), 0.35 + t * 0.04 * (i + 1), 0, TAU); ctx.stroke(); } // Lensing glow + hole. const glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.2); glow.addColorStop(0, withAlpha(f.palette.glow, 0.5)); glow.addColorStop(1, "rgba(0,0,0,0)"); ctx.fillStyle = glow; ctx.fillRect(0, 0, w, h); ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(cx, cy, r, 0, TAU); ctx.fill(); // Ship spiralling in. const dist = Math.max(r + 20, (w * 0.55) * (1 - p) + r); const ang = -0.6 - t * (0.4 + p * 2); const sx = cx + Math.cos(ang) * dist; const sy = cy + Math.sin(ang) * dist * 0.5; ctx.save(); ctx.translate(sx, sy); ctx.rotate(ang + Math.PI / 2); if (f.phase === "crashed") { ctx.scale(1 - Math.min(0.95, f.phaseT), 1 + f.phaseT * 3); ctx.globalAlpha = Math.max(0, 1 - f.phaseT); } ctx.fillStyle = "#e2e8f0"; ctx.beginPath(); ctx.moveTo(0, -18); ctx.lineTo(12, 14); ctx.lineTo(0, 8); ctx.lineTo(-12, 14); ctx.closePath(); ctx.fill(); ctx.fillStyle = f.palette.secondary; ctx.fillRect(-3, 12, 6, 12 + p * 20); ctx.restore(); // Chromatic distortion near horizon. if (p > 0.5 && !f.reduceMotion) { ctx.fillStyle = `rgba(167,139,250,${(p - 0.5) * 0.15})`; ctx.fillRect(Math.sin(t * 10) * 4, 0, w, h); } crashFlash(f, "#a78bfa"); cashGlow(f); }; /* ------------------------------------------------------------- freefall */ const freefall: Painter = (f) => { const { ctx, w, h, p, t } = f; gradient(ctx, w, h, [[0, mix("#020617", "#1d4ed8", p)], [1, mix("#1e3a8a", "#7dd3fc", p)]]); if (p < 0.4) stars(f, 60, 0.6 * (1 - p * 2.5), 0); // Ground approaching: patchwork fields scale up. const scale = 0.2 + p * p * 3; ctx.save(); ctx.translate(w / 2, h * 0.95); ctx.scale(scale, scale * 0.35); for (let i = -6; i < 6; i++) for (let j = -6; j < 6; j++) { ctx.fillStyle = `rgba(${60 + hashNoise(i * 13 + j, 80) * 60},${110 + hashNoise(i * 7 + j, 81) * 80},${50},${Math.min(1, 0.15 + p)})`; ctx.fillRect(i * 60, j * 60 - 200, 56, 56); } ctx.restore(); // Clouds rushing up. ctx.fillStyle = "rgba(255,255,255,0.35)"; for (let i = 0; i < 8; i++) { const y = (h - ((hashNoise(i, 90) * h + t * (120 + p * 500)) % (h + 100))) + 50; const x = hashNoise(i, 91) * w; ctx.beginPath(); ctx.ellipse(x, y, 60 + hashNoise(i, 92) * 60, 14, 0, 0, TAU); ctx.fill(); } // Skydiver. const cx = w * 0.5 + Math.sin(t * 2) * 8; const cy = h * 0.42; ctx.save(); ctx.translate(cx, cy); if (f.phase === "cashed") { // Parachute opens. const o = Math.min(1, f.phaseT * 3); ctx.fillStyle = f.palette.secondary; ctx.beginPath(); ctx.arc(0, -70 * o, 60 * o, Math.PI, 0); ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,0.7)"; ctx.lineWidth = 1.5; for (const dx of [-50, -20, 20, 50]) { ctx.beginPath(); ctx.moveTo(dx * o, -70 * o); ctx.lineTo(0, -10); ctx.stroke(); } } if (f.phase === "crashed") ctx.globalAlpha = Math.max(0, 1 - f.phaseT); ctx.rotate(Math.sin(t * 3) * 0.1); ctx.fillStyle = "#f97316"; ctx.fillRect(-8, -14, 16, 28); ctx.fillStyle = "#fde68a"; ctx.beginPath(); ctx.arc(0, -22, 8, 0, TAU); ctx.fill(); ctx.strokeStyle = "#f97316"; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(-8, -8); ctx.lineTo(-28, -28); ctx.moveTo(8, -8); ctx.lineTo(28, -28); ctx.moveTo(-6, 14); ctx.lineTo(-20, 36); ctx.moveTo(6, 14); ctx.lineTo(20, 36); ctx.stroke(); ctx.restore(); if (p > 0.7) shake(f, (p - 0.7) * 12); crashFlash(f, "#ffffff"); cashGlow(f); }; /* --------------------------------------------------------------- reactor */ const reactor: Painter = (f) => { const { ctx, w, h, p, t } = f; gradient(ctx, w, h, [[0, "#070c06"], [1, mix("#111c0e", "#3f0f0f", p)]]); // Panel grid. ctx.strokeStyle = "rgba(163,230,53,0.08)"; for (let x = 0; x < w; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } // Core. const cx = w * 0.5; const cy = h * 0.45; const heat = p; const coreColor = mix("#a3e635", "#ef4444", heat); const pulse = 1 + Math.sin(t * (2 + heat * 12)) * 0.05 * (1 + heat); const glow = ctx.createRadialGradient(cx, cy, 10, cx, cy, 150 * pulse); glow.addColorStop(0, withAlpha(coreColor.startsWith("#") ? coreColor : f.palette.primary, 0.9)); glow.addColorStop(0.4, coreColor.replace("rgb(", "rgba(").replace(")", ",0.45)")); glow.addColorStop(1, "rgba(0,0,0,0)"); ctx.fillStyle = glow; ctx.fillRect(0, 0, w, h); // Hex containment. ctx.strokeStyle = withAlpha(f.palette.primary, 0.6); ctx.lineWidth = 3; ctx.beginPath(); for (let i = 0; i < 6; i++) { const a = (i / 6) * TAU; const x = cx + Math.cos(a) * 110; const y = cy + Math.sin(a) * 110; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.stroke(); // Temperature gauge. const gx = w * 0.5; const gy = h * 0.86; ctx.fillStyle = "rgba(255,255,255,0.08)"; ctx.fillRect(gx - 150, gy - 8, 300, 16); const tg = ctx.createLinearGradient(gx - 150, 0, gx + 150, 0); tg.addColorStop(0, "#a3e635"); tg.addColorStop(0.6, "#facc15"); tg.addColorStop(1, "#ef4444"); ctx.fillStyle = tg; ctx.fillRect(gx - 150, gy - 8, 300 * Math.min(1, heat + 0.05), 16); ctx.fillStyle = "#ffffff"; ctx.fillRect(gx + 150 * 0.8, gy - 14, 2, 28); // limit marker if (f.event) { ctx.fillStyle = f.event.toLowerCase().includes("cool") ? "rgba(56,189,248,0.12)" : "rgba(239,68,68,0.12)"; ctx.fillRect(0, 0, w, h); } if (heat > 0.6) shake(f, (heat - 0.6) * 12); crashFlash(f, "#ef4444"); cashGlow(f); }; /* ----------------------------------------------------------------- storm */ const storm: Painter = (f) => { const { ctx, w, h, p, t } = f; gradient(ctx, w, h, [[0, mix("#1e293b", "#0a0d12", p)], [1, mix("#334155", "#1a212c", p)]]); // Road. ctx.fillStyle = "#0b0e14"; ctx.beginPath(); ctx.moveTo(w * 0.35, h); ctx.lineTo(w * 0.48, h * 0.55); ctx.lineTo(w * 0.52, h * 0.55); ctx.lineTo(w * 0.65, h); ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.setLineDash([12, 18]); ctx.lineDashOffset = -t * 300; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(w * 0.5, h); ctx.lineTo(w * 0.5, h * 0.55); ctx.stroke(); ctx.setLineDash([]); // Tornado: grows as we approach. const size = 60 + p * 260; const tx = w * 0.5 + Math.sin(t * 0.7) * 30 * (1 - p); const ty = h * 0.55; for (let i = 0; i < 9; i++) { const yy = ty - i * (size * 0.12); const rw = size * (0.15 + i * 0.1); ctx.fillStyle = `rgba(${120 + i * 8},${130 + i * 8},${150 + i * 6},${0.35 + i * 0.05})`; ctx.beginPath(); ctx.ellipse(tx + Math.sin(t * 6 + i) * 6, yy, rw, size * 0.07, 0, 0, TAU); ctx.fill(); } // Debris. ctx.fillStyle = "rgba(200,200,200,0.5)"; for (let i = 0; i < 20 * p + 4; i++) { const a = t * (3 + hashNoise(i, 100) * 3) + i; const rr = size * (0.3 + hashNoise(i, 101) * 0.6); ctx.fillRect(tx + Math.cos(a) * rr, ty - size * 0.5 + Math.sin(a) * rr * 0.4, 5, 3); } // Lightning. if (hashNoise(Math.floor(t * 6), 110) > 0.9 - p * 0.3) { ctx.fillStyle = "rgba(255,255,255,0.14)"; ctx.fillRect(0, 0, w, h); } // Truck. ctx.save(); ctx.translate(w * 0.5, h * 0.88); if (f.phase === "crashed") { ctx.rotate(f.phaseT * 4); ctx.translate(0, -f.phaseT * 200); ctx.globalAlpha = Math.max(0, 1 - f.phaseT); } shake(f, p * 4); ctx.fillStyle = f.palette.secondary; ctx.fillRect(-26, -30, 52, 30); ctx.fillStyle = "#0f172a"; ctx.fillRect(-20, -26, 40, 12); ctx.fillStyle = "#111"; ctx.beginPath(); ctx.arc(-18, 2, 8, 0, TAU); ctx.arc(18, 2, 8, 0, TAU); ctx.fill(); ctx.fillStyle = "#fde68a"; ctx.fillRect(-26, -12, 6, 5); ctx.fillRect(20, -12, 6, 5); ctx.restore(); crashFlash(f, "#cbd5e1"); cashGlow(f); }; /* -------------------------------------------------------------- elevator */ const elevator: Painter = (f) => { const { ctx, w, h, t, floor } = f; gradient(ctx, w, h, [[0, "#08090d"], [1, "#151821"]]); // Shaft with floor lines scrolling. const speed = 60 + Math.min(600, floor * 3); ctx.strokeStyle = "rgba(232,207,143,0.25)"; ctx.fillStyle = "rgba(232,207,143,0.6)"; ctx.font = "700 12px Geist, system-ui"; ctx.textAlign = "right"; const spacing = 110; const offset = (t * speed) % spacing; for (let i = -1; i < h / spacing + 2; i++) { const y = i * spacing + offset; const fl = floor + Math.round((h / 2 - y) / spacing); ctx.beginPath(); ctx.moveTo(w * 0.2, y); ctx.lineTo(w * 0.8, y); ctx.stroke(); if (fl >= 0) ctx.fillText(String(fl), w * 0.18, y - 6); if ([25, 50, 100, 250].includes(fl)) { ctx.fillStyle = "rgba(56,189,248,0.25)"; ctx.fillRect(w * 0.2, y - spacing, w * 0.6, spacing); ctx.fillStyle = "rgba(232,207,143,0.6)"; } } // Rails. ctx.fillStyle = "rgba(255,255,255,0.08)"; ctx.fillRect(w * 0.2, 0, 4, h); ctx.fillRect(w * 0.8 - 4, 0, 4, h); // Cabin. ctx.save(); ctx.translate(w * 0.5, h * 0.5); if (f.phase === "crashed") { ctx.translate(0, f.phaseT * f.phaseT * 900); ctx.rotate(Math.sin(f.phaseT * 20) * 0.05); } ctx.fillStyle = "#1f2330"; ctx.fillRect(-70, -80, 140, 160); ctx.strokeStyle = f.palette.primary; ctx.lineWidth = 2; ctx.strokeRect(-70, -80, 140, 160); ctx.fillStyle = "rgba(232,207,143,0.2)"; ctx.fillRect(-60, -70, 120, 30); ctx.fillStyle = f.palette.primary; ctx.font = "800 26px Geist, system-ui"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(String(floor), 0, -55); // Doors (open on cash-out). const open = f.phase === "cashed" ? Math.min(1, f.phaseT * 2) * 50 : 0; ctx.fillStyle = "#2b3040"; ctx.fillRect(-60 - open, -30, 58, 100); ctx.fillRect(2 + open, -30, 58, 100); if (open > 0) { ctx.fillStyle = "rgba(232,207,143,0.6)"; ctx.fillRect(-2, -30, 4, 100); } ctx.restore(); crashFlash(f, "#ef4444"); cashGlow(f); }; export const SCENES: Record = { sky, ocean, rocket, bank, volcano, blackhole, freefall, reactor, storm, elevator }; /** Log-scale progress helper: 1.00× → 0, 1000× → 1. */ export function progressFor(multiplier: number): number { return Math.max(0, Math.min(1, Math.log10(Math.max(1, multiplier)) / 3)); }