TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import type { CrashScene } from "@spinza/game-core/client";45/**6 * Procedural 2D-canvas scenes for the ten Risk Games. Each painter receives7 * the normalised progress `p` (0 at 1.00×, →1 near very high multipliers,8 * log scale), the elapsed time, the current multiplier and the round phase.9 * All drawing is original and parametric — no image assets.10 */1112export interface SceneFrame {13 ctx: CanvasRenderingContext2D;14 w: number;15 h: number;16 /** log-scale progress 0..1 (1 ≈ 1000×). */17 p: number;18 /** Seconds since round start (0 when idle). */19 t: number;20 multiplier: number;21 phase: "idle" | "running" | "cashed" | "crashed";22 /** Seconds since the phase changed (for crash/cash animations). */23 phaseT: number;24 palette: { primary: string; secondary: string; glow: string; bg: string; surface: string };25 /** Active event label, if any. */26 event: string | null;27 /** Elevator floor (steps curves). */28 floor: number;29 reduceMotion: boolean;30}3132type Painter = (f: SceneFrame) => void;3334const TAU = Math.PI * 2;3536function hashNoise(i: number, salt = 0): number {37 const x = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453;38 return x - Math.floor(x);39}4041function withAlpha(hex: string, a: number): string {42 const n = parseInt(hex.replace("#", ""), 16);43 return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;44}4546function mix(a: string, b: string, t: number): string {47 const na = parseInt(a.replace("#", ""), 16);48 const nb = parseInt(b.replace("#", ""), 16);49 const ch = (s: number) => Math.round(((na >> s) & 255) * (1 - t) + ((nb >> s) & 255) * t);50 return `rgb(${ch(16)},${ch(8)},${ch(0)})`;51}5253function gradient(ctx: CanvasRenderingContext2D, w: number, h: number, stops: [number, string][]) {54 const g = ctx.createLinearGradient(0, 0, 0, h);55 for (const [o, c] of stops) g.addColorStop(o, c);56 ctx.fillStyle = g;57 ctx.fillRect(0, 0, w, h);58}5960function stars(f: SceneFrame, count: number, alpha: number, drift = 0) {61 const { ctx, w, h, t } = f;62 ctx.fillStyle = `rgba(255,255,255,${alpha})`;63 for (let i = 0; i < count; i++) {64 const x = (hashNoise(i, 1) * w + drift * t * (0.3 + hashNoise(i, 4))) % w;65 const y = (hashNoise(i, 2) * h + drift * t * 2 * (0.3 + hashNoise(i, 5))) % h;66 const r = 0.6 + hashNoise(i, 3) * 1.6;67 ctx.beginPath();68 ctx.arc(x < 0 ? x + w : x, y < 0 ? y + h : y, r, 0, TAU);69 ctx.fill();70 }71}7273function shake(f: SceneFrame, amount: number) {74 if (f.reduceMotion) return;75 f.ctx.translate((hashNoise(Math.floor(f.t * 60), 9) - 0.5) * amount, (hashNoise(Math.floor(f.t * 60), 10) - 0.5) * amount);76}7778function crashFlash(f: SceneFrame, color: string) {79 if (f.phase !== "crashed") return;80 const a = Math.max(0, 0.75 - f.phaseT * 1.2);81 f.ctx.fillStyle = withAlpha(color, a);82 f.ctx.fillRect(0, 0, f.w, f.h);83}8485function cashGlow(f: SceneFrame) {86 if (f.phase !== "cashed") return;87 const a = Math.max(0, 0.35 - f.phaseT * 0.5);88 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);89 g.addColorStop(0, `rgba(201,169,97,${a})`);90 g.addColorStop(1, "rgba(201,169,97,0)");91 f.ctx.fillStyle = g;92 f.ctx.fillRect(0, 0, f.w, f.h);93}9495/* ------------------------------------------------------------------ sky */9697const sky: Painter = (f) => {98 const { ctx, w, h, p, t } = f;99 // Sky colour from dawn blue → storm grey → deep indigo → black space.100 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);101 const bottom = p < 0.5 ? mix("#7dd3fc", "#475569", p / 0.5) : mix("#475569", "#020617", (p - 0.5) / 0.5);102 gradient(ctx, w, h, [[0, top], [1, bottom]]);103 if (p > 0.6) stars(f, 90, Math.min(0.9, (p - 0.6) * 2.5), 0);104 // Clouds scrolling down as we climb.105 const cloudAlpha = p < 0.55 ? 0.55 : Math.max(0, 0.55 - (p - 0.55) * 3);106 for (let i = 0; i < 9; i++) {107 const y = ((hashNoise(i, 21) * h + t * (60 + i * 12) * (1 + p * 3)) % (h + 80)) - 40;108 const x = hashNoise(i, 22) * w;109 ctx.fillStyle = `rgba(255,255,255,${cloudAlpha * (0.5 + hashNoise(i, 23) * 0.5)})`;110 for (let k = 0; k < 4; k++) {111 ctx.beginPath();112 ctx.arc(x + k * 26 - 40, y + (k % 2) * 8, 18 + hashNoise(i + k, 24) * 16, 0, TAU);113 ctx.fill();114 }115 }116 // Storm lightning in the middle band.117 if (p > 0.3 && p < 0.65 && hashNoise(Math.floor(t * 4), 30) > 0.93) {118 ctx.fillStyle = "rgba(255,255,255,0.18)";119 ctx.fillRect(0, 0, w, h);120 }121 // Jet.122 const cx = w * 0.5;123 const cy = h * (0.62 - p * 0.25) + Math.sin(t * 3) * 3;124 ctx.save();125 ctx.translate(cx, cy);126 if (f.phase === "crashed") {127 ctx.rotate(Math.min(1.2, f.phaseT * 2.5));128 ctx.translate(f.phaseT * 60, f.phaseT * 160);129 ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.8);130 }131 ctx.rotate(-0.25);132 // Exhaust.133 const trail = ctx.createLinearGradient(-30, 0, -140, 0);134 trail.addColorStop(0, withAlpha(f.palette.secondary, 0.8));135 trail.addColorStop(1, withAlpha(f.palette.secondary, 0));136 ctx.fillStyle = trail;137 ctx.fillRect(-140, -3, 110, 6);138 // Fuselage.139 ctx.fillStyle = "#e5e7eb";140 ctx.beginPath();141 ctx.moveTo(40, 0);142 ctx.lineTo(-30, -9);143 ctx.lineTo(-40, 0);144 ctx.lineTo(-30, 9);145 ctx.closePath();146 ctx.fill();147 ctx.fillStyle = "#94a3b8";148 ctx.beginPath();149 ctx.moveTo(-5, -4);150 ctx.lineTo(-40, -30);151 ctx.lineTo(-22, -2);152 ctx.closePath();153 ctx.fill();154 ctx.beginPath();155 ctx.moveTo(-5, 4);156 ctx.lineTo(-40, 30);157 ctx.lineTo(-22, 2);158 ctx.closePath();159 ctx.fill();160 ctx.fillStyle = f.palette.primary;161 ctx.fillRect(10, -3, 14, 6);162 ctx.restore();163 crashFlash(f, "#ffffff");164 cashGlow(f);165};166167/* ---------------------------------------------------------------- ocean */168169const ocean: Painter = (f) => {170 const { ctx, w, h, p, t } = f;171 const top = mix("#0e7490", "#020617", Math.min(1, p * 1.4));172 const bottom = mix("#064e6a", "#000000", Math.min(1, p * 1.2));173 gradient(ctx, w, h, [[0, top], [1, bottom]]);174 // Light rays near surface.175 if (p < 0.5) {176 ctx.fillStyle = `rgba(255,255,255,${0.06 * (1 - p * 2)})`;177 for (let i = 0; i < 5; i++) {178 const x = w * (0.1 + i * 0.2) + Math.sin(t * 0.6 + i) * 20;179 ctx.beginPath();180 ctx.moveTo(x - 20, 0);181 ctx.lineTo(x + 20, 0);182 ctx.lineTo(x + 90, h);183 ctx.lineTo(x - 90, h);184 ctx.fill();185 }186 }187 // Rising bubbles.188 ctx.strokeStyle = "rgba(255,255,255,0.35)";189 for (let i = 0; i < 24; i++) {190 const y = h - ((hashNoise(i, 40) * h + t * (40 + i * 6) * (1 + p * 2)) % (h + 20));191 const x = hashNoise(i, 41) * w + Math.sin(t * 2 + i) * 6;192 ctx.beginPath();193 ctx.arc(x, y, 1.5 + hashNoise(i, 42) * 3, 0, TAU);194 ctx.stroke();195 }196 // Depth marks.197 ctx.fillStyle = "rgba(255,255,255,0.15)";198 ctx.font = "600 11px Geist, system-ui";199 for (let i = 0; i < 6; i++) {200 const y = ((i * h) / 5 - ((t * 30 * (1 + p * 3)) % (h / 5)) + h) % h;201 ctx.fillRect(0, y, w, 1);202 }203 // Leviathan silhouette during event.204 if (f.event) {205 ctx.fillStyle = "rgba(0,0,0,0.55)";206 ctx.beginPath();207 const ex = ((t * 120) % (w + 400)) - 200;208 ctx.ellipse(ex, h * 0.3, 170, 45, 0.1, 0, TAU);209 ctx.fill();210 ctx.fillStyle = withAlpha(f.palette.secondary, 0.8);211 ctx.beginPath();212 ctx.arc(ex + 120, h * 0.28, 5, 0, TAU);213 ctx.fill();214 }215 // Submarine.216 const cx = w * 0.5 + Math.sin(t * 1.2) * 6;217 const cy = h * (0.35 + p * 0.3);218 ctx.save();219 ctx.translate(cx, cy);220 if (f.phase === "crashed") {221 ctx.scale(1 + f.phaseT * 0.6, 1 - Math.min(0.9, f.phaseT * 0.9));222 ctx.globalAlpha = Math.max(0, 1 - f.phaseT);223 }224 ctx.fillStyle = "#fbbf24";225 ctx.beginPath();226 ctx.ellipse(0, 0, 54, 18, 0, 0, TAU);227 ctx.fill();228 ctx.fillStyle = "#d97706";229 ctx.fillRect(-12, -32, 22, 16);230 ctx.fillRect(-2, -42, 3, 12);231 ctx.fillStyle = "#0ea5e9";232 for (const dx of [-24, -6, 12]) {233 ctx.beginPath();234 ctx.arc(dx, -2, 5, 0, TAU);235 ctx.fill();236 }237 // Propeller wash.238 ctx.fillStyle = "rgba(255,255,255,0.25)";239 ctx.fillRect(-70 - (t * 200) % 20, -2, 14, 4);240 ctx.restore();241 crashFlash(f, "#38bdf8");242 cashGlow(f);243};244245/* --------------------------------------------------------------- rocket */246247const rocket: Painter = (f) => {248 const { ctx, w, h, p, t } = f;249 gradient(ctx, w, h, [[0, mix("#1e1b4b", "#000000", Math.min(1, p * 1.3))], [1, mix("#7c2d12", "#0a0612", Math.min(1, p * 1.6))]]);250 stars(f, 120, 0.3 + p * 0.6, 8 + p * 40);251 // Ground at the start.252 if (p < 0.25) {253 ctx.fillStyle = `rgba(60,30,20,${1 - p * 4})`;254 ctx.fillRect(0, h * 0.85 + p * h, w, h);255 }256 // Mars grows near the end.257 if (p > 0.55) {258 const r = (p - 0.55) * 400;259 ctx.fillStyle = "#c2410c";260 ctx.beginPath();261 ctx.arc(w * 0.8, h * 0.2, r, 0, TAU);262 ctx.fill();263 ctx.fillStyle = "rgba(0,0,0,0.25)";264 ctx.beginPath();265 ctx.arc(w * 0.8 + r * 0.3, h * 0.2 - r * 0.2, r * 0.3, 0, TAU);266 ctx.fill();267 }268 const cx = w * 0.5;269 const cy = h * 0.6 - Math.sin(t * 5) * 2;270 ctx.save();271 ctx.translate(cx, cy);272 if (f.phase === "crashed") {273 shake(f, 14);274 ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9);275 ctx.rotate(f.phaseT * 3);276 }277 // Flame.278 const flame = ctx.createLinearGradient(0, 40, 0, 40 + 80 + p * 60);279 flame.addColorStop(0, "#fff7ed");280 flame.addColorStop(0.3, f.palette.primary);281 flame.addColorStop(1, "rgba(251,146,60,0)");282 ctx.fillStyle = flame;283 ctx.beginPath();284 ctx.moveTo(-14, 40);285 ctx.lineTo(14, 40);286 ctx.lineTo(Math.sin(t * 30) * 6, 40 + 80 + p * 60 + Math.sin(t * 25) * 10);287 ctx.closePath();288 ctx.fill();289 // Body.290 ctx.fillStyle = "#f1f5f9";291 ctx.beginPath();292 ctx.moveTo(0, -60);293 ctx.bezierCurveTo(30, -20, 26, 20, 20, 40);294 ctx.lineTo(-20, 40);295 ctx.bezierCurveTo(-26, 20, -30, -20, 0, -60);296 ctx.fill();297 ctx.fillStyle = f.palette.secondary;298 ctx.beginPath();299 ctx.moveTo(-20, 15);300 ctx.lineTo(-40, 50);301 ctx.lineTo(-20, 40);302 ctx.closePath();303 ctx.fill();304 ctx.beginPath();305 ctx.moveTo(20, 15);306 ctx.lineTo(40, 50);307 ctx.lineTo(20, 40);308 ctx.closePath();309 ctx.fill();310 ctx.fillStyle = "#0ea5e9";311 ctx.beginPath();312 ctx.arc(0, -15, 8, 0, TAU);313 ctx.fill();314 ctx.restore();315 crashFlash(f, "#fb923c");316 cashGlow(f);317};318319/* ----------------------------------------------------------------- bank */320321const bank: Painter = (f) => {322 const { ctx, w, h, p, t } = f;323 gradient(ctx, w, h, [[0, "#0b0a08"], [1, "#1b1811"]]);324 // Vault wall with deposit boxes.325 const cols = Math.ceil(w / 60);326 const rows = Math.ceil(h / 40);327 for (let i = 0; i < cols; i++)328 for (let j = 0; j < rows; j++) {329 ctx.fillStyle = `rgba(201,169,97,${0.04 + hashNoise(i * 31 + j, 50) * 0.04})`;330 ctx.fillRect(i * 60 + 3, j * 40 + 3, 54, 34);331 }332 // Police light sweep intensifies with progress.333 const siren = Math.max(0, p - 0.25) * 1.3;334 if (siren > 0) {335 const flash = Math.sin(t * 8) > 0;336 ctx.fillStyle = flash ? `rgba(239,68,68,${siren * 0.18})` : `rgba(59,130,246,${siren * 0.18})`;337 ctx.fillRect(0, 0, w, h);338 }339 // Growing bag.340 const size = 50 + p * 120;341 const cx = w * 0.5;342 const cy = h * 0.62;343 ctx.save();344 ctx.translate(cx, cy);345 if (f.phase === "crashed") {346 ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9);347 ctx.translate(0, f.phaseT * 80);348 }349 const wobble = 1 + Math.sin(t * 6) * 0.02;350 ctx.scale(wobble, 1 / wobble);351 ctx.fillStyle = "#b08d57";352 ctx.beginPath();353 ctx.moveTo(-size * 0.7, size * 0.6);354 ctx.bezierCurveTo(-size * 1.1, -size * 0.4, -size * 0.3, -size * 0.7, 0, -size * 0.65);355 ctx.bezierCurveTo(size * 0.3, -size * 0.7, size * 1.1, -size * 0.4, size * 0.7, size * 0.6);356 ctx.closePath();357 ctx.fill();358 ctx.fillStyle = "#8a6d2e";359 ctx.fillRect(-size * 0.3, -size * 0.95, size * 0.6, size * 0.3);360 ctx.fillStyle = "#1a1408";361 ctx.font = `800 ${Math.round(size * 0.5)}px Geist, system-ui`;362 ctx.textAlign = "center";363 ctx.textBaseline = "middle";364 ctx.fillText("SC", 0, size * 0.1);365 ctx.restore();366 // Bills flying.367 ctx.fillStyle = withAlpha(f.palette.primary, 0.6);368 for (let i = 0; i < 12 * p + 2; i++) {369 const x = (hashNoise(i, 60) * w + t * 40) % w;370 const y = (hashNoise(i, 61) * h + t * 90) % h;371 ctx.save();372 ctx.translate(x, y);373 ctx.rotate(t * 2 + i);374 ctx.fillRect(-9, -4, 18, 8);375 ctx.restore();376 }377 crashFlash(f, "#ef4444");378 cashGlow(f);379};380381/* -------------------------------------------------------------- volcano */382383const volcano: Painter = (f) => {384 const { ctx, w, h, p, t } = f;385 gradient(ctx, w, h, [[0, mix("#1c0a05", "#3b0a05", p)], [1, mix("#3b0a05", "#7f1d1d", p)]]);386 // Cave walls.387 ctx.fillStyle = "#0a0503";388 ctx.beginPath();389 ctx.moveTo(0, 0);390 for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.18 + Math.sin(y / 60 + t * 0.2) * 20, y);391 ctx.lineTo(0, h);392 ctx.fill();393 ctx.beginPath();394 ctx.moveTo(w, 0);395 for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.82 + Math.cos(y / 70 + t * 0.2) * 20, y);396 ctx.lineTo(w, h);397 ctx.fill();398 // Rising lava level (visual tension) — rises with progress + wobble.399 const lavaY = h * (1 - Math.min(0.95, 0.15 + p * 0.75)) + Math.sin(t * 2) * 6;400 const lava = ctx.createLinearGradient(0, lavaY, 0, h);401 lava.addColorStop(0, "#fde68a");402 lava.addColorStop(0.15, f.palette.primary);403 lava.addColorStop(1, "#7f1d1d");404 ctx.fillStyle = lava;405 ctx.beginPath();406 ctx.moveTo(0, lavaY);407 for (let x = 0; x <= w; x += 20) ctx.lineTo(x, lavaY + Math.sin(x / 40 + t * 3) * 6);408 ctx.lineTo(w, h);409 ctx.lineTo(0, h);410 ctx.fill();411 // Crystals hanging.412 for (let i = 0; i < 8; i++) {413 const x = w * (0.25 + hashNoise(i, 70) * 0.5);414 const y = ((hashNoise(i, 71) * h + t * 25 * (1 + p)) % (h * 0.9));415 ctx.fillStyle = withAlpha(f.palette.secondary, 0.85);416 ctx.beginPath();417 ctx.moveTo(x, y - 14);418 ctx.lineTo(x + 8, y);419 ctx.lineTo(x, y + 14);420 ctx.lineTo(x - 8, y);421 ctx.closePath();422 ctx.fill();423 }424 // Explorer.425 const cx = w * 0.5;426 const cy = h * 0.4 + Math.sin(t * 2) * 3;427 ctx.save();428 ctx.translate(cx, cy);429 if (f.phase === "crashed") {430 shake(f, 18);431 ctx.globalAlpha = Math.max(0, 1 - f.phaseT);432 }433 ctx.strokeStyle = "#e5e7eb";434 ctx.lineWidth = 3;435 ctx.beginPath();436 ctx.moveTo(0, -300);437 ctx.lineTo(0, -20);438 ctx.stroke(); // rope439 ctx.fillStyle = "#f59e0b";440 ctx.beginPath();441 ctx.arc(0, -14, 9, 0, TAU);442 ctx.fill(); // helmet443 ctx.fillStyle = "#e5e7eb";444 ctx.fillRect(-7, -6, 14, 22);445 ctx.fillStyle = "#fde68a";446 ctx.beginPath();447 ctx.arc(0, -14, 3, 0, TAU);448 ctx.fill(); // lamp449 ctx.restore();450 if (p > 0.6) shake(f, (p - 0.6) * 10);451 crashFlash(f, "#ff5c3d");452 cashGlow(f);453};454455/* ------------------------------------------------------------ blackhole */456457const blackhole: Painter = (f) => {458 const { ctx, w, h, p, t } = f;459 gradient(ctx, w, h, [[0, "#050308"], [1, "#0f0a1e"]]);460 stars(f, 140, 0.7, 2 + p * 30);461 const cx = w * 0.72;462 const cy = h * 0.42;463 const r = 40 + p * 120;464 // Accretion disk.465 for (let i = 0; i < 5; i++) {466 ctx.strokeStyle = withAlpha(i % 2 ? f.palette.primary : f.palette.secondary, 0.35 - i * 0.05);467 ctx.lineWidth = 6 - i;468 ctx.beginPath();469 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);470 ctx.stroke();471 }472 // Lensing glow + hole.473 const glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.2);474 glow.addColorStop(0, withAlpha(f.palette.glow, 0.5));475 glow.addColorStop(1, "rgba(0,0,0,0)");476 ctx.fillStyle = glow;477 ctx.fillRect(0, 0, w, h);478 ctx.fillStyle = "#000";479 ctx.beginPath();480 ctx.arc(cx, cy, r, 0, TAU);481 ctx.fill();482 // Ship spiralling in.483 const dist = Math.max(r + 20, (w * 0.55) * (1 - p) + r);484 const ang = -0.6 - t * (0.4 + p * 2);485 const sx = cx + Math.cos(ang) * dist;486 const sy = cy + Math.sin(ang) * dist * 0.5;487 ctx.save();488 ctx.translate(sx, sy);489 ctx.rotate(ang + Math.PI / 2);490 if (f.phase === "crashed") {491 ctx.scale(1 - Math.min(0.95, f.phaseT), 1 + f.phaseT * 3);492 ctx.globalAlpha = Math.max(0, 1 - f.phaseT);493 }494 ctx.fillStyle = "#e2e8f0";495 ctx.beginPath();496 ctx.moveTo(0, -18);497 ctx.lineTo(12, 14);498 ctx.lineTo(0, 8);499 ctx.lineTo(-12, 14);500 ctx.closePath();501 ctx.fill();502 ctx.fillStyle = f.palette.secondary;503 ctx.fillRect(-3, 12, 6, 12 + p * 20);504 ctx.restore();505 // Chromatic distortion near horizon.506 if (p > 0.5 && !f.reduceMotion) {507 ctx.fillStyle = `rgba(167,139,250,${(p - 0.5) * 0.15})`;508 ctx.fillRect(Math.sin(t * 10) * 4, 0, w, h);509 }510 crashFlash(f, "#a78bfa");511 cashGlow(f);512};513514/* ------------------------------------------------------------- freefall */515516const freefall: Painter = (f) => {517 const { ctx, w, h, p, t } = f;518 gradient(ctx, w, h, [[0, mix("#020617", "#1d4ed8", p)], [1, mix("#1e3a8a", "#7dd3fc", p)]]);519 if (p < 0.4) stars(f, 60, 0.6 * (1 - p * 2.5), 0);520 // Ground approaching: patchwork fields scale up.521 const scale = 0.2 + p * p * 3;522 ctx.save();523 ctx.translate(w / 2, h * 0.95);524 ctx.scale(scale, scale * 0.35);525 for (let i = -6; i < 6; i++)526 for (let j = -6; j < 6; j++) {527 ctx.fillStyle = `rgba(${60 + hashNoise(i * 13 + j, 80) * 60},${110 + hashNoise(i * 7 + j, 81) * 80},${50},${Math.min(1, 0.15 + p)})`;528 ctx.fillRect(i * 60, j * 60 - 200, 56, 56);529 }530 ctx.restore();531 // Clouds rushing up.532 ctx.fillStyle = "rgba(255,255,255,0.35)";533 for (let i = 0; i < 8; i++) {534 const y = (h - ((hashNoise(i, 90) * h + t * (120 + p * 500)) % (h + 100))) + 50;535 const x = hashNoise(i, 91) * w;536 ctx.beginPath();537 ctx.ellipse(x, y, 60 + hashNoise(i, 92) * 60, 14, 0, 0, TAU);538 ctx.fill();539 }540 // Skydiver.541 const cx = w * 0.5 + Math.sin(t * 2) * 8;542 const cy = h * 0.42;543 ctx.save();544 ctx.translate(cx, cy);545 if (f.phase === "cashed") {546 // Parachute opens.547 const o = Math.min(1, f.phaseT * 3);548 ctx.fillStyle = f.palette.secondary;549 ctx.beginPath();550 ctx.arc(0, -70 * o, 60 * o, Math.PI, 0);551 ctx.fill();552 ctx.strokeStyle = "rgba(255,255,255,0.7)";553 ctx.lineWidth = 1.5;554 for (const dx of [-50, -20, 20, 50]) {555 ctx.beginPath();556 ctx.moveTo(dx * o, -70 * o);557 ctx.lineTo(0, -10);558 ctx.stroke();559 }560 }561 if (f.phase === "crashed") ctx.globalAlpha = Math.max(0, 1 - f.phaseT);562 ctx.rotate(Math.sin(t * 3) * 0.1);563 ctx.fillStyle = "#f97316";564 ctx.fillRect(-8, -14, 16, 28);565 ctx.fillStyle = "#fde68a";566 ctx.beginPath();567 ctx.arc(0, -22, 8, 0, TAU);568 ctx.fill();569 ctx.strokeStyle = "#f97316";570 ctx.lineWidth = 5;571 ctx.beginPath();572 ctx.moveTo(-8, -8);573 ctx.lineTo(-28, -28);574 ctx.moveTo(8, -8);575 ctx.lineTo(28, -28);576 ctx.moveTo(-6, 14);577 ctx.lineTo(-20, 36);578 ctx.moveTo(6, 14);579 ctx.lineTo(20, 36);580 ctx.stroke();581 ctx.restore();582 if (p > 0.7) shake(f, (p - 0.7) * 12);583 crashFlash(f, "#ffffff");584 cashGlow(f);585};586587/* --------------------------------------------------------------- reactor */588589const reactor: Painter = (f) => {590 const { ctx, w, h, p, t } = f;591 gradient(ctx, w, h, [[0, "#070c06"], [1, mix("#111c0e", "#3f0f0f", p)]]);592 // Panel grid.593 ctx.strokeStyle = "rgba(163,230,53,0.08)";594 for (let x = 0; x < w; x += 40) {595 ctx.beginPath();596 ctx.moveTo(x, 0);597 ctx.lineTo(x, h);598 ctx.stroke();599 }600 // Core.601 const cx = w * 0.5;602 const cy = h * 0.45;603 const heat = p;604 const coreColor = mix("#a3e635", "#ef4444", heat);605 const pulse = 1 + Math.sin(t * (2 + heat * 12)) * 0.05 * (1 + heat);606 const glow = ctx.createRadialGradient(cx, cy, 10, cx, cy, 150 * pulse);607 glow.addColorStop(0, withAlpha(coreColor.startsWith("#") ? coreColor : f.palette.primary, 0.9));608 glow.addColorStop(0.4, coreColor.replace("rgb(", "rgba(").replace(")", ",0.45)"));609 glow.addColorStop(1, "rgba(0,0,0,0)");610 ctx.fillStyle = glow;611 ctx.fillRect(0, 0, w, h);612 // Hex containment.613 ctx.strokeStyle = withAlpha(f.palette.primary, 0.6);614 ctx.lineWidth = 3;615 ctx.beginPath();616 for (let i = 0; i < 6; i++) {617 const a = (i / 6) * TAU;618 const x = cx + Math.cos(a) * 110;619 const y = cy + Math.sin(a) * 110;620 if (i === 0) ctx.moveTo(x, y);621 else ctx.lineTo(x, y);622 }623 ctx.closePath();624 ctx.stroke();625 // Temperature gauge.626 const gx = w * 0.5;627 const gy = h * 0.86;628 ctx.fillStyle = "rgba(255,255,255,0.08)";629 ctx.fillRect(gx - 150, gy - 8, 300, 16);630 const tg = ctx.createLinearGradient(gx - 150, 0, gx + 150, 0);631 tg.addColorStop(0, "#a3e635");632 tg.addColorStop(0.6, "#facc15");633 tg.addColorStop(1, "#ef4444");634 ctx.fillStyle = tg;635 ctx.fillRect(gx - 150, gy - 8, 300 * Math.min(1, heat + 0.05), 16);636 ctx.fillStyle = "#ffffff";637 ctx.fillRect(gx + 150 * 0.8, gy - 14, 2, 28); // limit marker638 if (f.event) {639 ctx.fillStyle = f.event.toLowerCase().includes("cool") ? "rgba(56,189,248,0.12)" : "rgba(239,68,68,0.12)";640 ctx.fillRect(0, 0, w, h);641 }642 if (heat > 0.6) shake(f, (heat - 0.6) * 12);643 crashFlash(f, "#ef4444");644 cashGlow(f);645};646647/* ----------------------------------------------------------------- storm */648649const storm: Painter = (f) => {650 const { ctx, w, h, p, t } = f;651 gradient(ctx, w, h, [[0, mix("#1e293b", "#0a0d12", p)], [1, mix("#334155", "#1a212c", p)]]);652 // Road.653 ctx.fillStyle = "#0b0e14";654 ctx.beginPath();655 ctx.moveTo(w * 0.35, h);656 ctx.lineTo(w * 0.48, h * 0.55);657 ctx.lineTo(w * 0.52, h * 0.55);658 ctx.lineTo(w * 0.65, h);659 ctx.fill();660 ctx.strokeStyle = "rgba(255,255,255,0.5)";661 ctx.setLineDash([12, 18]);662 ctx.lineDashOffset = -t * 300;663 ctx.lineWidth = 3;664 ctx.beginPath();665 ctx.moveTo(w * 0.5, h);666 ctx.lineTo(w * 0.5, h * 0.55);667 ctx.stroke();668 ctx.setLineDash([]);669 // Tornado: grows as we approach.670 const size = 60 + p * 260;671 const tx = w * 0.5 + Math.sin(t * 0.7) * 30 * (1 - p);672 const ty = h * 0.55;673 for (let i = 0; i < 9; i++) {674 const yy = ty - i * (size * 0.12);675 const rw = size * (0.15 + i * 0.1);676 ctx.fillStyle = `rgba(${120 + i * 8},${130 + i * 8},${150 + i * 6},${0.35 + i * 0.05})`;677 ctx.beginPath();678 ctx.ellipse(tx + Math.sin(t * 6 + i) * 6, yy, rw, size * 0.07, 0, 0, TAU);679 ctx.fill();680 }681 // Debris.682 ctx.fillStyle = "rgba(200,200,200,0.5)";683 for (let i = 0; i < 20 * p + 4; i++) {684 const a = t * (3 + hashNoise(i, 100) * 3) + i;685 const rr = size * (0.3 + hashNoise(i, 101) * 0.6);686 ctx.fillRect(tx + Math.cos(a) * rr, ty - size * 0.5 + Math.sin(a) * rr * 0.4, 5, 3);687 }688 // Lightning.689 if (hashNoise(Math.floor(t * 6), 110) > 0.9 - p * 0.3) {690 ctx.fillStyle = "rgba(255,255,255,0.14)";691 ctx.fillRect(0, 0, w, h);692 }693 // Truck.694 ctx.save();695 ctx.translate(w * 0.5, h * 0.88);696 if (f.phase === "crashed") {697 ctx.rotate(f.phaseT * 4);698 ctx.translate(0, -f.phaseT * 200);699 ctx.globalAlpha = Math.max(0, 1 - f.phaseT);700 }701 shake(f, p * 4);702 ctx.fillStyle = f.palette.secondary;703 ctx.fillRect(-26, -30, 52, 30);704 ctx.fillStyle = "#0f172a";705 ctx.fillRect(-20, -26, 40, 12);706 ctx.fillStyle = "#111";707 ctx.beginPath();708 ctx.arc(-18, 2, 8, 0, TAU);709 ctx.arc(18, 2, 8, 0, TAU);710 ctx.fill();711 ctx.fillStyle = "#fde68a";712 ctx.fillRect(-26, -12, 6, 5);713 ctx.fillRect(20, -12, 6, 5);714 ctx.restore();715 crashFlash(f, "#cbd5e1");716 cashGlow(f);717};718719/* -------------------------------------------------------------- elevator */720721const elevator: Painter = (f) => {722 const { ctx, w, h, t, floor } = f;723 gradient(ctx, w, h, [[0, "#08090d"], [1, "#151821"]]);724 // Shaft with floor lines scrolling.725 const speed = 60 + Math.min(600, floor * 3);726 ctx.strokeStyle = "rgba(232,207,143,0.25)";727 ctx.fillStyle = "rgba(232,207,143,0.6)";728 ctx.font = "700 12px Geist, system-ui";729 ctx.textAlign = "right";730 const spacing = 110;731 const offset = (t * speed) % spacing;732 for (let i = -1; i < h / spacing + 2; i++) {733 const y = i * spacing + offset;734 const fl = floor + Math.round((h / 2 - y) / spacing);735 ctx.beginPath();736 ctx.moveTo(w * 0.2, y);737 ctx.lineTo(w * 0.8, y);738 ctx.stroke();739 if (fl >= 0) ctx.fillText(String(fl), w * 0.18, y - 6);740 if ([25, 50, 100, 250].includes(fl)) {741 ctx.fillStyle = "rgba(56,189,248,0.25)";742 ctx.fillRect(w * 0.2, y - spacing, w * 0.6, spacing);743 ctx.fillStyle = "rgba(232,207,143,0.6)";744 }745 }746 // Rails.747 ctx.fillStyle = "rgba(255,255,255,0.08)";748 ctx.fillRect(w * 0.2, 0, 4, h);749 ctx.fillRect(w * 0.8 - 4, 0, 4, h);750 // Cabin.751 ctx.save();752 ctx.translate(w * 0.5, h * 0.5);753 if (f.phase === "crashed") {754 ctx.translate(0, f.phaseT * f.phaseT * 900);755 ctx.rotate(Math.sin(f.phaseT * 20) * 0.05);756 }757 ctx.fillStyle = "#1f2330";758 ctx.fillRect(-70, -80, 140, 160);759 ctx.strokeStyle = f.palette.primary;760 ctx.lineWidth = 2;761 ctx.strokeRect(-70, -80, 140, 160);762 ctx.fillStyle = "rgba(232,207,143,0.2)";763 ctx.fillRect(-60, -70, 120, 30);764 ctx.fillStyle = f.palette.primary;765 ctx.font = "800 26px Geist, system-ui";766 ctx.textAlign = "center";767 ctx.textBaseline = "middle";768 ctx.fillText(String(floor), 0, -55);769 // Doors (open on cash-out).770 const open = f.phase === "cashed" ? Math.min(1, f.phaseT * 2) * 50 : 0;771 ctx.fillStyle = "#2b3040";772 ctx.fillRect(-60 - open, -30, 58, 100);773 ctx.fillRect(2 + open, -30, 58, 100);774 if (open > 0) {775 ctx.fillStyle = "rgba(232,207,143,0.6)";776 ctx.fillRect(-2, -30, 4, 100);777 }778 ctx.restore();779 crashFlash(f, "#ef4444");780 cashGlow(f);781};782783export const SCENES: Record<CrashScene, Painter> = { sky, ocean, rocket, bank, volcano, blackhole, freefall, reactor, storm, elevator };784785/** Log-scale progress helper: 1.00× → 0, 1000× → 1. */786export function progressFor(multiplier: number): number {787 return Math.max(0, Math.min(1, Math.log10(Math.max(1, multiplier)) / 3));788}789