Add optional mobile controls
This commit is contained in:
@@ -95,6 +95,27 @@
|
|||||||
<button id="closeSettingsButton">Close</button>
|
<button id="closeSettingsButton">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
<script src="%BASE_URL%version.js"></script>
|
<script src="%BASE_URL%version.js"></script>
|
||||||
<script type="module" src="/src/main.ts"></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 { handleListControlKey } from './input/listController';
|
||||||
import { createAdminController, type AdminMenuAction } from './input/adminController';
|
import { createAdminController, type AdminMenuAction } from './input/adminController';
|
||||||
import { setupKeyboardInputHandlers } from './input/keyboardController';
|
import { setupKeyboardInputHandlers } from './input/keyboardController';
|
||||||
|
import { setupMobileControls } from './input/mobileController';
|
||||||
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
|
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu';
|
||||||
import { getEditSessionAction } from './input/editSession';
|
import { getEditSessionAction } from './input/editSession';
|
||||||
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
import { formatSteppedNumber, snapNumberToStep } from './input/numeric';
|
||||||
@@ -2679,6 +2680,25 @@ setupKeyboardInputHandlers({
|
|||||||
replaceTextOnNextType = value;
|
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({
|
setupDomUiHandlers({
|
||||||
dom,
|
dom,
|
||||||
updateConnectAvailability,
|
updateConnectAvailability,
|
||||||
|
|||||||
@@ -234,3 +234,124 @@ canvas {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
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