-
+ --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
+ }
+ }
+
+