gyro and touchscreen stuff

This commit is contained in:
Brandon Johnson
2026-01-26 10:54:48 -05:00
parent 1006c662c7
commit 19895401c1
4 changed files with 272 additions and 8 deletions

View File

@@ -55,7 +55,16 @@
<label id="control-mode-field" class="field hidden">
<span>Control Mode</span>
<select id="control-mode"></select>
<div class="control-mode-row">
<select id="control-mode"></select>
<button id="gyro-recalibrate" class="ghost compact hidden" type="button">Recalibrate</button>
<div id="gyro-helper" class="gyro-helper hidden" aria-hidden="true">
<div class="gyro-helper-frame">
<div id="gyro-helper-ghost" class="gyro-helper-ghost"></div>
<div id="gyro-helper-device" class="gyro-helper-device"></div>
</div>
</div>
</div>
</label>
<div id="smb1-fields">
<label class="field">
@@ -149,11 +158,18 @@
</div>
</div>
<button id="mobile-menu-button" class="mobile-menu-button hidden" type="button">
Level Select
</button>
<script type="module">
const field = document.getElementById('control-mode-field');
const select = document.getElementById('control-mode');
const hasTouch = ('ontouchstart' in window) || ((navigator.maxTouchPoints ?? 0) > 0);
const hasGyro = typeof window.DeviceOrientationEvent !== 'undefined';
const hasCoarsePointer = window.matchMedia?.('(pointer: coarse)')?.matches ?? false;
const hasGyro = typeof window.DeviceOrientationEvent !== 'undefined' && (hasTouch || hasCoarsePointer);
const gyroButton = document.getElementById('gyro-recalibrate');
const gyroHelper = document.getElementById('gyro-helper');
if (!field || !select || (!hasTouch && !hasGyro)) {
field?.classList.add('hidden');
@@ -161,7 +177,7 @@
field.classList.remove('hidden');
const options = [];
//if (hasGyro) options.push({ value: 'gyro', label: 'Gyro' });
if (hasGyro) options.push({ value: 'gyro', label: 'Gyro' });
if (hasTouch) options.push({ value: 'touch', label: 'Touchscreen' });
select.replaceChildren(...options.map((opt) => {
@@ -180,6 +196,14 @@
localStorage.setItem(key, select.value);
}
const syncGyroUi = () => {
const showGyro = select.value === 'gyro';
gyroButton?.classList.toggle('hidden', !showGyro);
gyroHelper?.classList.toggle('hidden', !showGyro);
};
syncGyroUi();
select.addEventListener('change', async () => {
let next = select.value;
@@ -198,6 +222,7 @@
}
localStorage.setItem(key, next);
syncGyroUi();
});
}
</script>

View File

@@ -6,7 +6,8 @@ export class Input {
this.controlModeKey = 'smb_control_mode';
this.hasTouch = ('ontouchstart' in window) || ((navigator.maxTouchPoints ?? 0) > 0);
this.hasGyro = typeof window.DeviceOrientationEvent !== 'undefined';
const hasCoarsePointer = window.matchMedia?.('(pointer: coarse)')?.matches ?? false;
this.hasGyro = typeof window.DeviceOrientationEvent !== 'undefined' && (this.hasTouch || hasCoarsePointer);
this.touch = {
pointerId: null,
@@ -16,12 +17,22 @@ export class Input {
value: { x: 0, y: 0 },
};
this.touchAction = {
pointerId: null,
active: false,
};
this.gyro = {
baselineSet: false,
baseBeta: 0,
baseGamma: 0,
value: { x: 0, y: 0 },
};
this.gyroRaw = {
beta: 0,
gamma: 0,
hasSample: false,
};
this.touchRoot = document.getElementById('touch-controls');
this.joystickEl = this.touchRoot?.querySelector?.('.joystick') ?? null;
@@ -98,12 +109,42 @@ export class Input {
if (event.pointerId === this.touch.pointerId) {
this.endTouch();
}
if (event.pointerId === this.touchAction.pointerId) {
this.endTouchAction();
}
},
pointercancel: (event) => {
if (event.pointerId === this.touch.pointerId) {
this.endTouch();
}
if (event.pointerId === this.touchAction.pointerId) {
this.endTouchAction();
}
},
pointerdownAction: (event) => {
if (!this.hasTouch) {
return;
}
if (event.pointerType !== 'touch') {
return;
}
if (this.isOverlayVisible()) {
return;
}
if (event.target instanceof HTMLElement) {
const interactive = event.target.closest('button, input, select, textarea, a, label');
if (interactive) {
return;
}
}
if (this.touchAction.pointerId !== null) {
return;
}
event.preventDefault();
this.touchAction.pointerId = event.pointerId;
this.touchAction.active = true;
},
deviceorientation: (event) => {
@@ -116,6 +157,9 @@ export class Input {
const beta = typeof event.beta === 'number' ? event.beta : 0;
const gamma = typeof event.gamma === 'number' ? event.gamma : 0;
this.gyroRaw.beta = beta;
this.gyroRaw.gamma = gamma;
this.gyroRaw.hasSample = true;
if (!this.gyro.baselineSet) {
this.gyro.baselineSet = true;
@@ -150,6 +194,9 @@ export class Input {
window.addEventListener('pointerup', this.handlers.pointerup);
window.addEventListener('pointercancel', this.handlers.pointercancel);
}
if (this.hasTouch) {
window.addEventListener('pointerdown', this.handlers.pointerdownAction, { passive: false });
}
if (this.hasGyro) {
window.addEventListener('deviceorientation', this.handlers.deviceorientation);
@@ -169,6 +216,9 @@ export class Input {
window.removeEventListener('pointerup', this.handlers.pointerup);
window.removeEventListener('pointercancel', this.handlers.pointercancel);
}
if (this.hasTouch) {
window.removeEventListener('pointerdown', this.handlers.pointerdownAction);
}
if (this.hasGyro) {
window.removeEventListener('deviceorientation', this.handlers.deviceorientation);
@@ -254,10 +304,39 @@ export class Input {
this.hideJoystick();
}
endTouchAction() {
this.touchAction.active = false;
this.touchAction.pointerId = null;
}
isDown(code) {
return this.down.has(code);
}
recalibrateGyro() {
if (!this.hasGyro) {
return;
}
if (this.gyroRaw.hasSample) {
this.gyro.baseBeta = this.gyroRaw.beta;
this.gyro.baseGamma = this.gyroRaw.gamma;
this.gyro.baselineSet = true;
} else {
this.gyro.baselineSet = false;
}
}
getGyroSample() {
return {
beta: this.gyroRaw.beta,
gamma: this.gyroRaw.gamma,
baseBeta: this.gyro.baseBeta,
baseGamma: this.gyro.baseGamma,
baselineSet: this.gyro.baselineSet,
hasSample: this.gyroRaw.hasSample,
};
}
wasPressed(code) {
if (this.pressed.has(code)) {
this.pressed.delete(code);
@@ -297,6 +376,9 @@ export class Input {
if (this.isDown('Space') || this.isDown('Enter') || this.isDown('KeyZ')) {
return true;
}
if (this.touchAction.active) {
return true;
}
const pad = this.getActiveGamepad();
if (!pad?.buttons?.length) {
return false;

View File

@@ -34,6 +34,16 @@ import type { StageData } from './noclip/SuperMonkeyBall/World.js';
import { convertSmb2StageDef, getMb2wsStageInfo, getSmb2StageInfo } from './smb2_render.js';
import { HudRenderer } from './hud.js';
function clamp(value: number, min: number, max: number) {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
const STAGE_BASE_PATH = STAGE_BASE_PATHS[GAME_SOURCES.SMB1];
const NAOMI_STAGE_IDS = new Set([
10, 19, 20, 30, 49, 50, 60, 70, 80, 92, 96, 97, 98, 99, 100, 114, 115, 116, 117, 118, 119, 120,
@@ -47,6 +57,11 @@ const canvas = document.getElementById('game') as HTMLCanvasElement;
const hudCanvas = document.getElementById('hud-canvas') as HTMLCanvasElement;
const overlay = document.getElementById('overlay') as HTMLElement;
const stageFade = document.getElementById('stage-fade') as HTMLElement;
const mobileMenuButton = document.getElementById('mobile-menu-button') as HTMLButtonElement | null;
const controlModeSelect = document.getElementById('control-mode') as HTMLSelectElement | null;
const gyroRecalibrateButton = document.getElementById('gyro-recalibrate') as HTMLButtonElement | null;
const gyroHelper = document.getElementById('gyro-helper') as HTMLElement | null;
const gyroHelperFrame = gyroHelper?.querySelector('.gyro-helper-frame') as HTMLElement | null;
const startButton = document.getElementById('start') as HTMLButtonElement;
const resumeButton = document.getElementById('resume') as HTMLButtonElement;
const difficultySelect = document.getElementById('difficulty') as HTMLSelectElement;
@@ -69,10 +84,22 @@ const announcerVolumeValue = document.getElementById('announcer-volume-value') a
const hudStatus = document.getElementById('hud-status') as HTMLElement | null;
const setOverlayVisible = (visible: boolean) => {
const hasTouch = ('ontouchstart' in window) || ((navigator.maxTouchPoints ?? 0) > 0);
function updateMobileMenuButtonVisibility() {
if (!mobileMenuButton) {
return;
}
const shouldShow = hasTouch && overlay.classList.contains('hidden') && running;
mobileMenuButton.classList.toggle('hidden', !shouldShow);
}
function setOverlayVisible(visible: boolean) {
overlay.classList.toggle('hidden', !visible);
canvas.style.pointerEvents = visible ? 'none' : 'auto';
};
document.body.classList.toggle('gameplay-active', !visible);
updateMobileMenuButtonVisibility();
}
const STAGE_FADE_MS = 333;
@@ -295,9 +322,11 @@ const game = new Game({
resumeButton.disabled = false;
},
onPaused: () => {
paused = true;
setOverlayVisible(true);
},
onResumed: () => {
paused = false;
setOverlayVisible(false);
},
onStageLoaded: (stageId) => {
@@ -437,6 +466,7 @@ async function handleStageLoaded(stageId: number) {
paused = false;
renderReady = true;
lastTime = performance.now();
updateMobileMenuButtonVisibility();
maybeStartSmb2LikeStageFade();
return;
}
@@ -462,6 +492,7 @@ async function handleStageLoaded(stageId: number) {
paused = false;
renderReady = true;
lastTime = performance.now();
updateMobileMenuButtonVisibility();
maybeStartSmb2LikeStageFade();
}
@@ -648,6 +679,7 @@ function renderFrame(now: number) {
resizeCanvasToDisplaySize(canvas);
resizeCanvasToDisplaySize(hudCanvas);
hudRenderer.resize(hudCanvas.width, hudCanvas.height);
updateGyroHelper();
if (game.loadingStage) {
const hudDelta = now - lastHudTime;
@@ -699,6 +731,27 @@ function renderFrame(now: number) {
hudRenderer.render(game, dtSeconds);
}
function updateGyroHelper() {
if (!controlModeSelect || !gyroHelper || !gyroHelperFrame) {
return;
}
const showGyro = controlModeSelect.value === 'gyro';
gyroHelper.classList.toggle('hidden', !showGyro);
if (!showGyro) {
return;
}
const sample = game.input?.getGyroSample?.();
if (!sample || !sample.hasSample) {
gyroHelperFrame.style.opacity = '0.5';
return;
}
const x = clamp(-sample.beta, -30, 30);
const y = clamp(sample.gamma, -30, 30);
gyroHelperFrame.style.opacity = '1';
gyroHelperFrame.style.setProperty('--gyro-x', `${x}deg`);
gyroHelperFrame.style.setProperty('--gyro-y', `${y}deg`);
}
setOverlayVisible(true);
startButton.disabled = false;
@@ -759,6 +812,15 @@ resumeButton.addEventListener('click', () => {
game.resume();
});
gyroRecalibrateButton?.addEventListener('click', () => {
game.input?.recalibrateGyro?.();
});
mobileMenuButton?.addEventListener('click', () => {
paused = true;
game.pause();
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
paused = true;

View File

@@ -21,12 +21,20 @@ body {
color: var(--fg);
}
body.gameplay-active {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
#game {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
#hud-canvas {
@@ -37,6 +45,8 @@ body {
display: block;
pointer-events: none;
z-index: 6;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.touch-controls {
@@ -45,6 +55,7 @@ body {
z-index: 7;
pointer-events: none;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.touch-controls.active {
@@ -107,6 +118,7 @@ body {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(6px);
z-index: 10;
padding: 16px;
}
.overlay.hidden {
@@ -118,12 +130,15 @@ body {
}
.panel {
width: min(520px, 92vw);
width: min(560px, 94vw);
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 24px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
max-height: calc(100vh - 32px);
overflow-y: auto;
overscroll-behavior: contain;
}
.panel h1 {
@@ -145,6 +160,65 @@ body {
color: var(--muted);
}
.control-mode-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.control-mode-row select {
flex: 1 1 200px;
}
.control-mode-row .compact {
flex: 0 0 auto;
padding: 8px 12px;
font-size: 12px;
}
.gyro-helper {
width: 72px;
height: 72px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.gyro-helper-frame {
position: relative;
width: 100%;
height: 100%;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(8, 8, 12, 0.6);
box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.45);
perspective: 220px;
--gyro-x: 0deg;
--gyro-y: 0deg;
}
.gyro-helper-ghost,
.gyro-helper-device {
position: absolute;
inset: 14px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.3);
transform-style: preserve-3d;
}
.gyro-helper-ghost {
background: rgba(255, 255, 255, 0.06);
transform: rotateX(60deg) rotateZ(45deg);
}
.gyro-helper-device {
background: rgba(255, 159, 28, 0.2);
border-color: rgba(255, 159, 28, 0.65);
transform: rotateX(calc(60deg + var(--gyro-x))) rotateZ(45deg) rotateY(var(--gyro-y));
transition: transform 80ms linear;
}
.checkbox-field {
margin-top: 4px;
}
@@ -320,4 +394,25 @@ button:disabled {
.credits-panel span {
color: var(--muted);
margin-left: 4px;
}
}
.mobile-menu-button {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
width: min(420px, 92vw);
height: 40px;
padding: 6px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(8, 8, 12, 0.78);
color: var(--fg);
font-weight: 600;
letter-spacing: 0.3px;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 9;
backdrop-filter: blur(6px);
}