commit e493a12e686cfba951872b0563d627eb1e140272 Author: Sebastian Krüger Date: Sun Aug 23 05:25:15 2026 +0200 Add vpinball: a browser Visual Pinball player Next.js app that plays real .vpx tables via @valknar/vpinball-wasm (WebAssembly Visual Pinball). Demo mode with the bundled default table, drag-and-drop custom table upload, a cabinet-styled UI with a coin-door HUD (fullscreen/stats/info/eject), a custom 404 page, PWA support (hand-rolled service worker), and a static Docker Compose deployment behind nginx. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012hQoM3jJT1Lx7CMTciMzvD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf56352 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# vpinball-wasm engine assets, copied from node_modules at build time +/public/vendor/ + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6d7ea5 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# @valknar/vpinball-wasm is published to a private Gitea npm registry. +# It's publicly readable, so no auth token is needed here. +@valknar:registry=https://dev.pivoine.art/api/packages/valknar/npm/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7c1355 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +FROM node:22-alpine AS base +ENV CI=true +RUN corepack enable + +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* .npmrc* ./ +RUN pnpm install --frozen-lockfile --trust-lockfile + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +FROM nginx:1.27-alpine AS runner +COPY --from=builder /app/out /usr/share/nginx/html +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff5d91c --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# vpinball + +Play real `.vpx` pinball tables in your browser. Drop in your own table or try the bundled demo — +no plugins, no native install. Powered by [`@valknar/vpinball-wasm`](https://dev.pivoine.art/valknar/vpinball-wasm), +a WebAssembly build of the real [Visual Pinball](https://github.com/vpinball/vpinball) engine +(real physics, real VBScript table scripting, real WebGL2 rendering). + +## Features + +- **Play any self-contained `.vpx` table** — drag a file onto the landing page, or click to browse. +- **Demo mode** — a bundled table plays instantly with no upload required. +- **Fullscreen**, a live **FPS/stats** readout, and a **game info** panel (table name, size, load + time) in an in-game HUD styled after a pinball cabinet's coin door. +- **Installable as a PWA** — add it to your home screen or desktop; the engine is cached after + your first game so it's available offline afterward. +- **Deployable as a static site** via Docker Compose — no Node server at runtime, just nginx + serving prebuilt files. + +## Prerequisites + +- Node.js ≥ 20, [pnpm](https://pnpm.io) ≥ 11 +- Docker + Docker Compose, for the containerized deploy + +## Install + +```bash +pnpm install --frozen-lockfile --trust-lockfile +``` + +`vpinball-wasm` is published to a private Gitea npm registry +(`https://dev.pivoine.art/api/packages/valknar/npm/`) under the `@valknar` scope. `.npmrc` maps +that scope to the registry; the package is publicly readable, so no auth token is needed to +install it. + +To bump the engine to a newer published version: + +```bash +pnpm add @valknar/vpinball-wasm@ +``` + +## Development + +```bash +pnpm dev +``` + +`pnpm dev` copies the engine's build output into `public/vendor/vpinball-wasm/` first (via the +`predev` script) — it's gitignored and regenerated from `node_modules`, so this only needs +`pnpm install` to have completed. Re-run `pnpm run copy-engine-assets` by hand if you update the +dependency without restarting the dev server. + +The dev server runs at `http://localhost:3000`. + +## Build + +```bash +pnpm build +``` + +Produces a fully static export in `out/` (`next.config.ts` sets `output: "export"`) — no Node +server needed to serve it. + +## Docker deploy + +```bash +docker compose build +docker compose up -d +``` + +Serves the static build via nginx on `http://localhost:8080`. No secrets or registry +authentication are required to build the image. + +## PWA + +The app can be installed from the browser's install prompt. A hand-rolled service worker +(`public/sw.js`) precaches the app shell; the engine's `.wasm`/`.data` files (~34 MB) are cached +on first visit to `/play`, not upfront, so installing the app doesn't force a large download +before anyone has actually played. After that first visit, offline reloads work. + +When you ship a release that changes the app shell or updates the vendored engine build, bump the +cache version so returning visitors actually get the update: + +```bash +pnpm run sw:bump +``` + +## Controls + +| Key | Action | +| -------------- | ------------- | +| Left Shift | Left flipper | +| Right Shift | Right flipper | +| Enter | Plunger | +| 1 | Start game | + +On touch devices, an on-screen control overlay appears automatically once a table is running. + +## Limitations + +Inherited from the underlying engine: + +- Only self-contained `.vpx` tables are supported — tables that reference an external `.vbs` + script override or a `Music/` folder won't load correctly. +- No PinMAME, DOF, or FlexDMD plugin support. +- Single-threaded (no `SharedArrayBuffer`) — performance depends on your device's single-core + WebGL2 throughput. +- Initial engine download is ~34 MB (cached after the first visit). +- Gamepad input and DMD rendering are implemented but not yet confirmed on real hardware/tables. +- Raw cabinet hardware input (real nudge/plunger boards) has no browser equivalent. + +## License + +This app's own source has no license file yet — add one before distributing it. Separately, the +built output bundles the `@valknar/vpinball-wasm` engine and its default table, which redistribute +Visual Pinball source under a **mixed legacy/GPLv3+ license** (migrating file-by-file to GPLv3+ +since October 2020) — see that package's own `LICENSE` for the authoritative terms before +distributing a build. diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..9209844 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,42 @@ +@import "tailwindcss"; + +@theme { + /* Cabinet palette — grounded in real pinball-machine parts, not a generic dark theme. */ + --color-ink: #0b0b0f; /* cabinet body / page background */ + --color-ash: #1b1c22; /* panel surface */ + --color-chrome: #a9aebb; /* metal bezel, borders, muted text */ + --color-marquee: #ff7a29; /* GI lamp amber — primary accent, CTAs */ + --color-arc: #4ce0d2; /* fluorescent-tube cyan — focus rings, secondary */ + --color-paper: #f3efe6; /* aged-backglass cream — high-contrast body text */ + + --font-display: var(--font-big-shoulders), "Arial Narrow", sans-serif; + --font-body: var(--font-space-grotesk), system-ui, sans-serif; + --font-mono: var(--font-plex-mono), "IBM Plex Mono", ui-monospace, monospace; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +body { + background: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-body); +} + +::selection { + background: var(--color-marquee); + color: var(--color-ink); +} + +:focus-visible { + outline: 2px solid var(--color-arc); + outline-offset: 2px; +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..a8f5220 Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ffe99d0 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata, Viewport } from "next"; +import { Big_Shoulders, Space_Grotesk, IBM_Plex_Mono } from "next/font/google"; +import RegisterServiceWorker from "@/components/RegisterServiceWorker"; +import "./globals.css"; + +const bigShoulders = Big_Shoulders({ + variable: "--font-big-shoulders", + weight: ["700", "900"], + subsets: ["latin"], +}); + +const spaceGrotesk = Space_Grotesk({ + variable: "--font-space-grotesk", + subsets: ["latin"], +}); + +const plexMono = IBM_Plex_Mono({ + variable: "--font-plex-mono", + weight: ["400", "500", "600"], + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "vpinball — Visual Pinball in your browser", + description: + "Play real .vpx pinball tables in the browser, powered by a WebAssembly build of Visual Pinball. Drop in your own table or play the bundled demo.", +}; + +export const viewport: Viewport = { + themeColor: "#0b0b0f", +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + + {children} + + + + ); +} diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..275a18e --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,28 @@ +import type { MetadataRoute } from "next"; + +// Required under output: "export" — a manifest route has no per-request +// dynamic behavior anyway, so this just makes that explicit to the build. +export const dynamic = "force-static"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "vpinball — Visual Pinball in your browser", + short_name: "vpinball", + description: "Play real .vpx pinball tables in the browser, powered by WebAssembly.", + start_url: "/", + display: "fullscreen", + background_color: "#0b0b0f", + theme_color: "#0b0b0f", + icons: [ + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + { + src: "/icons/icon-512-maskable.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + { src: "/icons/icon.svg", sizes: "any", type: "image/svg+xml" }, + ], + }; +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..6b33987 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; +import { buttonClassName } from "@/components/ui/Button"; + +export default function NotFound() { + return ( +
+

TILT

+

+ TABLE NOT FOUND +

+

+ That page drained down the outlane. Head back to the cabinet and pick a table. +

+ + Back to Start + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..c924d2d --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,22 @@ +import AttractHero from "@/components/landing/AttractHero"; + +export default function Home() { + return ( +
+ +

+ Pinball plays best in landscape or fullscreen — rotate your device once a table loads. +

+ +
+ ); +} diff --git a/app/play/page.tsx b/app/play/page.tsx new file mode 100644 index 0000000..7c1e7ba --- /dev/null +++ b/app/play/page.tsx @@ -0,0 +1,9 @@ +import PlayView from "@/components/pinball/PlayView"; + +export default function PlayPage() { + return ( +
+ +
+ ); +} diff --git a/components/FileDropZone.tsx b/components/FileDropZone.tsx new file mode 100644 index 0000000..6c524aa --- /dev/null +++ b/components/FileDropZone.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState, type ChangeEvent, type DragEvent, type ReactNode } from "react"; + +export const VPX_FILE_INPUT_ID = "vpx-file-input"; + +export interface FileDropZoneProps { + onFile: (file: File) => void; + onError: (message: string) => void; + children: ReactNode; + className?: string; +} + +function validate(file: File | undefined, onFile: (file: File) => void, onError: (m: string) => void) { + if (!file) return; + if (!file.name.toLowerCase().endsWith(".vpx")) { + onError(`"${file.name}" isn't a .vpx file.`); + return; + } + onFile(file); +} + +export default function FileDropZone({ onFile, onError, children, className = "" }: FileDropZoneProps) { + const [isDragging, setIsDragging] = useState(false); + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + validate(e.dataTransfer.files[0], onFile, onError); + }; + + const handleInputChange = (e: ChangeEvent) => { + validate(e.target.files?.[0], onFile, onError); + e.target.value = ""; + }; + + return ( +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + className={`relative ${className}`} + > + {children} + + {isDragging && ( +
+ DROP TO LOAD +
+ )} +
+ ); +} diff --git a/components/GameInfoPanel.tsx b/components/GameInfoPanel.tsx new file mode 100644 index 0000000..b30656f --- /dev/null +++ b/components/GameInfoPanel.tsx @@ -0,0 +1,38 @@ +import { formatBytes, formatDuration } from "@/lib/format"; +import { KEY_BINDINGS } from "@/lib/pinball/keyBindings"; +import type { TableInfo } from "@/lib/pinball/types"; + +export interface GameInfoPanelProps { + tableInfo: TableInfo; + loadTimeMs: number | null; +} + +export default function GameInfoPanel({ tableInfo, loadTimeMs }: GameInfoPanelProps) { + return ( +
+
+
TABLE
+
{tableInfo.name}
+
+
+
Source
+
{tableInfo.isDemo ? "Bundled demo" : "Your upload"}
+
Size
+
{tableInfo.isDemo ? "—" : formatBytes(tableInfo.sizeBytes)}
+
Load time
+
{loadTimeMs !== null ? formatDuration(loadTimeMs) : "—"}
+
+
+
CONTROLS
+
    + {KEY_BINDINGS.map((binding) => ( +
  • + {binding.action} + {binding.keys} +
  • + ))} +
+
+
+ ); +} diff --git a/components/RegisterServiceWorker.tsx b/components/RegisterServiceWorker.tsx new file mode 100644 index 0000000..5e9d812 --- /dev/null +++ b/components/RegisterServiceWorker.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect } from "react"; + +export default function RegisterServiceWorker() { + useEffect(() => { + if (!("serviceWorker" in navigator)) return; + navigator.serviceWorker.register("/sw.js").catch((err) => { + console.error("Service worker registration failed:", err); + }); + }, []); + + return null; +} diff --git a/components/hud/CoinDoorHud.tsx b/components/hud/CoinDoorHud.tsx new file mode 100644 index 0000000..05647ef --- /dev/null +++ b/components/hud/CoinDoorHud.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { PinballInstance } from "@valknar/vpinball-wasm"; +import { useIdleTimer } from "./useIdleTimer"; +import StatsReadout from "./StatsReadout"; +import GameInfoPanel from "@/components/GameInfoPanel"; +import type { TableInfo } from "@/lib/pinball/types"; + +export interface CoinDoorHudProps { + instanceRef: React.RefObject; + tableInfo: TableInfo; + loadTimeMs: number | null; +} + +function HudButton({ + label, + active, + onClick, +}: { + label: string; + active?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export default function CoinDoorHud({ instanceRef, tableInfo, loadTimeMs }: CoinDoorHudProps) { + const router = useRouter(); + const isIdle = useIdleTimer(3000); + const [showStats, setShowStats] = useState(false); + const [showInfo, setShowInfo] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, []); + + const handleFullscreen = () => { + instanceRef.current?.requestFullscreen(); + }; + + const handleEject = () => { + // dispose() (called by usePinballInstance's unmount cleanup, triggered by + // this navigation) tears the instance down immediately. Calling stop() + // here too raced it — stop() defers teardown to the engine's next + // internal step, and dispose() freeing WASM memory before that step runs + // hung the tab. + router.push("/"); + }; + + return ( + <> +
+
+ VPINBALL + + {tableInfo.name} + +
+
+ {showStats && } + setShowInfo((v) => !v)} /> + setShowStats((v) => !v)} /> + + +
+
+ + {showInfo && ( +
+ +
+ )} + + ); +} diff --git a/components/hud/StatsReadout.tsx b/components/hud/StatsReadout.tsx new file mode 100644 index 0000000..a5fdad7 --- /dev/null +++ b/components/hud/StatsReadout.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** Self-contained rAF-based FPS counter — the engine exposes no perf API of its own. */ +export default function StatsReadout() { + const [fps, setFps] = useState(null); + + useEffect(() => { + let frameCount = 0; + let windowStart = performance.now(); + let raf = 0; + + const tick = (now: number) => { + frameCount += 1; + const elapsed = now - windowStart; + if (elapsed >= 500) { + setFps(Math.round((frameCount * 1000) / elapsed)); + frameCount = 0; + windowStart = now; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + + return () => cancelAnimationFrame(raf); + }, []); + + return ( +
+ FPS + {fps ?? "--"} +
+ ); +} diff --git a/components/hud/useIdleTimer.ts b/components/hud/useIdleTimer.ts new file mode 100644 index 0000000..03f43ec --- /dev/null +++ b/components/hud/useIdleTimer.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** True once no pointer/keyboard activity has been seen for `timeoutMs`. */ +export function useIdleTimer(timeoutMs = 3000): boolean { + const [isIdle, setIsIdle] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + const reset = () => { + setIsIdle(false); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setIsIdle(true), timeoutMs); + }; + + reset(); + const events: Array = [ + "mousemove", + "pointerdown", + "touchstart", + "keydown", + ]; + events.forEach((event) => window.addEventListener(event, reset, { passive: true })); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + events.forEach((event) => window.removeEventListener(event, reset)); + }; + }, [timeoutMs]); + + return isIdle; +} diff --git a/components/landing/AttractHero.tsx b/components/landing/AttractHero.tsx new file mode 100644 index 0000000..b4422e2 --- /dev/null +++ b/components/landing/AttractHero.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import Button, { buttonClassName } from "@/components/ui/Button"; +import FileDropZone, { VPX_FILE_INPUT_ID } from "@/components/FileDropZone"; +import { setPendingTable } from "@/lib/pinball/tableSelectionStore"; + +export default function AttractHero() { + const router = useRouter(); + const [error, setError] = useState(null); + + const handleFile = (file: File) => { + setError(null); + setPendingTable(file); + router.push("/play"); + }; + + return ( + +
+ {/* Backglass glow — stands in for real playfield art; GI-lamp amber + a cyan tube accent. */} +
+
+ +
+

WEBASSEMBLY · WEBGL2 · REAL .VPX TABLES

+

+ VPINBALL +

+

+ Real Visual Pinball tables, playing in your browser. Drop in a{" "} + .vpx file, or try the bundled demo. +

+ +
+ + +
+ +

or drag a .vpx file anywhere onto this panel

+ + {error && ( +

+ {error} +

+ )} +
+
+ + ); +} diff --git a/components/pinball/PinballCanvas.tsx b/components/pinball/PinballCanvas.tsx new file mode 100644 index 0000000..e312ffc --- /dev/null +++ b/components/pinball/PinballCanvas.tsx @@ -0,0 +1,14 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { PinballCanvasImplProps } from "./PinballCanvasImpl"; + +// The engine touches window/canvas/WebGL as soon as it mounts, so it's kept +// out of the server render entirely rather than just deferring to an effect. +const PinballCanvasImpl = dynamic(() => import("./PinballCanvasImpl"), { + ssr: false, +}); + +export default function PinballCanvas(props: PinballCanvasImplProps) { + return ; +} diff --git a/components/pinball/PinballCanvasImpl.tsx b/components/pinball/PinballCanvasImpl.tsx new file mode 100644 index 0000000..57c8d91 --- /dev/null +++ b/components/pinball/PinballCanvasImpl.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRef } from "react"; +import { usePinballInstance } from "@/lib/pinball/usePinballInstance"; +import { useTouchControls } from "@/lib/pinball/useTouchControls"; +import CoinDoorHud from "@/components/hud/CoinDoorHud"; +import type { TableInfo } from "@/lib/pinball/types"; + +export interface PinballCanvasImplProps { + tableData?: ArrayBuffer; + tableInfo: TableInfo; +} + +export default function PinballCanvasImpl({ tableData, tableInfo }: PinballCanvasImplProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance( + canvasRef, + tableData, + ); + useTouchControls(containerRef, status === "running"); + + return ( +
+ {/* SDL3's Emscripten backend resolves its own window/GL-context target via + document.querySelector("#canvas") independently of Module.canvas — the + id is required or context creation silently no-ops (findCanvasEventTarget + returns null, emscripten_webgl_create_context returns handle 0, and the + engine crashes later reading an unset GL context). */} + + + {(status === "ready" || status === "running") && ( + + )} + + {status === "loading" && ( +
+
LOADING TABLE
+
+
+
+
{Math.round(progress * 100)}%
+
+ )} + + {status === "error" && ( +
+
TABLE NOT LOADING
+

{error}

+
+ )} + + {status === "ready" && ( +
+ +
+ )} +
+ ); +} diff --git a/components/pinball/PlayView.tsx b/components/pinball/PlayView.tsx new file mode 100644 index 0000000..deb5763 --- /dev/null +++ b/components/pinball/PlayView.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, useState } from "react"; +import PinballCanvas from "./PinballCanvas"; +import { takePendingTable } from "@/lib/pinball/tableSelectionStore"; +import type { TableInfo } from "@/lib/pinball/types"; + +interface ResolvedTable { + tableData?: ArrayBuffer; + tableInfo: TableInfo; +} + +export default function PlayView() { + const [resolved, setResolved] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const file = takePendingTable(); + if (file) { + const tableData = await file.arrayBuffer(); + if (cancelled) return; + setResolved({ + tableData, + tableInfo: { name: file.name, sizeBytes: file.size, isDemo: false }, + }); + return; + } + // No pending upload (fresh visit, or a hard refresh of /play) — fall + // back to the bundled demo table rather than dead-ending the route. + setResolved({ + tableInfo: { name: "Demo table", sizeBytes: 0, isDemo: true }, + }); + })(); + return () => { + cancelled = true; + }; + }, []); + + if (!resolved) { + return ( +
+ Preparing table… +
+ ); + } + + return ( + + ); +} diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx new file mode 100644 index 0000000..0d8d3b7 --- /dev/null +++ b/components/ui/Button.tsx @@ -0,0 +1,22 @@ +import type { ButtonHTMLAttributes } from "react"; + +export type ButtonVariant = "primary" | "outline" | "quiet"; + +const variantClasses: Record = { + primary: + "border-2 border-marquee bg-marquee/10 text-marquee shadow-[0_0_24px_rgba(255,122,41,0.35)] hover:bg-marquee/20", + outline: "border-2 border-chrome/50 text-paper hover:border-arc hover:text-arc", + quiet: "border border-transparent text-chrome hover:text-paper", +}; + +export function buttonClassName(variant: ButtonVariant = "primary", className = ""): string { + return `inline-block rounded-full px-6 py-3 font-display text-lg tracking-wide transition ${variantClasses[variant]} ${className}`; +} + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; +} + +export default function Button({ variant = "primary", className = "", ...props }: ButtonProps) { + return