SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
25.1 KB · 789 lines typescript
Raw Blame History
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