Add optional mobile controls
This commit is contained in:
@@ -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">☰ 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">▲</button>
|
||||
<button id="dpadLeft" type="button" class="dpad-btn dpad-left" aria-label="Move left">◀</button>
|
||||
<div class="dpad-center" aria-hidden="true"></div>
|
||||
<button id="dpadRight" type="button" class="dpad-btn dpad-right" aria-label="Move right">▶</button>
|
||||
<button id="dpadDown" type="button" class="dpad-btn dpad-down" aria-label="Move down">▼</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>
|
||||
|
||||
91
client/src/input/mobileController.ts
Normal file
91
client/src/input/mobileController.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user