Files
vpinball/components/pinball/PlayView.tsx
T
valknarandClaude Sonnet 5 35fb68c916 Add ROM upload, local table library, engine bump, and UX/responsiveness fixes
- Bump @valknar/vpinball-wasm to 0.3.2 (PinMAME ROM support, MsgBox fix)
- Add optional PinMAME ROM .zip upload alongside .vpx tables
- Persist uploaded tables/ROMs in IndexedDB so users don't have to
  re-upload on later visits, with a library list on the landing page
- Fix Insert Coin key mismatch with the engine's real coin-door gating
  (now bound to 4, matching the shared table scripts) and surface it
  in the controls legend and "Tap to Start" hint
- Make the landing page, staging panel, and in-game HUD responsive for
  portrait phone widths; drop the landscape hint since portrait plays fine
- Replace the separate STATS HUD toggle with an FPS row in the info panel

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9JmzRBZenapVkFWjw9jmG
2026-08-23 20:15:41 +02:00

120 lines
4.1 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import PinballCanvas from "./PinballCanvas";
import { takePendingTable } from "@/lib/pinball/tableSelectionStore";
import { getLibraryEntry, saveToLibrary } from "@/lib/pinball/tableLibrary";
import { romGameNameFromFileName } from "@/lib/pinball/rom";
import type { RomSelection } from "@/lib/pinball/usePinballInstance";
import type { TableInfo } from "@/lib/pinball/types";
interface ResolvedTable {
tableData?: ArrayBuffer;
rom?: RomSelection;
tableInfo: TableInfo;
}
async function toRomSelection(romFile: File): Promise<RomSelection> {
return { gameName: romGameNameFromFileName(romFile.name), romData: await romFile.arrayBuffer() };
}
export default function PlayView() {
const [resolved, setResolved] = useState<ResolvedTable | null>(null);
// takePendingTable() pops the store on read, so it must run at most once
// per real mount — React's dev-mode double-invoke of effects would
// otherwise have the second call see an already-emptied store (the first
// call already popped the selection) and silently fall back to the demo
// table. No unmount-cancellation guard here: this ref makes the
// double-invoke's second pass a full no-op, so the async work below only
// ever runs once for a given component instance — a cleanup-driven cancel
// flag would incorrectly cancel that one real run's own result, since the
// phantom unmount's cleanup fires for whichever pass actually started it.
const hasResolvedRef = useRef(false);
useEffect(() => {
if (hasResolvedRef.current) return;
hasResolvedRef.current = true;
(async () => {
const selection = takePendingTable();
if (selection?.kind === "upload") {
const { vpxFile, romFile, saveToLibrary: shouldSave } = selection;
const [tableData, rom] = await Promise.all([
vpxFile.arrayBuffer(),
romFile ? toRomSelection(romFile) : Promise.resolve(undefined),
]);
if (shouldSave) {
saveToLibrary({
id: crypto.randomUUID(),
name: vpxFile.name,
sizeBytes: vpxFile.size,
addedAt: Date.now(),
vpxBlob: vpxFile,
romFileName: romFile?.name,
romBlob: romFile,
}).catch((err) => console.error("Failed to save table to library:", err));
}
setResolved({
tableData,
rom,
tableInfo: { name: vpxFile.name, sizeBytes: vpxFile.size, isDemo: false, romFileName: romFile?.name },
});
return;
}
if (selection?.kind === "library") {
const entry = await getLibraryEntry(selection.id);
if (entry) {
const [tableData, rom] = await Promise.all([
entry.vpxBlob.arrayBuffer(),
entry.romBlob
? entry.romBlob.arrayBuffer().then((romData) => ({
gameName: romGameNameFromFileName(entry.romFileName ?? ""),
romData,
}))
: Promise.resolve(undefined),
]);
setResolved({
tableData,
rom,
tableInfo: {
name: entry.name,
sizeBytes: entry.sizeBytes,
isDemo: false,
romFileName: entry.romFileName,
},
});
return;
}
// Fall through to the demo table if the library entry was removed
// (e.g. deleted in another tab) between selecting it and arriving here.
}
// No pending upload/library selection (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 },
});
})();
}, []);
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}
rom={resolved.rom}
tableInfo={resolved.tableInfo}
key={resolved.tableInfo.name}
/>
);
}