Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
import "../ui/styles.css";
import { renderShell } from "./shell";
import { BinjgbEmulator } from "../emulator/binjgb";
import { GameSession } from "../app/session";
import { InputController } from "../input/controller";
import { bindControls } from "../input/bindings";
import { validateRom } from "../rom/validate";
import { BrowserSaveRepository, encodeSave } from "../storage/saves";
import { acquireSessionLock } from "../platform/session-lock";
const root = document.querySelector<HTMLElement>("#app")!;
renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let muted = true;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
const emulator = new BinjgbEmulator((error) => {
session.state = "error";
controls.release();
showError(error);
update();
});
const session = new GameSession(emulator, new BrowserSaveRepository());
const input = new InputController((button, pressed) =>
emulator.setButton(button, pressed),
);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy,
() => emulator.unlockAudio(),
);
const ready = () => ["paused", "playing"].includes(session.state);
function showError(error: unknown) {
status.textContent =
error instanceof Error
? error.message
: "Something went wrong. Please try again.";
status.classList.add("error");
}
function message(text: string) {
status.textContent = text;
status.classList.remove("error");
}
function update() {
const playing = session.state === "playing";
root.classList.toggle("has-game", ready());
for (const id of [
"play",
"sound",
"save",
"backup",
"import-save",
"export-battery",
"screen-resume",
]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
root
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
.forEach((button) => {
button.disabled = !playing || busy;
});
get<HTMLButtonElement>("play").textContent = playing ? "Pause" : "Play";
get("power-label").textContent = playing
? "PLAYING"
: ready()
? "PAUSED"
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
}
async function save() {
if (!ready()) return;
// Capture again after an earlier write, so pausing never drops the last frame.
if (saving) {
await saving.catch(() => {});
return save();
}
saving = session
.save()
.then((time) => {
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
saveStatus.classList.remove("error");
})
.catch((error) => {
saveStatus.textContent =
"Session could not be saved. Export a backup to keep your progress.";
saveStatus.classList.add("error");
throw error;
})
.finally(() => {
saving = undefined;
});
return saving;
}
function pause() {
if (session.state !== "playing") return;
session.pause();
controls.release();
update();
void save().catch(showError);
}
function play() {
if (busy) return;
session.play();
canvas.focus({ preventScroll: true });
message("Your adventure is running.");
update();
}
get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
busy = true;
controls.release();
update();
message("Checking your cartridge locally…");
try {
const rom = await validateRom(file);
if (!lockRelease) lockRelease = await acquireSessionLock();
if (ready()) {
session.pause();
controls.release();
await saving;
await save();
}
const restored = await session.load(rom, canvas);
message(
restored
? "Your saved session is ready. Press Play to continue."
: "Cartridge verified. Press Play to begin.",
);
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("sound").addEventListener("click", () => {
muted = !muted;
emulator.setMuted(muted);
get("sound").textContent = muted ? "Sound off" : "Sound on";
get("sound").setAttribute("aria-pressed", String(!muted));
});
get("save").addEventListener("click", () => {
void save().catch(showError);
});
function download(contents: BlobPart, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
get("backup").addEventListener("click", () => {
try {
download(
encodeSave(session.snapshot()),
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message("Session backup exported. Keep it somewhere safe.");
} catch (error) {
showError(error);
}
});
get("export-battery").addEventListener("click", () => {
try {
download(
new Uint8Array(emulator.exportBattery()).buffer,
"crystal.sav",
"application/octet-stream",
);
} catch (error) {
showError(error);
}
});
get("import-save").addEventListener("click", () => {
pause();
get<HTMLInputElement>("save-file").click();
});
get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
if (file.size > 2_000_000) {
showError(new Error("This backup is too large."));
return;
}
if (
!window.confirm(
"Replace your current session with this backup? Export your current session first if you want to keep it.",
)
)
return;
busy = true;
update();
try {
await saving;
await session.importSave(await file.text());
message("Backup restored. Press Play to continue.");
saveStatus.textContent = "Imported session saved on this device.";
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("help").addEventListener("click", () => {
pause();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pause();
});
window.addEventListener("pagehide", () => {
pause();
});
setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
+51
View File
@@ -0,0 +1,51 @@
const key = (name: string, label: string, className = "") =>
`<button class="game-key ${className}" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
export function renderShell(root: HTMLElement) {
root.innerHTML = `
<header class="site-header">
<a class="brand" href="./" aria-label="Crystal home"><img src="${import.meta.env.BASE_URL}icon.svg" alt="" width="36" height="36">crystal<span>REFERENCE EMULATOR</span></a>
<button id="help" class="text-button">How to play <span aria-hidden="true">↗</span></button>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow"><span class="small-star">✧</span> YOUR CARTRIDGE. A NEW HOME.</p>
<h1 id="page-title">Original game.<br><em>Reference only.</em></h1>
<p class="intro-copy">Development reference for the native port.<br> Bring your own ROM. Pick up where you left off.</p>
<div class="load-card">
<div class="card-heading"><span class="step-number">01</span><h2>Bring your cartridge</h2></div>
<p>Pokémon Crystal<br><strong>English US/Europe · Revision 1.1</strong></p>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">+</span> Choose ROM <span aria-hidden="true">→</span></label>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream" />
<p class="privacy-note"><span aria-hidden="true">◇</span> Opened locally. Never uploaded.</p>
</div>
<div class="project-note"><span class="note-line"></span><p>A small beginning, built for more.<br><span>Touch menus, online linking, and walking features are planned.</span></p></div>
</section>
<section class="play-area" aria-label="Game player">
<div class="console">
<div class="console-top"><span>CRYSTAL / 01</span><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span></div>
<div class="screen-bezel">
<div class="screen">
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen. Arrow keys move, X is A, Z is B, Enter is Start, Shift is Select."></canvas>
<div id="screen-empty" class="screen-empty"><div class="crystal-mark" aria-hidden="true">◇</div><h2>Your next chapter<br>starts here.</h2><p>Load your Crystal ROM to begin.</p><span class="screen-dots" aria-hidden="true">· · ·</span></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing →</button></div>
</div>
<div class="screen-caption"><span>GAME BOY COLOR</span><span>160 × 144</span></div>
</div>
<div class="controls">
<div class="dpad" aria-label="Directional controls">${key("up", "▲")}${key("left", "◀")}<span class="dpad-center" aria-hidden="true"></span>${key("right", "▶")}${key("down", "▼")}</div>
<div class="action-keys">${key("b", "B")}${key("a", "A")}</div>
<div class="system-keys">${key("select", "SELECT")}${key("start", "START")}</div>
<div class="speaker" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
<div class="console-bottom"><span>MADE FOR THE WAY YOU PLAY</span><span>✧</span></div>
</div>
<div class="player-toolbar" aria-label="Player actions"><button id="play" disabled>Play</button><button id="sound" aria-pressed="false" disabled>Sound off</button><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button></div>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
<details class="save-tools"><summary>Save files & compatibility</summary><div><button id="import-save" disabled>Import session backup</button><button id="export-battery" disabled>Export in-game .sav</button><input id="save-file" class="file-input" type="file" accept=".json" /><p>Use the game’s SAVE menu before exporting a .sav. Session backups also preserve your current screen and clock state.</p><p>The game clock currently advances only while playing. Time while paused or closed is not added. Export a backup before clearing browser data.</p></div></details>
</section>
</main>
<footer><span>A personal cartridge, a pocket-sized world.</span><span>Independent project · No game assets included</span></footer>
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="eyebrow">GETTING STARTED</p><h2>Make yourself at home.</h2><p>Choose your own uncompressed English US/Europe revision 1.1 ROM, then press Play. ROMs stay in memory and must be selected again after a reload.</p><p>Use the onscreen controls, or focus the game screen and use:</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>A / B</dt><dd>X / Z</dd><dt>Start / Select</dt><dd>Enter / Shift</dd></dl><p>Sessions save every 10 seconds and when paused. Keep a downloaded backup: browser storage can be cleared. Switching apps pauses the game; tap Play to resume.</p><p>The original game menus remain intact for this first version. Internet Link and step counting are not available yet.</p></dialog>`;
}