TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23/**4 * Escape 99 — Spinza Original (ladder). A fast roguelike tower: 99 floors,5 * room by room, cash out on any of them. The tower strip scrolls as the player6 * climbs; every CLIMB animates the room resolution returned by the server.7 */8import { useCallback, useEffect, useRef, useState } from "react";9import { AnimatePresence, motion } from "framer-motion";10import { ArrowUp, Crown, DoorOpen, Flag, Flame, Gem, Orbit, RotateCcw, Shield, ShieldCheck, Skull, Sparkles, Sword, TriangleAlert, Zap, type LucideIcon } from "lucide-react";11import { formatMultiplier, formatSC } from "@spinza/shared";12import type { LadderEvent, LadderOffer } from "@spinza/game-core/client";13import { cn } from "@/lib/utils";14import { Button } from "@/components/ui";15import { useLadder, type ArcadeGameProps, type LadderView } from "./contract";1617interface EscapeConfig {18 floors: number;19 bands: [number, number][];20 checkpoints: number[];21 rooms: { kind: string; label: string; weight: number; detail: string }[];22 forkChance: number;23 portalChance: number;24}2526/** Room resolution being animated from the returned log tail. */27interface Resolution {28 ev: LadderEvent;29 from: number;30 step: "resolve" | "result";31}3233type Phase = "idle" | "resolve" | "running" | "cashed" | "busted" | "completed";3435const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));36const ROW = 22;37const DANGER = "#ff5c7a";3839const ICONS: Record<string, LucideIcon> = { chest: Gem, enemy: Sword, trap: TriangleAlert, multiplier: Sparkles, room: DoorOpen, portal: Orbit, "fork-safe": Shield, "fork-risky": Flame, start: DoorOpen, summit: Crown };40const SUCCESS: Record<string, string> = { chest: "Chest opened", enemy: "Guardian defeated", trap: "Trap disarmed", multiplier: "Rune charged", room: "Stairs climbed", portal: "Warped three floors", "fork-safe": "Corridor cleared", "fork-risky": "Corridor cleared", start: "You are in" };4142function lastStep(log: LadderEvent[]): LadderEvent | undefined {43 for (let i = log.length - 1; i >= 0; i--) if (log[i].outcome === "ok" || log[i].outcome === "bust") return log[i];44 return undefined;45}4647function pickSafe(offers: LadderOffer[]): LadderOffer | undefined {48 return offers.find((o) => o.kind === "fork-safe") ?? offers[0];49}5051export function EscapeGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {52 const cfg = definition.config as unknown as EscapeConfig;53 const palette = definition.presentation.palette;54 const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug);55 const [anim, setAnim] = useState<Resolution | null>(null);56 const [selected, setSelected] = useState<string | null>(null);57 const [flourish, setFlourish] = useState<number | null>(null);58 const [summit, setSummit] = useState(false);59 const [auto, setAuto] = useState(false);60 const autoRef = useRef(false);61 const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]);62 const sec = (ms: number) => dur(ms) / 1000;6364 /* ------------------------------------------------------------ derived */65 const phase: Phase = anim ? "resolve" : !session ? "idle" : session.status;66 const running = session?.status === "running";67 const locked = busy || anim !== null;68 const floor = anim ? (anim.step === "resolve" || anim.ev.outcome === "bust" ? anim.from : anim.ev.stage) : (session?.stage ?? 0);69 const runBet = session?.bet ?? bet;70 const current = session?.current ?? 0;71 const offers = session?.offers ?? [];72 const selectedId = offers.some((o) => o.id === selected) ? selected : offers[0]?.id;73 const checkpointsHit: number[] = Array.isArray(session?.extra.checkpointsHit) ? (session.extra.checkpointsHit as unknown[]).filter((x): x is number => typeof x === "number") : [];74 const bustEvent = phase === "busted" && session ? lastStep(session.log) : undefined;75 const ended = phase === "cashed" || phase === "busted" || phase === "completed";7677 useEffect(() => {78 if (running) onBusy(true);79 }, [running, onBusy]);8081 /* ------------------------------------------------------------ sequence */82 const stopAuto = useCallback(() => {83 autoRef.current = false;84 setAuto(false);85 }, []);8687 const finish = useCallback(88 (s: LadderView) => {89 stopAuto();90 onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 });91 onBusy(false);92 },93 [stopAuto, onResult, onBusy],94 );9596 /** Animate the last step of `s` (climbed from floor `from`); settle end states. Returns true when auto-climb should continue. */97 const animateStep = useCallback(98 async (s: LadderView, from: number): Promise<boolean> => {99 const ev = lastStep(s.log);100 if (ev) {101 setAnim({ ev, from, step: "resolve" });102 sound("tick");103 await wait(dur(ev.outcome === "bust" ? 520 : 420));104 const checkpoint = ev.data?.checkpoint === true;105 if (ev.outcome === "bust") {106 sound("lose");107 setAnim({ ev, from, step: "result" });108 await wait(dur(1100));109 } else {110 setAnim({ ev, from, step: "result" });111 if (checkpoint) {112 sound("bonus");113 setFlourish(ev.stage);114 setTimeout(() => setFlourish(null), dur(1500));115 } else sound("tick");116 await wait(dur(checkpoint ? 700 : 380));117 }118 setAnim(null);119 if (checkpoint && s.status === "running") stopAuto();120 }121 if (s.status === "completed") {122 await wait(dur(300));123 setSummit(true);124 sound("bigWin");125 }126 if (s.status !== "running") {127 finish(s);128 return false;129 }130 if (!autoRef.current) return false;131 await wait(dur(650));132 return autoRef.current;133 },134 [dur, sound, stopAuto, finish],135 );136137 /** Animate `first`, then keep climbing the safe path while auto-climb is on (stops at checkpoints / end states). */138 const runFrom = useCallback(139 async (first: LadderView, from: number) => {140 let s = first;141 let prev = from;142 for (;;) {143 const cont = await animateStep(s, prev);144 if (!cont) return;145 prev = s.stage;146 const res = await act({ type: "continue", offerId: pickSafe(s.offers)?.id });147 if (!res) {148 stopAuto();149 return;150 }151 s = res.session;152 }153 },154 [animateStep, act, stopAuto],155 );156157 const climb = async (offerId: string | undefined) => {158 if (!session) return;159 const res = await act({ type: "continue", offerId });160 if (!res) {161 stopAuto();162 return;163 }164 await runFrom(res.session, session.stage);165 };166167 const onStart = async () => {168 if (locked) return;169 sound("click");170 setSummit(false);171 if (session && session.status !== "running") reset();172 onBusy(true);173 const res = await start(bet);174 if (!res) {175 onBusy(false);176 return;177 }178 await runFrom(res.session, 0);179 };180181 const onClimb = async () => {182 if (!session || locked) return;183 sound("click");184 await climb(selectedId ?? undefined);185 };186187 const onCashout = async () => {188 if (!session || locked) return;189 sound("click");190 stopAuto();191 const res = await act({ type: "cashout" });192 if (!res) return;193 sound("win");194 finish(res.session);195 };196197 const onToggleAuto = () => {198 sound("click");199 const next = !auto;200 autoRef.current = next;201 setAuto(next);202 if (next && session && !locked) void climb(pickSafe(offers)?.id);203 };204205 const onNewRun = () => {206 sound("click");207 setSummit(false);208 reset();209 };210211 /* -------------------------------------------------------------- render */212 const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff";213 const isFork = offers.length > 1;214215 return (216 <div className="absolute inset-0 flex flex-col overflow-hidden" data-scene="escape">217 <div className="pointer-events-none absolute inset-0" style={{ background: `radial-gradient(70% 50% at 50% 100%, ${palette.surface} 0%, transparent 70%), radial-gradient(60% 40% at 50% 0%, ${palette.primary}14 0%, transparent 70%)` }} />218 {/* Bust flash */}219 <AnimatePresence>220 {phase === "busted" || (anim && anim.ev.outcome === "bust" && anim.step === "result") ? (221 <motion.div key="flash" className="pointer-events-none absolute inset-0 z-[3]" style={{ background: `radial-gradient(70% 60% at 50% 50%, ${DANGER}44 0%, transparent 100%)` }} initial={{ opacity: 0 }} animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 0.2, 0.7, 0] }} exit={{ opacity: 0 }} transition={{ duration: sec(1100) }} />222 ) : null}223 </AnimatePresence>224 {/* Summit: the tower opens to the sky */}225 <AnimatePresence>226 {summit ? (227 <motion.div key="sky" className="pointer-events-none absolute inset-0 z-[3]" initial={{ opacity: 0, y: "60%" }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: sec(1600), ease: [0.16, 1, 0.3, 1] }} style={{ background: `linear-gradient(180deg, #e9fff5 0%, ${palette.glow}aa 22%, ${palette.primary}44 55%, transparent 100%)`, mixBlendMode: "screen" }} />228 ) : null}229 </AnimatePresence>230231 <div className="relative z-[2] mx-auto flex h-full w-full max-w-3xl flex-col px-3 pb-2 pt-1">232 {/* Stats */}233 <div className="flex items-end justify-between gap-3">234 <div>235 <div className="eyebrow">{phase === "busted" ? "Run over" : phase === "cashed" ? "Exited" : phase === "completed" ? "Summit" : "Multiplier"}</div>236 <div className="flex items-baseline gap-2">237 <motion.div key={`${phase}-${current}`} initial={{ opacity: 0.4, y: 4 }} animate={{ opacity: 1, y: 0 }} className="text-[clamp(34px,9vw,52px)] font-extrabold leading-none tabular tracking-tight" style={{ color: multiplierColor, textShadow: phase === "running" || phase === "resolve" ? `0 0 28px ${palette.glow}88` : undefined }}>238 {phase === "idle" ? "—" : formatMultiplier(current)}239 </motion.div>240 </div>241 </div>242 <div className="text-center">243 <div className="eyebrow">Floor</div>244 <div className="text-2xl font-extrabold leading-none tabular">245 <motion.span key={floor} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="inline-block" style={{ color: phase === "busted" ? DANGER : palette.glow }}>246 {Math.max(1, floor)}247 </motion.span>248 <span className="text-sm text-fg-3">/{cfg.floors}</span>249 </div>250 </div>251 <div className="text-right">252 <div className="eyebrow">{phase === "busted" ? "Lost" : ended ? "Win" : "Potential win"}</div>253 <div className={cn("text-lg font-bold tabular sm:text-xl", phase === "busted" ? "text-danger" : "text-credit")}>{phase === "idle" ? formatSC(bet) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}</div>254 <div className="text-[11px] text-fg-3 tabular">Bet {formatSC(runBet)}</div>255 </div>256 </div>257258 {/* Tower + room */}259 <div className="mt-2 grid min-h-0 flex-1 grid-cols-[64px_1fr] gap-3 sm:grid-cols-[88px_1fr]">260 <Tower cfg={cfg} floor={Math.max(1, floor)} busted={phase === "busted"} checkpointsHit={checkpointsHit} palette={palette} sec={sec} />261262 <div className="relative flex min-h-0 flex-col justify-center">263 <AnimatePresence mode="wait">264 {phase === "idle" ? (265 <motion.div key="idle" initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.94 }} className="flex flex-col items-center gap-3 text-center">266 <button267 onClick={() => void onStart()}268 disabled={locked}269 className="grid h-[112px] w-[112px] place-items-center rounded-full text-[17px] font-extrabold tracking-[0.14em] transition-transform active:scale-95 focus-ring disabled:opacity-60"270 style={{ background: `radial-gradient(circle at 50% 30%, #e9fff5 0%, ${palette.glow} 18%, ${palette.primary} 60%, #0f6b4a 100%)`, boxShadow: `0 0 0 8px ${palette.primary}2a, 0 0 0 9px ${palette.primary}55, 0 18px 60px -12px ${palette.primary}`, color: "#04140d" }}271 aria-label="Start run"272 >273 START274 </button>275 <span className="rounded-full bg-black/50 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-2 tabular">Bet {formatSC(bet)}</span>276 <p className="max-w-[260px] text-[12px] text-fg-3">{definition.tagline} You enter floor 1 automatically.</p>277 </motion.div>278 ) : anim ? (279 <ResolveCard key={`res-${anim.ev.stage}-${anim.ev.kind}`} res={anim} palette={palette} reduceMotion={reduceMotion} />280 ) : phase === "running" ? (281 <motion.div key={`offers-${session?.stage}`} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: sec(260) }} className="flex flex-col gap-2">282 <div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-fg-3">283 <span>{isFork ? "Fork — choose a corridor" : offers[0]?.kind === "portal" ? "Portal detected" : `Floor ${floor + 1}`}</span>284 <span className="tabular">Next</span>285 </div>286 <div className={cn("grid gap-2", isFork ? "grid-cols-2" : "grid-cols-1")}>287 {offers.map((o) => (288 <OfferCard key={o.id} offer={o} bet={runBet} selected={selectedId === o.id} fork={isFork} onSelect={() => setSelected(o.id)} palette={palette} />289 ))}290 </div>291 </motion.div>292 ) : phase === "busted" && bustEvent ? (293 <motion.div key="bust" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center" style={{ borderColor: `${DANGER}66` }}>294 <Skull className="mx-auto h-10 w-10" style={{ color: DANGER }} />295 <div className="mt-2 text-xl font-extrabold uppercase tracking-tight" style={{ color: DANGER }}>296 {bustEvent.label}297 </div>298 {bustEvent.detail ? <div className="text-[12px] text-fg-3">{bustEvent.detail}</div> : null}299 <div className="mt-2 text-[12px] text-fg-2">300 The run ended on floor {session?.stage ?? 0}. Bet lost: <span className="tabular text-danger">{formatSC(runBet)}</span>301 </div>302 </motion.div>303 ) : phase === "cashed" ? (304 <motion.div key="cashed" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center">305 <ShieldCheck className="mx-auto h-10 w-10" style={{ color: palette.glow }} />306 <div className="mt-2 text-[12px] font-extrabold uppercase tracking-[0.22em]" style={{ color: palette.glow }}>307 Exited at floor {session?.stage}308 </div>309 <div className="text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>310 </motion.div>311 ) : phase === "completed" ? (312 <motion.div key="summit" initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 200, damping: 16 }} className="text-center">313 <Crown className="mx-auto h-12 w-12" style={{ color: palette.glow, filter: `drop-shadow(0 0 18px ${palette.glow})` }} />314 <div className="mt-2 text-[clamp(20px,6vw,30px)] font-extrabold uppercase tracking-tight shimmer-text">Floor 99</div>315 <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-2">The tower opens to the sky</div>316 <div className="mt-2 text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>317 </motion.div>318 ) : null}319 </AnimatePresence>320321 {/* Checkpoint flourish */}322 <AnimatePresence>323 {flourish !== null ? (324 <motion.div key={`cp-${flourish}`} className="pointer-events-none absolute inset-x-0 top-0 flex justify-center" initial={{ opacity: 0, y: 14, scale: 0.8 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -10 }} transition={{ type: "spring", stiffness: 260, damping: 18 }}>325 <div className="flex items-center gap-2 rounded-full px-4 py-1.5 text-[12px] font-extrabold uppercase tracking-[0.2em]" style={{ background: `${palette.primary}22`, border: `1px solid ${palette.primary}88`, color: palette.glow, boxShadow: `0 0 30px -6px ${palette.glow}` }}>326 <Flag className="h-4 w-4" /> Checkpoint {flourish}327 </div>328 </motion.div>329 ) : null}330 </AnimatePresence>331 </div>332 </div>333334 {/* Controls */}335 <div className="mt-2 min-h-[72px]">336 <AnimatePresence mode="wait">337 {phase === "running" || phase === "resolve" ? (338 <motion.div key="run" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="flex flex-col gap-2">339 <div className="grid grid-cols-2 gap-2">340 <button onClick={() => void onCashout()} disabled={locked || !session?.canCashout} className="tap flex h-14 flex-col items-center justify-center rounded-md border text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ borderColor: `${palette.primary}88`, color: palette.glow, background: `${palette.primary}14` }}>341 {definition.presentation.secondaryVerb ?? "CASH OUT"}342 <span className="text-[11px] font-semibold normal-case tracking-normal text-fg-2 tabular">+{formatSC(Math.round(runBet * current))}</span>343 </button>344 <button onClick={() => void onClimb()} disabled={locked || offers.length === 0} className="tap flex h-14 flex-col items-center justify-center rounded-md text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${palette.primary}` }}>345 <span className="inline-flex items-center gap-1">346 <ArrowUp className="h-4 w-4" /> {definition.presentation.verb}347 </span>348 {(() => {349 const o = offers.find((x) => x.id === selectedId);350 return o ? (351 <span className="text-[11px] font-semibold normal-case tracking-normal tabular" style={{ color: "#0a3d2a" }}>352 {Math.round(o.survival * 100)}% safe · {formatMultiplier(o.next)}353 </span>354 ) : null;355 })()}356 </button>357 </div>358 <button role="switch" aria-checked={auto} onClick={onToggleAuto} className="flex h-9 items-center justify-center gap-2 rounded-md text-[12px] font-semibold text-fg-2 focus-ring">359 <span className={cn("relative h-5 w-9 rounded-full border transition-colors", auto ? "border-transparent" : "bg-surface-3 border-line-2")} style={auto ? { background: palette.primary } : undefined}>360 <span className={cn("absolute top-0.5 h-[14px] w-[14px] rounded-full bg-white transition-transform", auto ? "translate-x-[18px]" : "translate-x-0.5")} />361 </span>362 <Zap className="h-3.5 w-3.5" style={{ color: auto ? palette.glow : undefined }} />363 Auto-climb to next checkpoint (safe path)364 </button>365 </motion.div>366 ) : ended ? (367 <motion.div key="end" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-[1fr_auto] gap-2">368 <button onClick={() => void onStart()} disabled={locked} className="tap flex h-14 items-center justify-center gap-2 rounded-md text-[14px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: phase === "busted" ? `linear-gradient(180deg, #ff8aa0, ${DANGER})` : `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${phase === "busted" ? DANGER : palette.primary}` }}>369 <RotateCcw className="h-4 w-4" /> {phase === "busted" ? "Try again" : "Climb again"}370 <span className="text-[11px] font-semibold normal-case tracking-normal tabular opacity-70">{formatSC(bet)}</span>371 </button>372 <Button variant="secondary" size="lg" className="h-14" onClick={onNewRun}>373 New run374 </Button>375 </motion.div>376 ) : (377 <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex h-14 items-center justify-center gap-3 text-[11px] uppercase tracking-wider text-fg-3">378 {cfg.checkpoints.map((c) => (379 <span key={c} className="inline-flex items-center gap-1">380 <Flag className="h-3 w-3" style={{ color: palette.primary }} /> {c}381 </span>382 ))}383 </motion.div>384 )}385 </AnimatePresence>386 </div>387 </div>388389 <AnimatePresence>390 {error ? (391 <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 z-[50] mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">392 <div className="text-fg-2">{error}</div>393 <div className="mt-2 flex justify-center gap-2">394 <Button size="sm" variant="secondary" onClick={clearError}>395 Dismiss396 </Button>397 <Button size="sm" variant="accent" href="/rewards">398 Get rewards399 </Button>400 </div>401 </motion.div>402 ) : null}403 </AnimatePresence>404 </div>405 );406}407408/* ------------------------------------------------------------------ parts */409410type Palette = ArcadeGameProps["definition"]["presentation"]["palette"];411412function Tower({ cfg, floor, busted, checkpointsHit, palette, sec }: { cfg: EscapeConfig; floor: number; busted: boolean; checkpointsHit: number[]; palette: Palette; sec: (ms: number) => number }) {413 const floors = Array.from({ length: cfg.floors }, (_, i) => cfg.floors - i); // 99 … 1414 const index = cfg.floors - floor;415 return (416 <div className="relative h-full min-h-[180px] overflow-hidden rounded-lg" style={{ background: `linear-gradient(180deg, ${palette.surface} 0%, #050807 100%)`, border: `1px solid ${palette.primary}33`, boxShadow: "inset 0 0 40px -10px #000" }} aria-label={`Tower, floor ${floor}`}>417 {/* rails */}418 <div className="pointer-events-none absolute inset-y-0 left-2 w-px" style={{ background: `${palette.primary}33` }} />419 <div className="pointer-events-none absolute inset-y-0 right-2 w-px" style={{ background: `${palette.primary}33` }} />420 <motion.ol className="absolute inset-x-0 top-1/2" initial={false} animate={{ y: -(index * ROW + ROW / 2) }} transition={{ type: "spring", stiffness: 140, damping: 22, duration: sec(500) }}>421 {floors.map((f) => {422 const cp = cfg.checkpoints.includes(f);423 const cur = f === floor;424 const done = f < floor;425 const hit = checkpointsHit.includes(f);426 return (427 <li key={f} className="relative flex items-center justify-center" style={{ height: ROW }}>428 <span className="absolute inset-x-3 top-1/2 h-px" style={{ background: cur ? (busted ? DANGER : palette.glow) : done ? `${palette.primary}55` : "rgba(255,255,255,0.07)" }} />429 <span className={cn("relative z-[1] rounded px-1.5 text-[11px] font-bold tabular leading-none", cur ? "text-black" : done ? "" : "text-fg-4")} style={cur ? { background: busted ? DANGER : palette.glow, boxShadow: `0 0 16px ${busted ? DANGER : palette.glow}` } : done ? { color: palette.primary, background: "#050807" } : { background: "#050807" }}>430 {f}431 </span>432 {cp ? <Flag className="absolute right-3 top-1/2 z-[1] h-3 w-3 -translate-y-1/2" style={{ color: hit ? palette.glow : cur ? palette.glow : palette.secondary, filter: hit ? `drop-shadow(0 0 6px ${palette.glow})` : undefined }} /> : null}433 </li>434 );435 })}436 </motion.ol>437 <div className="pointer-events-none absolute inset-x-0 top-0 h-10" style={{ background: `linear-gradient(180deg, ${palette.surface}, transparent)` }} />438 <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10" style={{ background: "linear-gradient(0deg, #050807, transparent)" }} />439 {/* current-floor marker */}440 <span className="pointer-events-none absolute left-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} />441 <span className="pointer-events-none absolute right-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} />442 </div>443 );444}445446function OfferCard({ offer, bet, selected, fork, onSelect, palette }: { offer: LadderOffer; bet: number; selected: boolean; fork: boolean; onSelect: () => void; palette: Palette }) {447 const Icon = ICONS[offer.kind] ?? DoorOpen;448 const risky = offer.kind === "fork-risky";449 const accent = risky ? palette.secondary : palette.primary;450 const [title, sub] = offer.label.includes(" — ") ? offer.label.split(" — ") : [offer.label, ""];451 return (452 <button453 type="button"454 onClick={onSelect}455 aria-pressed={selected}456 className={cn("tap relative flex w-full flex-col rounded-lg border p-3 text-left transition-all active:scale-[0.99] focus-ring", fork ? "min-h-[132px]" : "min-h-[96px]")}457 style={{ borderColor: selected ? accent : "rgba(255,255,255,0.1)", background: selected ? `linear-gradient(180deg, ${accent}22, ${accent}0a)` : "linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02))", boxShadow: selected ? `0 0 28px -8px ${accent}` : undefined }}458 >459 <div className="flex w-full items-start justify-between gap-2">460 <div className="flex min-w-0 items-center gap-2">461 <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md" style={{ background: `${accent}22`, color: accent }}>462 <Icon className="h-5 w-5" />463 </span>464 <div className="min-w-0">465 <div className="truncate text-[13px] font-bold text-fg">{sub || title}</div>466 {sub ? <div className="truncate text-[11px] text-fg-3">{title}</div> : null}467 </div>468 </div>469 <div className="shrink-0 text-right">470 <div className="text-lg font-extrabold leading-none tabular" style={{ color: accent }}>471 {formatMultiplier(offer.next)}472 </div>473 <div className="text-[10px] text-fg-3 tabular">{formatSC(Math.round(bet * offer.next))}</div>474 </div>475 </div>476 <div className="mt-auto w-full pt-2">477 <div className="flex items-center justify-between text-[11px]">478 <span className="text-fg-3">{offer.description}</span>479 <span className="font-bold tabular" style={{ color: accent }}>480 {Math.round(offer.survival * 100)}%481 </span>482 </div>483 <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-white/10">484 <div className="h-full rounded-full" style={{ width: `${Math.round(offer.survival * 100)}%`, background: accent }} />485 </div>486 </div>487 </button>488 );489}490491function ResolveCard({ res, palette, reduceMotion }: { res: Resolution; palette: Palette; reduceMotion: boolean }) {492 const { ev, step } = res;493 const bust = ev.outcome === "bust";494 const Icon = bust && step === "result" ? Skull : (ICONS[ev.kind] ?? DoorOpen);495 const accent = bust && step === "result" ? DANGER : ev.kind === "fork-risky" ? palette.secondary : palette.glow;496 const motionFor = (): { animate: Record<string, number | number[]>; transition: Record<string, number | string> } => {497 if (reduceMotion || step === "result") return { animate: { scale: bust ? 1.15 : 1, rotate: 0 }, transition: { duration: 0.2 } };498 switch (ev.kind) {499 case "chest":500 return { animate: { scale: [1, 1.35, 1.1], rotate: [0, -12, 8, 0], y: [0, -8, 0] }, transition: { duration: 0.42 } };501 case "enemy":502 return { animate: { rotate: [-50, 40, -10, 0], x: [-14, 14, 0], scale: [1, 1.2, 1] }, transition: { duration: 0.42 } };503 case "trap":504 return { animate: { x: [0, -6, 6, -5, 5, 0], scale: [1, 1.15, 1] }, transition: { duration: 0.42 } };505 case "multiplier":506 return { animate: { scale: [1, 1.5, 1.2], rotate: [0, 180, 360] }, transition: { duration: 0.42 } };507 case "portal":508 return { animate: { rotate: [0, 540], scale: [1, 1.6, 0.6, 1.1] }, transition: { duration: 0.5 } };509 case "fork-safe":510 case "fork-risky":511 return { animate: { x: ev.kind === "fork-safe" ? [30, -6, 0] : [-30, 6, 0], scale: [0.9, 1.15, 1] }, transition: { duration: 0.4 } };512 default:513 return { animate: { y: [0, -14, 0], scale: [1, 1.1, 1] }, transition: { duration: 0.4 } };514 }515 };516 const m = motionFor();517 const headline = step === "resolve" ? ev.detail && bust ? ev.detail : ev.label : bust ? ev.label : SUCCESS[ev.kind] ?? "Cleared";518 return (519 <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.18 }} className="surface relative overflow-hidden rounded-lg p-4 text-center" style={{ borderColor: `${accent}66` }}>520 <motion.div key={step} className="mx-auto grid h-16 w-16 place-items-center rounded-full" style={{ background: `${accent}22`, color: accent, boxShadow: `0 0 34px -8px ${accent}` }} animate={m.animate} transition={m.transition}>521 <Icon className="h-8 w-8" />522 </motion.div>523 {!bust && step === "result" && !reduceMotion ? (524 <>525 {[0, 1, 2, 3, 4, 5].map((i) => (526 <motion.span key={i} className="pointer-events-none absolute left-1/2 top-[38px] h-1.5 w-1.5 rounded-full" style={{ background: accent }} initial={{ x: 0, y: 0, opacity: 1 }} animate={{ x: Math.cos((i / 6) * Math.PI * 2) * 70, y: Math.sin((i / 6) * Math.PI * 2) * 70, opacity: 0 }} transition={{ duration: 0.5, ease: "easeOut" }} />527 ))}528 </>529 ) : null}530 <motion.div key={`${step}-h`} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="mt-3 text-[15px] font-extrabold uppercase tracking-tight" style={{ color: bust && step === "result" ? DANGER : "var(--color-fg)" }}>531 {headline}532 </motion.div>533 <div className="mt-0.5 h-4 text-[12px] text-fg-3">534 {step === "resolve" ? "Resolving…" : bust ? ev.detail ?? "" : ev.data?.checkpoint === true ? "Checkpoint reached" : `Floor ${ev.stage} · ${formatMultiplier(ev.multiplierAfter)}`}535 </div>536 </motion.div>537 );538}539