Add optional mobile controls

This commit is contained in:
2026-03-12 12:59:24 +01:00
parent 17b6bf9b2d
commit dc8602cacf
4 changed files with 253 additions and 0 deletions

View File

@@ -95,6 +95,27 @@
<button id="closeSettingsButton">Close</button>
</div>
</div>
<div id="mobileControls" class="mobile-controls" data-expanded="false" aria-label="Mobile game controls">
<button id="mobileControlsToggle" type="button" class="mobile-toggle-btn"
aria-expanded="false" aria-controls="mobileControlsBody">&#9776; Controls</button>
<div id="mobileControlsBody" class="mobile-controls-body">
<div class="dpad" role="group" aria-label="Movement">
<button id="dpadUp" type="button" class="dpad-btn dpad-up" aria-label="Move up">&#9650;</button>
<button id="dpadLeft" type="button" class="dpad-btn dpad-left" aria-label="Move left">&#9664;</button>
<div class="dpad-center" aria-hidden="true"></div>
<button id="dpadRight" type="button" class="dpad-btn dpad-right" aria-label="Move right">&#9654;</button>
<button id="dpadDown" type="button" class="dpad-btn dpad-down" aria-label="Move down">&#9660;</button>
</div>
<div class="mobile-actions" role="group" aria-label="Actions">
<button id="mobileBtnChat" type="button" class="mobile-action-btn">Chat</button>
<button id="mobileBtnUse" type="button" class="mobile-action-btn">Use</button>
<button id="mobileBtnLocateUser" type="button" class="mobile-action-btn">Find User</button>
<button id="mobileBtnLocateItem" type="button" class="mobile-action-btn">Find Item</button>
<button id="mobileBtnCommands" type="button" class="mobile-action-btn mobile-action-btn--wide">Commands</button>
</div>
</div>
</div>
</main>
<script src="%BASE_URL%version.js"></script>
<script type="module" src="/src/main.ts"></script>

View File

@@ -0,0 +1,91 @@
import type { ModeInput } from './commandTypes';
type MobileControllerDeps = {
dom: {
canvas: HTMLCanvasElement;
mobileControls: HTMLDivElement;
toggleButton: HTMLButtonElement;
dpadUp: HTMLButtonElement;
dpadDown: HTMLButtonElement;
dpadLeft: HTMLButtonElement;
dpadRight: HTMLButtonElement;
btnChat: HTMLButtonElement;
btnUse: HTMLButtonElement;
btnLocateUser: HTMLButtonElement;
btnLocateItem: HTMLButtonElement;
btnCommandPalette: HTMLButtonElement;
};
state: {
running: boolean;
keysPressed: Record<string, boolean>;
};
handleModeInput: (input: ModeInput) => void;
openCommandPalette: () => void;
};
/**
* Wires touch handlers for the on-screen mobile controls panel.
* Movement uses hold-to-walk by writing directly into keysPressed (same as the
* keyboard controller). Action buttons dispatch through handleModeInput so they
* travel the same code path as their keyboard equivalents.
*/
export function setupMobileControls(deps: MobileControllerDeps): void {
const { dom, state } = deps;
// ── Toggle ───────────────────────────────────────────────────────────────
dom.toggleButton.addEventListener('click', () => {
const expanded = dom.mobileControls.dataset['expanded'] === 'true';
const next = String(!expanded);
dom.mobileControls.dataset['expanded'] = next;
dom.toggleButton.setAttribute('aria-expanded', next);
});
// ── D-pad movement ───────────────────────────────────────────────────────
const dpadMap: Array<[HTMLButtonElement, string]> = [
[dom.dpadUp, 'ArrowUp'],
[dom.dpadDown, 'ArrowDown'],
[dom.dpadLeft, 'ArrowLeft'],
[dom.dpadRight, 'ArrowRight'],
];
for (const [btn, arrowCode] of dpadMap) {
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
if (!state.running) return;
state.keysPressed[arrowCode] = true;
}, { passive: false });
btn.addEventListener('touchend', () => {
state.keysPressed[arrowCode] = false;
});
btn.addEventListener('touchcancel', () => {
state.keysPressed[arrowCode] = false;
});
}
// ── Action buttons ───────────────────────────────────────────────────────
type ActionDef = [HTMLButtonElement, string, string];
const actionMap: ActionDef[] = [
[dom.btnChat, 'Slash', '/'],
[dom.btnUse, 'Enter', 'Enter'],
[dom.btnLocateUser, 'KeyL', 'l'],
[dom.btnLocateItem, 'KeyI', 'i'],
];
for (const [btn, code, key] of actionMap) {
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
if (!state.running) return;
dom.canvas.focus();
deps.handleModeInput({ code, key, ctrlKey: false, shiftKey: false });
}, { passive: false });
}
dom.btnCommandPalette.addEventListener('touchstart', (e) => {
e.preventDefault();
if (!state.running) return;
dom.canvas.focus();
deps.openCommandPalette();
}, { passive: false });
}

View File

@@ -31,6 +31,7 @@ import { dispatchModeInput } from './input/modeDispatcher';
import { handleListControlKey } from './input/listController';
import { createAdminController, type AdminMenuAction } from './input/adminController';
import { setupKeyboardInputHandlers } from './input/keyboardController';
import { setupMobileControls } from './input/mobileController';
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
import { getEditSessionAction } from './input/editSession';
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
@@ -2679,6 +2680,25 @@ setupKeyboardInputHandlers({
replaceTextOnNextType = value;
},
});
setupMobileControls({
dom: {
canvas: dom.canvas,
mobileControls: requiredById('mobileControls') as HTMLDivElement,
toggleButton: requiredById('mobileControlsToggle') as HTMLButtonElement,
dpadUp: requiredById('dpadUp') as HTMLButtonElement,
dpadDown: requiredById('dpadDown') as HTMLButtonElement,
dpadLeft: requiredById('dpadLeft') as HTMLButtonElement,
dpadRight: requiredById('dpadRight') as HTMLButtonElement,
btnChat: requiredById('mobileBtnChat') as HTMLButtonElement,
btnUse: requiredById('mobileBtnUse') as HTMLButtonElement,
btnLocateUser: requiredById('mobileBtnLocateUser') as HTMLButtonElement,
btnLocateItem: requiredById('mobileBtnLocateItem') as HTMLButtonElement,
btnCommandPalette: requiredById('mobileBtnCommands') as HTMLButtonElement,
},
state,
handleModeInput,
openCommandPalette,
});
setupDomUiHandlers({
dom,
updateConnectAvailability,

View File

@@ -234,3 +234,124 @@ canvas {
display: grid;
gap: 0.5rem;
}
/* ── Mobile controls panel ─────────────────────────────────────────────── */
/* Hide on pointer:fine (mouse) devices; show on touch screens */
@media (hover: hover) and (pointer: fine) {
.mobile-controls {
display: none;
}
}
/* Extra bottom padding so the fixed panel doesn't cover footer content */
@media not all and (hover: hover) and (pointer: fine) {
.app {
padding-bottom: 3rem;
}
}
.mobile-controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgb(15 23 42 / 95%);
border-top: 1px solid #334155;
z-index: 100;
}
.mobile-toggle-btn {
display: block;
width: 100%;
padding: 0.5rem 1rem;
background: none;
border: none;
color: #e5e7eb;
font-family: inherit;
font-size: 1rem;
text-align: left;
cursor: pointer;
touch-action: manipulation;
}
.mobile-controls-body {
display: none;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1rem 1rem;
align-items: flex-start;
justify-content: center;
}
.mobile-controls[data-expanded="true"] .mobile-controls-body {
display: flex;
}
/* D-pad: 3×3 cross layout */
.dpad {
display: grid;
grid-template-areas:
". up ."
"left mid right"
". down .";
grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(3, 52px);
gap: 2px;
}
.dpad-btn {
background: #1e293b;
border: 1px solid #475569;
border-radius: 6px;
color: #e5e7eb;
font-size: 1.1rem;
cursor: pointer;
touch-action: none;
-webkit-user-select: none;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
}
.dpad-btn:active {
background: #334155;
}
.dpad-up { grid-area: up; }
.dpad-left { grid-area: left; }
.dpad-center { grid-area: mid; }
.dpad-right { grid-area: right; }
.dpad-down { grid-area: down; }
/* Action buttons row */
.mobile-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.mobile-action-btn {
min-height: 48px;
padding: 0.25rem 0.75rem;
background: #1e293b;
border: 1px solid #475569;
border-radius: 6px;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
}
.mobile-action-btn:active {
background: #334155;
}
.mobile-action-btn--wide {
flex: 1 0 100%;
}