From e493a12e686cfba951872b0563d627eb1e140272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 23 Aug 2026 05:25:15 +0200 Subject: [PATCH] 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 --- .gitignore | 44 + .npmrc | 3 + AGENTS.md | 9 + CLAUDE.md | 1 + Dockerfile | 20 + README.md | 117 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 42 + app/icon.png | Bin 0 -> 8227 bytes app/layout.tsx | 45 + app/manifest.ts | 28 + app/not-found.tsx | 19 + app/page.tsx | 22 + app/play/page.tsx | 9 + components/FileDropZone.tsx | 62 + components/GameInfoPanel.tsx | 38 + components/RegisterServiceWorker.tsx | 14 + components/hud/CoinDoorHud.tsx | 102 + components/hud/StatsReadout.tsx | 35 + components/hud/useIdleTimer.ts | 33 + components/landing/AttractHero.tsx | 70 + components/pinball/PinballCanvas.tsx | 14 + components/pinball/PinballCanvasImpl.tsx | 69 + components/pinball/PlayView.tsx | 51 + components/ui/Button.tsx | 22 + components/ui/Panel.tsx | 10 + docker-compose.yml | 6 + docker/nginx.conf | 49 + eslint.config.mjs | 21 + lib/format.ts | 12 + lib/pinball/keyBindings.ts | 11 + lib/pinball/tableSelectionStore.ts | 15 + lib/pinball/types.ts | 5 + lib/pinball/usePinballInstance.ts | 104 + lib/pinball/useTouchControls.ts | 38 + lib/pinball/webglSupport.ts | 9 + next.config.ts | 9 + package.json | 31 + pnpm-lock.yaml | 4196 ++++++++++++++++++++++ pnpm-workspace.yaml | 6 + postcss.config.mjs | 7 + public/icons/favicon-32.png | Bin 0 -> 1070 bytes public/icons/icon-192.png | Bin 0 -> 8227 bytes public/icons/icon-512-maskable.png | Bin 0 -> 23053 bytes public/icons/icon-512.png | Bin 0 -> 23053 bytes public/icons/icon-source.svg | 7 + public/icons/icon.svg | 7 + public/sw.js | 77 + scripts/bump-sw-version.mjs | 21 + scripts/copy-engine-assets.mjs | 26 + tsconfig.json | 34 + 51 files changed, 5570 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/icon.png create mode 100644 app/layout.tsx create mode 100644 app/manifest.ts create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/play/page.tsx create mode 100644 components/FileDropZone.tsx create mode 100644 components/GameInfoPanel.tsx create mode 100644 components/RegisterServiceWorker.tsx create mode 100644 components/hud/CoinDoorHud.tsx create mode 100644 components/hud/StatsReadout.tsx create mode 100644 components/hud/useIdleTimer.ts create mode 100644 components/landing/AttractHero.tsx create mode 100644 components/pinball/PinballCanvas.tsx create mode 100644 components/pinball/PinballCanvasImpl.tsx create mode 100644 components/pinball/PlayView.tsx create mode 100644 components/ui/Button.tsx create mode 100644 components/ui/Panel.tsx create mode 100644 docker-compose.yml create mode 100644 docker/nginx.conf create mode 100644 eslint.config.mjs create mode 100644 lib/format.ts create mode 100644 lib/pinball/keyBindings.ts create mode 100644 lib/pinball/tableSelectionStore.ts create mode 100644 lib/pinball/types.ts create mode 100644 lib/pinball/usePinballInstance.ts create mode 100644 lib/pinball/useTouchControls.ts create mode 100644 lib/pinball/webglSupport.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 public/icons/favicon-32.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512-maskable.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-source.svg create mode 100644 public/icons/icon.svg create mode 100644 public/sw.js create mode 100644 scripts/bump-sw-version.mjs create mode 100644 scripts/copy-engine-assets.mjs create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a8f5220f3c2d30f8cd73ae3ad1f3eaaf7d9af291 GIT binary patch literal 8227 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 literal 0 HcmV?d00001 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