diff --git a/resources/emscripten/emscripten-shell.html b/resources/emscripten/emscripten-shell.html index ebe713eb99..b68f264a86 100644 --- a/resources/emscripten/emscripten-shell.html +++ b/resources/emscripten/emscripten-shell.html @@ -1,365 +1,681 @@ - - - - EasyRPG Player - - - -
- -
- -
- -
- - -
- - - - - - - + --color-gray: hsl(0 0% 55%); + --controls-size: clamp(3.5rem, 20vmin, 5.5rem); + --controls-opacity: 0.4; + --controls-fade: 80ms; + } + + 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: env(safe-area-inset-top); + right: env(safe-area-inset-right); + z-index: 10; + } + + #controls button { + display: inline-flex; + padding: 0.5rem; + 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; + } + + #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) { + #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; + } + } + + + +
+ + +
+ +
+
-
-
-
+
+ + + + +
-
- -{{{ SCRIPT }}} - - - - + // Stop the browser's default for keys the game uses + window.addEventListener("keydown", (event) => { + 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 Type of the keyboard event + * @param {string} code Physical key code to simulate (e.g. "ArrowUp") + */ + function simulateKeyboardEvent(eventType, code) { + canvas.dispatchEvent( + new KeyboardEvent(eventType, { code, bubbles: true }), + ); + } + + /** + * Bind an element by a specific key to simulate on touch + * + * @param {HTMLElement} element The element to bind a key to + * @param {string} key Key to simulate + */ + function bindKey(element, key) { + keys.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 { + // The pointer may already be gone + } + }); + + // 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 && keys.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", keys.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", keys.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 { + // The toggle still works without persistence + } + } + + 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 { + // Keeping the screen awake is a progressive enhancement; the + // request is denied e.g. in power-save mode + } + } + +