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
This commit is contained in:
@@ -1,40 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
if (hasResolvedRef.current) return;
|
||||
hasResolvedRef.current = true;
|
||||
|
||||
(async () => {
|
||||
const file = takePendingTable();
|
||||
if (file) {
|
||||
const tableData = await file.arrayBuffer();
|
||||
if (cancelled) return;
|
||||
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,
|
||||
tableInfo: { name: file.name, sizeBytes: file.size, isDemo: false },
|
||||
rom,
|
||||
tableInfo: { name: vpxFile.name, sizeBytes: vpxFile.size, isDemo: false, romFileName: romFile?.name },
|
||||
});
|
||||
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.
|
||||
|
||||
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 },
|
||||
});
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!resolved) {
|
||||
@@ -46,6 +109,11 @@ export default function PlayView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PinballCanvas tableData={resolved.tableData} tableInfo={resolved.tableInfo} key={resolved.tableInfo.name} />
|
||||
<PinballCanvas
|
||||
tableData={resolved.tableData}
|
||||
rom={resolved.rom}
|
||||
tableInfo={resolved.tableInfo}
|
||||
key={resolved.tableInfo.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user