The RomM replacement. - registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA, N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket, WonderSwan, Atari, Coleco, Intellivision, and PlayStation. - games: platform detection from the file extension, mapping each to an EmulatorJS core. Platforms with no browser core are recognised but marked not-playable so the UI offers a download instead of a broken player. - migration 0005: game_saves keyed by (user, item, slot). Save states are user data, not media — two people playing the same ROM keep their own progress. - save endpoints with a 16 MiB cap and a validated slot name; the upload is read through a LimitReader so an unbounded body is refused rather than buffered. - igdb provider: Twitch client-credentials flow with the token cached for its ~2 month lifetime, Apicalypse query body, and cover URLs normalised from protocol-relative t_thumb to https t_cover_big. - searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog" matches immediately. Stored titles keep the original. - web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons. Verified live against IGDB: Sonic matched (id 316900) with real cover art; a 4 KB save uploaded, listed, downloaded byte-identical, and deleted.
194 lines
5.2 KiB
Svelte
194 lines
5.2 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte'
|
|
import { api, ApiError } from '../api'
|
|
|
|
let { itemId }: { itemId: number } = $props()
|
|
|
|
// EmulatorJS loads its cores and data files from this path. It is a CDN by
|
|
// default, so playing a game needs outbound internet; the ROM itself never
|
|
// leaves the server.
|
|
const DATA_PATH = 'https://cdn.emulatorjs.org/stable/data/'
|
|
|
|
let platform = $state('')
|
|
let core = $state('')
|
|
let playable = $state(false)
|
|
let loading = $state(true)
|
|
let error = $state<string | null>(null)
|
|
let status = $state('')
|
|
let busy = $state(false)
|
|
|
|
let scriptEl: HTMLScriptElement | null = null
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const g = await api.game(itemId)
|
|
platform = g.platform
|
|
core = g.core
|
|
playable = g.playable
|
|
if (playable) {
|
|
await boot()
|
|
}
|
|
} catch (err) {
|
|
error = err instanceof ApiError ? err.message : 'could not open this game'
|
|
} finally {
|
|
loading = false
|
|
}
|
|
})
|
|
|
|
onDestroy(() => {
|
|
scriptEl?.remove()
|
|
const w = window as any
|
|
w.EJS_emulator = undefined
|
|
})
|
|
|
|
async function boot() {
|
|
const w = window as any
|
|
// EmulatorJS reads these globals when loader.js runs.
|
|
w.EJS_player = '#ejs-host'
|
|
w.EJS_core = core
|
|
w.EJS_gameUrl = api.fileUrl(itemId)
|
|
w.EJS_gameName = `vellum-${itemId}`
|
|
w.EJS_pathtodata = DATA_PATH
|
|
w.EJS_startOnLoaded = true
|
|
w.EJS_color = '#6d7cff'
|
|
w.EJS_backgroundColor = '#0a0a0c'
|
|
|
|
await loadScript(DATA_PATH + 'loader.js')
|
|
}
|
|
|
|
function loadScript(src: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const s = document.createElement('script')
|
|
s.src = src
|
|
s.async = true
|
|
s.onload = () => resolve()
|
|
s.onerror = () => reject(new Error('could not load the emulator'))
|
|
document.body.appendChild(s)
|
|
scriptEl = s
|
|
})
|
|
}
|
|
|
|
function emulator(): any {
|
|
return (window as any).EJS_emulator
|
|
}
|
|
|
|
// Save states are the emulator's full snapshot, uploaded as raw bytes and
|
|
// kept per user, so two people playing the same ROM keep separate progress.
|
|
async function saveToServer() {
|
|
const emu = emulator()
|
|
if (!emu?.gameManager?.getState) {
|
|
status = 'The emulator is still starting.'
|
|
return
|
|
}
|
|
busy = true
|
|
status = ''
|
|
try {
|
|
const state: Uint8Array = emu.gameManager.getState()
|
|
const res = await fetch(api.saveUrl(itemId), {
|
|
method: 'PUT',
|
|
credentials: 'same-origin',
|
|
// Cast: the DOM lib types BlobPart as ArrayBuffer-backed, while
|
|
// Uint8Array is generic over ArrayBufferLike (which includes
|
|
// SharedArrayBuffer). The runtime value is always a normal buffer.
|
|
body: new Blob([state as BlobPart]),
|
|
})
|
|
if (!res.ok) throw new Error(`save failed (${res.status})`)
|
|
const info = await res.json()
|
|
status = `Saved ${Math.round(info.size / 1024)} KB to the server.`
|
|
} catch (err) {
|
|
status = err instanceof Error ? err.message : 'could not save'
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
async function loadFromServer() {
|
|
const emu = emulator()
|
|
if (!emu?.gameManager?.loadState) {
|
|
status = 'The emulator is still starting.'
|
|
return
|
|
}
|
|
busy = true
|
|
status = ''
|
|
try {
|
|
const res = await fetch(api.saveUrl(itemId), { credentials: 'same-origin' })
|
|
if (res.status === 404) {
|
|
status = 'No save on the server yet.'
|
|
return
|
|
}
|
|
if (!res.ok) throw new Error(`load failed (${res.status})`)
|
|
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
emu.gameManager.loadState(bytes)
|
|
status = 'Loaded the save from the server.'
|
|
} catch (err) {
|
|
status = err instanceof Error ? err.message : 'could not load the save'
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if error}
|
|
<div class="alert">{error}</div>
|
|
{:else if loading}
|
|
<div class="stage center"><div class="spinner"></div></div>
|
|
{:else if !playable}
|
|
<div class="empty">
|
|
<h3>{platform || 'Unrecognised ROM'}</h3>
|
|
<p>
|
|
{#if platform}
|
|
There is no in-browser emulator for {platform} yet.
|
|
{:else}
|
|
The file extension does not identify a known platform.
|
|
{/if}
|
|
</p>
|
|
<a class="btn" href={api.fileUrl(itemId)} download>Download the ROM</a>
|
|
</div>
|
|
{:else}
|
|
<div class="bar">
|
|
<span class="badge">{platform}</span>
|
|
<span class="spacer"></span>
|
|
<button class="btn" onclick={loadFromServer} disabled={busy}>Load save</button>
|
|
<button class="btn" onclick={saveToServer} disabled={busy}>Save to server</button>
|
|
</div>
|
|
|
|
{#if status}
|
|
<p class="status muted">{status}</p>
|
|
{/if}
|
|
|
|
<div class="stage">
|
|
<div id="ejs-host"></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
.spacer {
|
|
flex: 1;
|
|
}
|
|
.status {
|
|
margin: 0 0 var(--space-3);
|
|
font-size: 13px;
|
|
}
|
|
.stage {
|
|
height: calc(100vh - 60px - 150px);
|
|
min-height: 420px;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
background: #000;
|
|
overflow: hidden;
|
|
}
|
|
.stage :global(#ejs-host) {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.center {
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
</style>
|