"use client"; /** * Escape 99 — Spinza Original (ladder). A fast roguelike tower: 99 floors, * room by room, cash out on any of them. The tower strip scrolls as the player * climbs; every CLIMB animates the room resolution returned by the server. */ import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowUp, Crown, DoorOpen, Flag, Flame, Gem, Orbit, RotateCcw, Shield, ShieldCheck, Skull, Sparkles, Sword, TriangleAlert, Zap, type LucideIcon } from "lucide-react"; import { formatMultiplier, formatSC } from "@spinza/shared"; import type { LadderEvent, LadderOffer } from "@spinza/game-core/client"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui"; import { useLadder, type ArcadeGameProps, type LadderView } from "./contract"; interface EscapeConfig { floors: number; bands: [number, number][]; checkpoints: number[]; rooms: { kind: string; label: string; weight: number; detail: string }[]; forkChance: number; portalChance: number; } /** Room resolution being animated from the returned log tail. */ interface Resolution { ev: LadderEvent; from: number; step: "resolve" | "result"; } type Phase = "idle" | "resolve" | "running" | "cashed" | "busted" | "completed"; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const ROW = 22; const DANGER = "#ff5c7a"; const ICONS: Record = { chest: Gem, enemy: Sword, trap: TriangleAlert, multiplier: Sparkles, room: DoorOpen, portal: Orbit, "fork-safe": Shield, "fork-risky": Flame, start: DoorOpen, summit: Crown }; const SUCCESS: Record = { 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" }; function lastStep(log: LadderEvent[]): LadderEvent | undefined { for (let i = log.length - 1; i >= 0; i--) if (log[i].outcome === "ok" || log[i].outcome === "bust") return log[i]; return undefined; } function pickSafe(offers: LadderOffer[]): LadderOffer | undefined { return offers.find((o) => o.kind === "fork-safe") ?? offers[0]; } export function EscapeGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { const cfg = definition.config as unknown as EscapeConfig; const palette = definition.presentation.palette; const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug); const [anim, setAnim] = useState(null); const [selected, setSelected] = useState(null); const [flourish, setFlourish] = useState(null); const [summit, setSummit] = useState(false); const [auto, setAuto] = useState(false); const autoRef = useRef(false); const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]); const sec = (ms: number) => dur(ms) / 1000; /* ------------------------------------------------------------ derived */ const phase: Phase = anim ? "resolve" : !session ? "idle" : session.status; const running = session?.status === "running"; const locked = busy || anim !== null; const floor = anim ? (anim.step === "resolve" || anim.ev.outcome === "bust" ? anim.from : anim.ev.stage) : (session?.stage ?? 0); const runBet = session?.bet ?? bet; const current = session?.current ?? 0; const offers = session?.offers ?? []; const selectedId = offers.some((o) => o.id === selected) ? selected : offers[0]?.id; const checkpointsHit: number[] = Array.isArray(session?.extra.checkpointsHit) ? (session.extra.checkpointsHit as unknown[]).filter((x): x is number => typeof x === "number") : []; const bustEvent = phase === "busted" && session ? lastStep(session.log) : undefined; const ended = phase === "cashed" || phase === "busted" || phase === "completed"; useEffect(() => { if (running) onBusy(true); }, [running, onBusy]); /* ------------------------------------------------------------ sequence */ const stopAuto = useCallback(() => { autoRef.current = false; setAuto(false); }, []); const finish = useCallback( (s: LadderView) => { stopAuto(); onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 }); onBusy(false); }, [stopAuto, onResult, onBusy], ); /** Animate the last step of `s` (climbed from floor `from`); settle end states. Returns true when auto-climb should continue. */ const animateStep = useCallback( async (s: LadderView, from: number): Promise => { const ev = lastStep(s.log); if (ev) { setAnim({ ev, from, step: "resolve" }); sound("tick"); await wait(dur(ev.outcome === "bust" ? 520 : 420)); const checkpoint = ev.data?.checkpoint === true; if (ev.outcome === "bust") { sound("lose"); setAnim({ ev, from, step: "result" }); await wait(dur(1100)); } else { setAnim({ ev, from, step: "result" }); if (checkpoint) { sound("bonus"); setFlourish(ev.stage); setTimeout(() => setFlourish(null), dur(1500)); } else sound("tick"); await wait(dur(checkpoint ? 700 : 380)); } setAnim(null); if (checkpoint && s.status === "running") stopAuto(); } if (s.status === "completed") { await wait(dur(300)); setSummit(true); sound("bigWin"); } if (s.status !== "running") { finish(s); return false; } if (!autoRef.current) return false; await wait(dur(650)); return autoRef.current; }, [dur, sound, stopAuto, finish], ); /** Animate `first`, then keep climbing the safe path while auto-climb is on (stops at checkpoints / end states). */ const runFrom = useCallback( async (first: LadderView, from: number) => { let s = first; let prev = from; for (;;) { const cont = await animateStep(s, prev); if (!cont) return; prev = s.stage; const res = await act({ type: "continue", offerId: pickSafe(s.offers)?.id }); if (!res) { stopAuto(); return; } s = res.session; } }, [animateStep, act, stopAuto], ); const climb = async (offerId: string | undefined) => { if (!session) return; const res = await act({ type: "continue", offerId }); if (!res) { stopAuto(); return; } await runFrom(res.session, session.stage); }; const onStart = async () => { if (locked) return; sound("click"); setSummit(false); if (session && session.status !== "running") reset(); onBusy(true); const res = await start(bet); if (!res) { onBusy(false); return; } await runFrom(res.session, 0); }; const onClimb = async () => { if (!session || locked) return; sound("click"); await climb(selectedId ?? undefined); }; const onCashout = async () => { if (!session || locked) return; sound("click"); stopAuto(); const res = await act({ type: "cashout" }); if (!res) return; sound("win"); finish(res.session); }; const onToggleAuto = () => { sound("click"); const next = !auto; autoRef.current = next; setAuto(next); if (next && session && !locked) void climb(pickSafe(offers)?.id); }; const onNewRun = () => { sound("click"); setSummit(false); reset(); }; /* -------------------------------------------------------------- render */ const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff"; const isFork = offers.length > 1; return (
{/* Bust flash */} {phase === "busted" || (anim && anim.ev.outcome === "bust" && anim.step === "result") ? ( ) : null} {/* Summit: the tower opens to the sky */} {summit ? ( ) : null}
{/* Stats */}
{phase === "busted" ? "Run over" : phase === "cashed" ? "Exited" : phase === "completed" ? "Summit" : "Multiplier"}
{phase === "idle" ? "—" : formatMultiplier(current)}
Floor
{Math.max(1, floor)} /{cfg.floors}
{phase === "busted" ? "Lost" : ended ? "Win" : "Potential win"}
{phase === "idle" ? formatSC(bet) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}
Bet {formatSC(runBet)}
{/* Tower + room */}
{phase === "idle" ? ( Bet {formatSC(bet)}

{definition.tagline} You enter floor 1 automatically.

) : anim ? ( ) : phase === "running" ? (
{isFork ? "Fork — choose a corridor" : offers[0]?.kind === "portal" ? "Portal detected" : `Floor ${floor + 1}`} Next
{offers.map((o) => ( setSelected(o.id)} palette={palette} /> ))}
) : phase === "busted" && bustEvent ? (
{bustEvent.label}
{bustEvent.detail ?
{bustEvent.detail}
: null}
The run ended on floor {session?.stage ?? 0}. Bet lost: {formatSC(runBet)}
) : phase === "cashed" ? (
Exited at floor {session?.stage}
+{formatSC(session?.win ?? 0)}
) : phase === "completed" ? (
Floor 99
The tower opens to the sky
+{formatSC(session?.win ?? 0)}
) : null}
{/* Checkpoint flourish */} {flourish !== null ? (
Checkpoint {flourish}
) : null}
{/* Controls */}
{phase === "running" || phase === "resolve" ? (
) : ended ? ( ) : ( {cfg.checkpoints.map((c) => ( {c} ))} )}
{error ? (
{error}
) : null}
); } /* ------------------------------------------------------------------ parts */ type Palette = ArcadeGameProps["definition"]["presentation"]["palette"]; function Tower({ cfg, floor, busted, checkpointsHit, palette, sec }: { cfg: EscapeConfig; floor: number; busted: boolean; checkpointsHit: number[]; palette: Palette; sec: (ms: number) => number }) { const floors = Array.from({ length: cfg.floors }, (_, i) => cfg.floors - i); // 99 … 1 const index = cfg.floors - floor; return (
{/* rails */}
{floors.map((f) => { const cp = cfg.checkpoints.includes(f); const cur = f === floor; const done = f < floor; const hit = checkpointsHit.includes(f); return (
  • {f} {cp ? : null}
  • ); })}
    {/* current-floor marker */}
    ); } function OfferCard({ offer, bet, selected, fork, onSelect, palette }: { offer: LadderOffer; bet: number; selected: boolean; fork: boolean; onSelect: () => void; palette: Palette }) { const Icon = ICONS[offer.kind] ?? DoorOpen; const risky = offer.kind === "fork-risky"; const accent = risky ? palette.secondary : palette.primary; const [title, sub] = offer.label.includes(" — ") ? offer.label.split(" — ") : [offer.label, ""]; return ( ); } function ResolveCard({ res, palette, reduceMotion }: { res: Resolution; palette: Palette; reduceMotion: boolean }) { const { ev, step } = res; const bust = ev.outcome === "bust"; const Icon = bust && step === "result" ? Skull : (ICONS[ev.kind] ?? DoorOpen); const accent = bust && step === "result" ? DANGER : ev.kind === "fork-risky" ? palette.secondary : palette.glow; const motionFor = (): { animate: Record; transition: Record } => { if (reduceMotion || step === "result") return { animate: { scale: bust ? 1.15 : 1, rotate: 0 }, transition: { duration: 0.2 } }; switch (ev.kind) { case "chest": return { animate: { scale: [1, 1.35, 1.1], rotate: [0, -12, 8, 0], y: [0, -8, 0] }, transition: { duration: 0.42 } }; case "enemy": return { animate: { rotate: [-50, 40, -10, 0], x: [-14, 14, 0], scale: [1, 1.2, 1] }, transition: { duration: 0.42 } }; case "trap": return { animate: { x: [0, -6, 6, -5, 5, 0], scale: [1, 1.15, 1] }, transition: { duration: 0.42 } }; case "multiplier": return { animate: { scale: [1, 1.5, 1.2], rotate: [0, 180, 360] }, transition: { duration: 0.42 } }; case "portal": return { animate: { rotate: [0, 540], scale: [1, 1.6, 0.6, 1.1] }, transition: { duration: 0.5 } }; case "fork-safe": case "fork-risky": return { animate: { x: ev.kind === "fork-safe" ? [30, -6, 0] : [-30, 6, 0], scale: [0.9, 1.15, 1] }, transition: { duration: 0.4 } }; default: return { animate: { y: [0, -14, 0], scale: [1, 1.1, 1] }, transition: { duration: 0.4 } }; } }; const m = motionFor(); const headline = step === "resolve" ? ev.detail && bust ? ev.detail : ev.label : bust ? ev.label : SUCCESS[ev.kind] ?? "Cleared"; return ( {!bust && step === "result" && !reduceMotion ? ( <> {[0, 1, 2, 3, 4, 5].map((i) => ( ))} ) : null} {headline}
    {step === "resolve" ? "Resolving…" : bust ? ev.detail ?? "" : ev.data?.checkpoint === true ? "Checkpoint reached" : `Floor ${ev.stage} · ${formatMultiplier(ev.multiplierAfter)}`}
    ); }