This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
+278
@@ -0,0 +1,278 @@
|
||||
import "./ui/styles.css";
|
||||
import { renderShell } from "./ui/shell";
|
||||
import { NativeRuntime } from "./game/runtime";
|
||||
import type { GameWorld } from "./game/world/world";
|
||||
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 lockRelease: (() => void) | undefined;
|
||||
let saving: Promise<void> | undefined;
|
||||
function showWorld(world: GameWorld) {
|
||||
get<HTMLInputElement>("player-name").value = world.name;
|
||||
get<HTMLSelectElement>("player-avatar").value = world.avatar;
|
||||
get("location").textContent = world.map.name;
|
||||
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
|
||||
get("game-notice").textContent = world.notice;
|
||||
get("native-dialogue").hidden = !world.dialogue;
|
||||
get("dialogue-text").textContent =
|
||||
world.dialogue?.pages[world.dialogue.index] ?? "";
|
||||
get("dialogue-page").textContent = world.dialogue
|
||||
? `${world.dialogue.index + 1} / ${world.dialogue.pages.length}`
|
||||
: "";
|
||||
// Coordinates provide a stable accessible description and a test observation
|
||||
// point, without exposing mutable game state or a production debug API.
|
||||
canvas.dataset.map = world.mapId;
|
||||
canvas.dataset.x = String(world.x);
|
||||
canvas.dataset.y = String(world.y);
|
||||
canvas.setAttribute(
|
||||
"aria-label",
|
||||
`${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`,
|
||||
);
|
||||
}
|
||||
const runtime = new NativeRuntime(
|
||||
showWorld,
|
||||
(error) => {
|
||||
session.state = "error";
|
||||
controls.release();
|
||||
showError(error);
|
||||
update();
|
||||
},
|
||||
() => ({
|
||||
name:
|
||||
get<HTMLInputElement>("player-name")
|
||||
.value.trim()
|
||||
.replace(/[^A-Za-z0-9 -]/g, "")
|
||||
.slice(0, 7) || "CHRIS",
|
||||
avatar:
|
||||
get<HTMLSelectElement>("player-avatar").value === "kris"
|
||||
? "kris"
|
||||
: "chris",
|
||||
}),
|
||||
);
|
||||
const session = new GameSession(
|
||||
runtime,
|
||||
new BrowserSaveRepository("crystal-native-port"),
|
||||
);
|
||||
const input = new InputController((button, pressed) =>
|
||||
runtime.setButton(button, pressed),
|
||||
);
|
||||
const controls = bindControls(
|
||||
root,
|
||||
input,
|
||||
() => session.state === "playing" && !busy,
|
||||
() => {},
|
||||
);
|
||||
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", "save", "backup", "import-save", "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);
|
||||
get<HTMLInputElement>("player-name").disabled = busy || ready();
|
||||
get<HTMLSelectElement>("player-avatar").disabled = busy || ready();
|
||||
get<HTMLButtonElement>("dialogue-next").disabled =
|
||||
busy || session.state !== "playing";
|
||||
}
|
||||
|
||||
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(
|
||||
"Native exploration is running. Story progression and battles are not ported yet.",
|
||||
);
|
||||
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("crystal-native-session");
|
||||
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 explore your room and New Bark Town.",
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
get("play").addEventListener("click", () =>
|
||||
session.state === "playing" ? pause() : play(),
|
||||
);
|
||||
get("screen-resume").addEventListener("click", play);
|
||||
get("dialogue-next").addEventListener("click", () => {
|
||||
if (session.state !== "playing") return;
|
||||
runtime.world?.advanceDialogue();
|
||||
if (runtime.world) showWorld(runtime.world);
|
||||
canvas.focus({ preventScroll: true });
|
||||
});
|
||||
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("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();
|
||||
Reference in new issue
Block a user