-
+ --color-gray: hsl(0 0% 55%);
+ --controls-size: clamp(3.5rem, 20vmin, 5.5rem);
+ --controls-opacity: 0.4;
+ --controls-fade: 80ms;
+
+ --save-fill: rgb(255 255 255 / 0.05);
+ --save-fill-hover: rgb(255 255 255 / 0.09);
+ --save-line: rgb(255 255 255 / 0.09);
+ }
+
+ html {
+ touch-action: none;
+ }
+
+ body {
+ margin: 0;
+ font-family: system-ui, sans-serif;
+ color: white;
+ background: black;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ #boot {
+ position: fixed;
+ inset: 0;
+ z-index: 1;
+ display: grid;
+ place-content: center;
+ justify-items: center;
+ font-family: ui-monospace, monospace;
+ text-align: center;
+ background: black;
+ pointer-events: none;
+ transition:
+ opacity 300ms ease,
+ visibility 300ms ease;
+ }
+
+ #boot.done {
+ visibility: hidden;
+ opacity: 0;
+ }
+
+ #status {
+ font-size: 0.875rem;
+ color: var(--color-gray);
+ }
+
+ #controls {
+ position: fixed;
+ top: calc(env(safe-area-inset-top) + 0.5rem);
+ right: calc(env(safe-area-inset-right) + 0.5rem);
+ z-index: 10;
+ display: flex;
+ gap: 0.25rem;
+ }
+
+ #controls button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.35rem;
+ color: white;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ opacity: 0.7;
+ transition: opacity 80ms ease;
+ }
+
+ #controls button[hidden] {
+ display: none;
+ }
+
+ #controls button:focus-visible {
+ opacity: 1;
+ }
+
+ body[data-scaling="integer"] #controls-scaling .icon-fit,
+ body:not([data-scaling="integer"]) #controls-scaling .icon-integer {
+ display: none;
+ }
+
+ #viewport:fullscreen #controls-fullscreen .icon-enter,
+ #viewport:not(:fullscreen) #controls-fullscreen .icon-exit {
+ display: none;
+ }
+
+ #canvas {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ outline: none;
+ image-rendering: pixelated;
+ transform: translate(-50%, -50%);
+ }
+
+ img#canvas {
+ object-fit: contain;
+ }
+
+ /* Pixel-perfect mode: `!important` beats the inline styles SDL
+ writes onto the canvas while it manages its window size */
+ body[data-scaling="integer"] #canvas {
+ width: var(--canvas-width, 100%) !important;
+ height: var(--canvas-height, 100%) !important;
+ }
+
+ /* Fit mode: `!important` likewise beats SDL's inline size, which
+ shrinks the canvas when it measures a zero-height body */
+ body:not([data-scaling="integer"]) #canvas {
+ width: 100% !important;
+ height: 100% !important;
+ object-fit: contain !important;
+ }
+
+ @media (hover: none), (pointer: coarse) {
+ #controls button,
+ .save-button {
+ min-block-size: 44px;
+ }
+
+ .save-button-icon {
+ min-inline-size: 44px;
+ }
+
+ #controls {
+ gap: 0.625rem;
+ }
+
+ #viewport {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100dvh;
+ display: grid;
+ grid-template-areas:
+ "screen screen"
+ "dpad apad";
+ grid-template-rows: minmax(0, 1fr) auto;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ #canvas {
+ position: static;
+ grid-area: screen;
+ object-fit: contain;
+ transform: none;
+ }
+
+ body[data-scaling="integer"] #canvas {
+ place-self: center;
+ outline: 1px solid hsl(0 0% 25%);
+ box-shadow: 0 0 0 6px hsl(0 0% 8%);
+ }
+
+ #dpad,
+ #apad {
+ align-self: center;
+ }
+
+ #dpad {
+ grid-area: dpad;
+ padding: 0.75rem 0 calc(0.75rem + env(safe-area-inset-bottom))
+ calc(0.75rem + env(safe-area-inset-left));
+ }
+
+ #apad {
+ grid-area: apad;
+ justify-self: end;
+ padding: 0.75rem calc(0.75rem + env(safe-area-inset-right))
+ calc(0.75rem + env(safe-area-inset-bottom)) 0;
+ }
+
+ @media (orientation: landscape) {
+ #viewport {
+ grid-template-areas: "dpad screen apad";
+ grid-template-rows: minmax(0, 1fr);
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ }
+
+ #dpad {
+ padding: 0 0.75rem 0 calc(0.75rem + env(safe-area-inset-left));
+ }
+
+ #apad {
+ padding: 0 calc(0.75rem + env(safe-area-inset-right)) 0 0.75rem;
+ }
+ }
+ }
+
+ #dpad svg {
+ width: calc(2 * var(--controls-size));
+ height: calc(2 * var(--controls-size));
+ fill: var(--color-gray);
+ }
+
+ #apad > * {
+ width: var(--controls-size);
+ height: var(--controls-size);
+ background-color: var(--color-gray);
+ border-radius: 50%;
+ }
+
+ #apad > :first-child {
+ margin-left: var(--controls-size);
+ }
+
+ #dpad svg > *,
+ #apad > * {
+ opacity: var(--controls-opacity);
+ transition:
+ opacity var(--controls-fade) ease,
+ scale 80ms ease;
+ }
+
+ #dpad path {
+ transform-box: fill-box;
+ transform-origin: center;
+ }
+
+ #dpad path.active,
+ #apad > .active {
+ opacity: 1;
+ scale: 0.94;
+ }
+
+ /* Idle dims the whole deck by flipping the shared opacity var */
+ body.controls-idle {
+ --controls-opacity: 0.2;
+ --controls-fade: 500ms;
+ }
+
+ body.gamepad-connected #dpad,
+ body.gamepad-connected #apad {
+ display: none;
+ }
+
+ @media (hover: hover) and (pointer: fine) {
+ #apad,
+ #dpad {
+ display: none;
+ }
+
+ #controls button:hover {
+ opacity: 1;
+ }
+ }
+
+ #save-dialog {
+ position: fixed;
+ inset: 0;
+ margin: auto;
+ width: min(20rem, calc(100vw - 2rem));
+ padding: 1.05rem 1.1rem 1.1rem;
+ color: white;
+ background: hsl(0 0% 10%);
+ border: 1px solid var(--save-line);
+ border-radius: 0.75rem;
+ box-shadow: 0 1rem 3rem hsl(0 0% 0% / 0.5);
+ opacity: 0;
+ scale: 0.98;
+ transition:
+ opacity 150ms ease-out,
+ scale 150ms ease-out,
+ overlay 150ms ease-out allow-discrete,
+ display 150ms ease-out allow-discrete;
+ }
+
+ #save-dialog[open] {
+ opacity: 1;
+ scale: 1;
+ }
+
+ @starting-style {
+ #save-dialog[open] {
+ opacity: 0;
+ scale: 0.98;
+ }
+ }
+
+ #save-dialog::backdrop {
+ background: hsl(0 0% 0% / 0.6);
+ backdrop-filter: blur(2px);
+ opacity: 0;
+ transition:
+ opacity 150ms ease-out,
+ overlay 150ms ease-out allow-discrete,
+ display 150ms ease-out allow-discrete;
+ }
+
+ #save-dialog[open]::backdrop {
+ opacity: 1;
+ }
+
+ @starting-style {
+ #save-dialog[open]::backdrop {
+ opacity: 0;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ #save-dialog,
+ #save-dialog::backdrop {
+ transition: none;
+ }
+ }
+
+ .save-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.85rem;
+ }
+
+ .save-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ }
+
+ #save-list {
+ padding: 0.1rem 0.6rem;
+ background: var(--save-fill);
+ border-radius: 0.5rem;
+ }
+
+ .save-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+ }
+
+ .save-row + .save-row {
+ border-top: 1px solid var(--save-line);
+ }
+
+ .save-name {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.9375rem;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .save-age {
+ color: var(--color-gray);
+ font-weight: 400;
+ }
+
+ .save-age::before {
+ content: " · ";
+ }
+
+ .save-empty {
+ margin: 0.4rem 0.1rem;
+ font-size: 0.9375rem;
+ color: var(--color-gray);
+ }
+
+ .save-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.6rem;
+ color: white;
+ background: transparent;
+ border: 0;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.9375rem;
+ transition:
+ background-color 120ms ease,
+ opacity 120ms ease;
+ }
+
+ .save-button:hover:not(:disabled),
+ .save-button:focus-visible {
+ background: var(--save-fill-hover);
+ }
+
+ .save-button:disabled {
+ opacity: 0.4;
+ cursor: default;
+ }
+
+ .save-button svg {
+ flex: none;
+ }
+
+ .save-button-icon {
+ justify-content: center;
+ padding: 0.45rem;
+ }
+
+ .save-button-block {
+ width: 100%;
+ padding: 0.6rem 0.65rem;
+ text-align: left;
+ }
+
+ .save-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ margin-top: 0.6rem;
+ }
+
+ #save-toast {
+ position: fixed;
+ bottom: calc(1rem + env(safe-area-inset-bottom));
+ left: 50%;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ max-width: calc(100vw - 2rem);
+ padding: 0.6rem 0.9rem;
+ font: inherit;
+ font-size: 0.875rem;
+ text-align: left;
+ color: white;
+ background: hsl(0 0% 12%);
+ border: 1px solid hsl(0 0% 25%);
+ border-radius: 0.5rem;
+ box-shadow: 0 0.5rem 1.5rem hsl(0 0% 0% / 0.5);
+ cursor: pointer;
+ opacity: 0;
+ visibility: hidden;
+ transform: translate(-50%, 1rem);
+ transition:
+ opacity 200ms ease,
+ transform 200ms ease,
+ visibility 200ms ease;
+ }
+
+ #save-toast svg {
+ flex: none;
+ }
+
+ #save-toast.visible {
+ opacity: 1;
+ visibility: visible;
+ transform: translate(-50%, 0);
+ }
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-{{{ SCRIPT }}}
-
-
-
-
+ // Stop the browser's default for keys the game uses
+ window.addEventListener("keydown", (event) => {
+ // While the save panel is open it owns the keyboard, so leave
+ // its Space/arrow/Tab navigation alone. The game engine reads
+ // keys only from #canvas, which has lost focus to the dialog
+ // anyway, so there is nothing to suppress here.
+ if (saveDialog.open) return;
+ if (preventNativeKeys.includes(event.key)) {
+ event.preventDefault();
+ }
+ });
+
+ canvas.addEventListener("contextmenu", (event) => {
+ event.preventDefault();
+ });
+ }
+
+ window.addEventListener("gamepadconnected", (event) => {
+ connectedGamepads.add(event.gamepad.index);
+ updateTouchControlsVisibility();
+ });
+ window.addEventListener("gamepaddisconnected", (event) => {
+ connectedGamepads.delete(event.gamepad.index);
+ updateTouchControlsVisibility();
+ });
+
+ // The wake lock is released whenever the tab gets hidden, so it has
+ // to be re-requested every time the player becomes visible again
+ requestWakeLock();
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible") {
+ requestWakeLock();
+ }
+ });
+
+ /**
+ * Simulate a keyboard event on the Emscripten canvas
+ *
+ * @param {string} eventType "keydown" or "keyup"
+ * @param {string} code Physical key code to simulate (e.g. "ArrowUp")
+ */
+ function simulateKeyboardEvent(eventType, code) {
+ canvas.dispatchEvent(
+ new KeyboardEvent(eventType, { code, bubbles: true }),
+ );
+ }
+
+ /**
+ * Bind a touch control element to the key it simulates
+ *
+ * @param {HTMLElement} element
+ * @param {string} key Key code to dispatch, e.g. "ArrowUp"
+ */
+ function bindKey(element, key) {
+ keyByControlId.set(element.id, key);
+
+ element.addEventListener("pointerdown", (event) => {
+ event.preventDefault();
+ engagedPointers.add(event.pointerId);
+ pressControl(event.pointerId, element.id);
+
+ // Touch input captures implicitly; mouse and pen need an
+ // explicit capture so the gesture keeps firing on this element
+ try {
+ element.setPointerCapture(event.pointerId);
+ } catch {}
+ });
+
+ // Slide between controls without lifting the pointer
+ element.addEventListener("pointermove", (event) => {
+ if (!engagedPointers.has(event.pointerId)) return;
+
+ const targetId = document.elementFromPoint(
+ event.clientX,
+ event.clientY,
+ )?.id;
+ if (targetId === pressedControls.get(event.pointerId)) return;
+
+ if (targetId && keyByControlId.has(targetId)) {
+ pressControl(event.pointerId, targetId);
+ } else {
+ releaseControl(event.pointerId);
+ }
+ });
+
+ for (const eventType of ["pointerup", "pointercancel"]) {
+ element.addEventListener(eventType, (event) => {
+ engagedPointers.delete(event.pointerId);
+ releaseControl(event.pointerId);
+ });
+ }
+ }
+
+ /**
+ * Press a control, releasing whatever the pointer held before
+ *
+ * @param {number} pointerId Pointer pressing the control
+ * @param {string} elementId Element id of the control to press
+ */
+ function pressControl(pointerId, elementId) {
+ releaseControl(pointerId);
+ wakeTouchControls();
+
+ pressedControls.set(pointerId, elementId);
+ simulateKeyboardEvent("keydown", keyByControlId.get(elementId));
+ document.getElementById(elementId).classList.add("active");
+ }
+
+ /**
+ * Release the control held by a pointer, unless another pointer
+ * still presses the same control
+ *
+ * @param {number} pointerId Pointer to release
+ */
+ function releaseControl(pointerId) {
+ const elementId = pressedControls.get(pointerId);
+ if (!elementId) return;
+
+ wakeTouchControls();
+ pressedControls.delete(pointerId);
+ if ([...pressedControls.values()].includes(elementId)) return;
+
+ simulateKeyboardEvent("keyup", keyByControlId.get(elementId));
+ document.getElementById(elementId).classList.remove("active");
+ }
+
+ function updateTouchControlsVisibility() {
+ document.body.classList.toggle(
+ "gamepad-connected",
+ connectedGamepads.size > 0,
+ );
+ }
+
+ /**
+ * Toggle pixel-perfect scaling and reflect it on the button
+ *
+ * @param {boolean} isEnabled Whether to snap to integer multiples
+ * @param {boolean} shouldPersist Save the choice; only explicit toggles
+ * persist, not the device default
+ */
+ function setIntegerScaling(isEnabled, shouldPersist) {
+ if (isEnabled) {
+ document.body.dataset.scaling = "integer";
+ } else {
+ delete document.body.dataset.scaling;
+ }
+ scalingButton.setAttribute("aria-pressed", String(isEnabled));
+
+ if (shouldPersist) {
+ try {
+ localStorage.setItem(
+ scalingStorageKey,
+ isEnabled ? "integer" : "fit",
+ );
+ } catch {}
+ }
+
+ applyIntegerScale();
+
+ // SDL only re-reads the canvas size on window resize, so nudge
+ // it after the CSS box of the canvas changed
+ window.dispatchEvent(new Event("resize"));
+ }
+
+ /**
+ * Snap the canvas to the largest whole multiple of the native game
+ * resolution that fits the available space
+ */
+ function applyIntegerScale() {
+ const rootStyle = document.documentElement.style;
+ rootStyle.removeProperty("--canvas-width");
+ rootStyle.removeProperty("--canvas-height");
+ if (document.body.dataset.scaling !== "integer") return;
+
+ // With the properties cleared the canvas spans 100% again, so its
+ // client box measures the available space
+ const scale = Math.max(
+ 1,
+ Math.floor(
+ Math.min(
+ canvas.clientWidth / nativeGameWidth,
+ canvas.clientHeight / nativeGameHeight,
+ ),
+ ),
+ );
+ rootStyle.setProperty("--canvas-width", `${nativeGameWidth * scale}px`);
+ rootStyle.setProperty(
+ "--canvas-height",
+ `${nativeGameHeight * scale}px`,
+ );
+ }
+
+ /**
+ * Coalesce a burst of resize/observer callbacks into a single
+ * recompute on the next frame, once layout has settled
+ */
+ function scheduleIntegerScale() {
+ cancelAnimationFrame(scaleFrameId);
+ scaleFrameId = requestAnimationFrame(applyIntegerScale);
+ }
+
+ /**
+ * Light up the touch controls and dim them again after a few
+ * seconds without input
+ */
+ function wakeTouchControls() {
+ document.body.classList.remove("controls-idle");
+ clearTimeout(controlsIdleTimeoutId);
+ controlsIdleTimeoutId = setTimeout(() => {
+ // Keep the controls lit while something is still pressed
+ if (pressedControls.size > 0) {
+ wakeTouchControls();
+ return;
+ }
+ document.body.classList.add("controls-idle");
+ }, 3000);
+ }
+
+ async function requestWakeLock() {
+ try {
+ await navigator.wakeLock?.request("screen");
+ } catch {}
+ }
+
+ /**
+ * Wrap the engine's download hook so downloaded screenshots stay crisp.
+ * They are saved at the native 320×240, and the `image-rendering:
+ * pixelated` that keeps the live canvas sharp doesn't travel with the
+ * file – viewers upscale the tiny PNG into a blur. Re-emit it enlarged
+ * with nearest-neighbor instead; savegame downloads (.lsd) and anything
+ * else pass through.
+ */
+ function upscaleScreenshotDownloads() {
+ const originalDownload = player.api_private.download_js;
+
+ player.api_private.download_js = (buffer, size, filenamePointer) => {
+ const heap = player.HEAPU8;
+ let nameEnd = filenamePointer;
+ while (heap[nameEnd]) nameEnd++;
+ const filename = new TextDecoder().decode(
+ heap.subarray(filenamePointer, nameEnd),
+ );
+
+ if (!filename.toLowerCase().endsWith(".png")) {
+ originalDownload(buffer, size, filenamePointer);
+ return;
+ }
+
+ // The pointer is valid only during this call, and the heap view
+ // detaches on a later memory growth, so slice the bytes out now
+ const sourceBlob = new Blob([heap.slice(buffer, buffer + size)], {
+ type: "image/png",
+ });
+ upscaleAndSave(sourceBlob, filename, screenshotUpscaleFactor);
+ };
+ }
+
+ /**
+ * Redraw a PNG blob enlarged with nearest-neighbor, then save it under
+ * the original name. Falls back to the source blob if it cannot be
+ * decoded or re-encoded, so a screenshot is never lost.
+ *
+ * @param {Blob} sourceBlob Original PNG from the engine
+ * @param {string} filename Name to save under, e.g. "screenshot_0.png"
+ * @param {number} scale Whole-number factor to enlarge by
+ */
+ function upscaleAndSave(sourceBlob, filename, scale) {
+ const sourceUrl = URL.createObjectURL(sourceBlob);
+ const image = new Image();
+
+ image.addEventListener("load", () => {
+ URL.revokeObjectURL(sourceUrl);
+ try {
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth * scale;
+ canvas.height = image.naturalHeight * scale;
+ const context = canvas.getContext("2d");
+ context.imageSmoothingEnabled = false;
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
+ canvas.toBlob((upscaledBlob) => {
+ saveBlob(upscaledBlob ?? sourceBlob, filename);
+ }, "image/png");
+ } catch {
+ saveBlob(sourceBlob, filename);
+ }
+ });
+
+ image.addEventListener("error", () => {
+ URL.revokeObjectURL(sourceUrl);
+ saveBlob(sourceBlob, filename);
+ });
+
+ image.src = sourceUrl;
+ }
+
+ /**
+ * Trigger a browser download of a blob under the given filename
+ */
+ function saveBlob(blob, filename) {
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = filename;
+ link.click();
+ link.remove();
+ setTimeout(() => URL.revokeObjectURL(link.href), 0);
+ }
+
+ // RPG Maker 2000/2003 caps save games at 15 slots (Save01..Save15.lsd)
+ const saveSlotCount = 15;
+ const saveHintStorageKey = "easyrpg-player-save-hint-seen";
+ const iconDownload = `
`;
+ const iconUpload = `
`;
+ /**
+ * Save slots captured at the first poll – the baseline a later write is compared against
+ * @type {Map
| undefined}
+ */
+ let saveSlotBaseline;
+ let saveHintPollId = 0;
+ let saveWatchId = 0;
+ const relativeTimeFormatter = new Intl.RelativeTimeFormat("en", {
+ numeric: "auto",
+ });
+
+ /**
+ * Wire up the save panel and its one-time backup hint
+ */
+ function setupSaveManager() {
+ const savesButton = document.getElementById("controls-saves");
+ savesButton.addEventListener("click", (event) => {
+ // Don't let #controls' click handler yank focus back to the canvas
+ event.stopPropagation();
+ openSaveDialog();
+ });
+
+ document
+ .getElementById("save-close")
+ .addEventListener("click", () => saveDialog.close());
+
+ saveDialog.addEventListener("click", (event) => {
+ if (event.target === saveDialog) saveDialog.close();
+ });
+
+ // Hand the keyboard back to the game whenever the panel closes
+ saveDialog.addEventListener("close", () =>
+ canvas.focus({ preventScroll: true }),
+ );
+
+ document
+ .getElementById("save-screenshot")
+ .addEventListener("click", () => {
+ player.api.takeScreenshot(false);
+ saveDialog.close();
+ });
+
+ setupSaveHint();
+ }
+
+ function openSaveDialog() {
+ if (saveDialog.open) return;
+ renderSaveSlots();
+ saveDialog.showModal();
+ }
+
+ /**
+ * Absolute path of the IDBFS-backed save directory, mounted at `Save`
+ * inside the running game's working directory
+ */
+ function getSavePath() {
+ return `${player.FS.cwd()}/Save`;
+ }
+
+ /**
+ * Map each populated save slot to its modification time in ms. An
+ * empty or not-yet-mounted directory yields an empty map.
+ *
+ * @returns {Map}
+ */
+ function readSaveSlots() {
+ const slots = new Map();
+ try {
+ const savePath = getSavePath();
+ for (const entry of player.FS.readdir(savePath)) {
+ const match = /^Save(\d{2})\.lsd$/i.exec(entry);
+ if (!match) continue;
+ const { mtime } = player.FS.stat(`${savePath}/${entry}`);
+ slots.set(Number(match[1]), mtime.getTime());
+ }
+ } catch {
+ // The save directory does not exist until the first save
+ }
+ return slots;
+ }
+
+ /**
+ * Rebuild the slot list: a download/replace row per existing save,
+ * with the import button pointed at the lowest free slot
+ */
+ function renderSaveSlots() {
+ const slots = readSaveSlots();
+ const usedSlots = [...slots.keys()].sort((a, b) => a - b);
+ const saveList = document.getElementById("save-list");
+ saveList.replaceChildren();
+
+ if (usedSlots.length === 0) {
+ const emptyMessage = document.createElement("p");
+ emptyMessage.className = "save-empty";
+ emptyMessage.textContent =
+ "No saves yet. Save inside the game first.";
+ saveList.append(emptyMessage);
+ } else {
+ for (const slot of usedSlots) {
+ saveList.append(createSaveRow(slot, slots.get(slot)));
+ }
+ }
+
+ const freeSlot = firstFreeSlot(usedSlots);
+ const importButton = document.getElementById("save-import");
+ importButton.disabled = freeSlot === undefined;
+ importButton.title =
+ freeSlot === undefined
+ ? "All 15 slots are full – replace one instead"
+ : `Import into file ${freeSlot}`;
+ // Reassigned, not addEventListener: this runs on every open, and the
+ // import button persists across renders, so listeners would stack up
+ importButton.onclick =
+ freeSlot === undefined ? null : () => uploadToSlot(freeSlot);
+ }
+
+ /**
+ * Build one slot row with download and replace controls
+ *
+ * @param {number} slot 1-based save slot
+ * @param {number} modifiedAt Slot modification time in ms
+ */
+ function createSaveRow(slot, modifiedAt) {
+ const row = document.createElement("div");
+ row.className = "save-row";
+
+ const name = document.createElement("span");
+ name.className = "save-name";
+ name.textContent = `File ${slot}`;
+
+ const age = document.createElement("span");
+ age.className = "save-age";
+ age.textContent = formatSaveAge(modifiedAt);
+ name.append(age);
+
+ row.append(
+ name,
+ createSaveButton(iconDownload, `Download file ${slot}`, () =>
+ player.api.downloadSavegame(slot),
+ ),
+ createSaveButton(iconUpload, `Replace file ${slot}`, () =>
+ uploadToSlot(slot),
+ ),
+ );
+ return row;
+ }
+
+ /**
+ * Icon-only button whose label doubles as aria-label and tooltip
+ */
+ function createSaveButton(icon, label, onClick) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "save-button save-button-icon";
+ button.setAttribute("aria-label", label);
+ button.title = label;
+ button.innerHTML = icon;
+ button.addEventListener("click", onClick);
+ return button;
+ }
+
+ /**
+ * Lowest unoccupied slot, or `undefined` when every slot is taken
+ *
+ * @param {number[]} usedSlots Occupied slot numbers
+ * @returns {number | undefined}
+ */
+ function firstFreeSlot(usedSlots) {
+ for (let slot = 1; slot <= saveSlotCount; slot++) {
+ if (!usedSlots.includes(slot)) return slot;
+ }
+ return undefined;
+ }
+
+ /**
+ * Friendly relative age of a save, e.g. "just now" or "2 days ago".
+ * Relies on IDBFS persisting each slot's mtime across sessions.
+ *
+ * @param {number} timestamp Modification time in ms
+ */
+ function formatSaveAge(timestamp) {
+ const ageSeconds = (Date.now() - timestamp) / 1000;
+ const units = [
+ ["year", 31536000],
+ ["month", 2592000],
+ ["day", 86400],
+ ["hour", 3600],
+ ["minute", 60],
+ ];
+ for (const [unit, secondsPerUnit] of units) {
+ if (ageSeconds >= secondsPerUnit) {
+ return relativeTimeFormatter.format(
+ -Math.floor(ageSeconds / secondsPerUnit),
+ unit,
+ );
+ }
+ }
+ return "just now";
+ }
+
+ /**
+ * Hand a slot to the engine's upload picker, then refresh the list.
+ * The picker writes straight into the in-memory FS, but its hidden
+ * is detached from the document, so no change event reaches us –
+ * poll the directory until the slot actually lands instead.
+ *
+ * @param {number} slot 1-based slot to write the chosen file into
+ */
+ function uploadToSlot(slot) {
+ player.api.uploadSavegame(slot);
+ watchForSaveChange();
+ }
+
+ /**
+ * Poll until the picked file lands, then refresh the list. Gives up after
+ * a minute or once the panel closes – the user dismissed the picker
+ * without choosing a file.
+ */
+ function watchForSaveChange() {
+ clearInterval(saveWatchId);
+ const slotsBeforeUpload = readSaveSlots();
+ let elapsedMs = 0;
+ saveWatchId = setInterval(() => {
+ elapsedMs += 500;
+ if (slotsChanged(slotsBeforeUpload, readSaveSlots())) {
+ renderSaveSlots();
+ clearInterval(saveWatchId);
+ } else if (elapsedMs >= 60000 || !saveDialog.open) {
+ clearInterval(saveWatchId);
+ }
+ }, 500);
+ }
+
+ /**
+ * Whether a later slot read differs – a slot appeared or was overwritten
+ */
+ function slotsChanged(previousSlots, currentSlots) {
+ if (previousSlots.size !== currentSlots.size) return true;
+ for (const [slot, mtime] of currentSlots) {
+ if (previousSlots.get(slot) !== mtime) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Watch for the first save write and, once it lands, reveal a
+ * one-time hint that saves can be backed up. Skipped entirely if the
+ * hint was already shown in an earlier session.
+ */
+ function setupSaveHint() {
+ const toast = document.getElementById("save-toast");
+ toast.querySelector("span").textContent =
+ `Game saved – ${hasTouchscreen ? "tap" : "click"} to back up`;
+ toast.addEventListener("click", () => {
+ hideSaveToast();
+ openSaveDialog();
+ });
+
+ let alreadySeen = false;
+ try {
+ alreadySeen = Boolean(localStorage.getItem(saveHintStorageKey));
+ } catch {}
+ if (alreadySeen) return;
+
+ saveHintPollId = setInterval(pollForNewSave, 4000);
+ }
+
+ /**
+ * Compare the current slots against the first observed snapshot; a
+ * new or freshly overwritten slot reveals the hint once
+ */
+ function pollForNewSave() {
+ const currentSlots = readSaveSlots();
+ if (saveSlotBaseline === undefined) {
+ // The first read is the baseline, never itself a "new save"
+ saveSlotBaseline = currentSlots;
+ return;
+ }
+ for (const [slot, mtime] of currentSlots) {
+ const previousMtime = saveSlotBaseline.get(slot);
+ if (previousMtime === undefined || mtime > previousMtime) {
+ revealSaveHint();
+ return;
+ }
+ }
+ saveSlotBaseline = currentSlots;
+ }
+
+ function revealSaveHint() {
+ clearInterval(saveHintPollId);
+ try {
+ localStorage.setItem(saveHintStorageKey, "1");
+ } catch {}
+ document.getElementById("save-toast").classList.add("visible");
+ setTimeout(hideSaveToast, 6000);
+ }
+
+ function hideSaveToast() {
+ document.getElementById("save-toast").classList.remove("visible");
+ }
+
+