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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hQoM3jJT1Lx7CMTciMzvD
This commit is contained in:
2026-08-23 05:25:15 +02:00
co-authored by Claude Sonnet 5
commit e493a12e68
51 changed files with 5570 additions and 0 deletions
+51
View File
@@ -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<ResolvedTable | null>(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 (
<div className="flex h-full w-full items-center justify-center bg-ink font-mono text-chrome">
Preparing table
</div>
);
}
return (
<PinballCanvas tableData={resolved.tableData} tableInfo={resolved.tableInfo} key={resolved.tableInfo.name} />
);
}