diff --git a/resources/emscripten/emscripten-shell.html b/resources/emscripten/emscripten-shell.html index ebe713eb99..5377356b71 100644 --- a/resources/emscripten/emscripten-shell.html +++ b/resources/emscripten/emscripten-shell.html @@ -1,365 +1,1433 @@ - - - - EasyRPG Player - - - -
- -
- -
- -
- - -
- - - - - - - + --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); + } + + + + + +
+
-
-
-
+ +
+
+ + + +
+ + + + +
+

Saves

+ +
+ +
+ +
+ + + +
+
+ + + + + +
-
- -{{{ 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"); + } + +