This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -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();
|
||||
@@ -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>`;
|
||||
}
|
||||
Reference in new issue
Block a user