- 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
120 lines
4.1 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|