diff --git a/resources/emscripten/emscripten-shell.html b/resources/emscripten/emscripten-shell.html index ebe713eb99..044b4ae419 100644 --- a/resources/emscripten/emscripten-shell.html +++ b/resources/emscripten/emscripten-shell.html @@ -1,365 +1,581 @@ - - - - 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: 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; + } + + #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; + } + + /* `!important` beats the inline size SDL writes onto the canvas + while it manages its window size, which shrinks the canvas when + it measures a zero-height body */ + #canvas { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; + } + + @media (hover: none), (pointer: coarse) { + #controls button { + min-block-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; + } + + #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 }}} - - - - + fullscreenButton.hidden = true; + } + + document.addEventListener("fullscreenchange", () => { + canvas.focus({ preventScroll: true }); + // The icon swaps via the :fullscreen CSS rule; only the label, which + // CSS cannot set, has to follow the state here + const label = document.fullscreenElement + ? "Exit fullscreen" + : "Enter fullscreen"; + fullscreenButton.setAttribute("aria-label", label); + fullscreenButton.title = label; + }); + + if (hasTouchscreen) { + for (const button of document.querySelectorAll("[data-key]")) { + bindKey(button, button.dataset.key); + } + wakeTouchControls(); + } else { + // 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 physical 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, + ); + } + + /** + * 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 {} + } + +