improve toolbar on mobile, add joystick modes

This commit is contained in:
Brian Beck 2026-02-12 12:04:26 -08:00
parent 68c7b303aa
commit d9293b1a0b
30 changed files with 480 additions and 152 deletions

View file

@ -47,14 +47,16 @@ export function CopyCoordinatesButton({
return (
<button
type="button"
className="IconButton CopyCoordinatesButton"
className="IconButton LabelledButton CopyCoordinatesButton"
aria-label="Copy coordinates URL"
title="Copy coordinates URL"
onClick={handleCopyLink}
data-copied={showCopied ? "true" : "false"}
id="copyCoordinatesButton"
>
<FaMapPin className="MapPin" />
<FaClipboardCheck className="ClipboardCheck" />
<span className="ButtonLabel"> Copy coordinates URL</span>
</button>
);
}

View file

@ -1,14 +1,21 @@
import { useControls, useDebug, useSettings } from "./SettingsProvider";
import {
useControls,
useDebug,
useSettings,
type TouchMode,
} from "./SettingsProvider";
import { MissionSelect } from "./MissionSelect";
import { RefObject } from "react";
import { RefObject, useEffect, useState, useRef } from "react";
import { Camera } from "three";
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
import { FiSettings } from "react-icons/fi";
export function InspectorControls({
missionName,
missionType,
onChangeMission,
cameraRef,
isTouch,
}: {
missionName: string;
missionType: string;
@ -20,6 +27,7 @@ export function InspectorControls({
missionType: string;
}) => void;
cameraRef: RefObject<Camera | null>;
isTouch: boolean | null;
}) {
const {
fogEnabled,
@ -31,8 +39,139 @@ export function InspectorControls({
animationEnabled,
setAnimationEnabled,
} = useSettings();
const { speedMultiplier, setSpeedMultiplier } = useControls();
const { speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode } =
useControls();
const { debugMode, setDebugMode } = useDebug();
const [settingsOpen, setSettingsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Focus the panel when it opens.
useEffect(() => {
if (settingsOpen) {
dropdownRef.current?.focus();
}
}, [settingsOpen]);
const handleDropdownBlur = (e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (
related &&
(dropdownRef.current?.contains(related) ||
buttonRef.current?.contains(related))
) {
return;
}
setSettingsOpen(false);
};
// Close on Escape and return focus to the gear button.
const handlePanelKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setSettingsOpen(false);
buttonRef.current?.focus();
}
};
const settingsFields = (
<>
<div className="Controls-group">
<CopyCoordinatesButton cameraRef={cameraRef} />
</div>
<div className="Controls-group">
<div className="CheckboxField">
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
<div className="CheckboxField">
<input
id="audioInput"
type="checkbox"
checked={audioEnabled}
onChange={(event) => {
setAudioEnabled(event.target.checked);
}}
/>
<label htmlFor="audioInput">Audio?</label>
</div>
</div>
<div className="Controls-group">
<div className="CheckboxField">
<input
id="animationInput"
type="checkbox"
checked={animationEnabled}
onChange={(event) => {
setAnimationEnabled(event.target.checked);
}}
/>
<label htmlFor="animationInput">Animation?</label>
</div>
<div className="CheckboxField">
<input
id="debugInput"
type="checkbox"
checked={debugMode}
onChange={(event) => {
setDebugMode(event.target.checked);
}}
/>
<label htmlFor="debugInput">Debug?</label>
</div>
</div>
<div className="Controls-group">
<div className="Field">
<label htmlFor="fovInput">FOV</label>
<input
id="fovInput"
type="range"
min={75}
max={120}
step={5}
value={fov}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
<output htmlFor="fovInput">{fov}</output>
</div>
<div className="Field">
<label htmlFor="speedInput">Speed</label>
<input
id="speedInput"
type="range"
min={0.1}
max={5}
step={0.05}
value={speedMultiplier}
onChange={(event) =>
setSpeedMultiplier(parseFloat(event.target.value))
}
/>
</div>
</div>
{isTouch && (
<div className="Controls-group">
<div className="Field">
<label htmlFor="touchModeInput">Joystick:</label>{" "}
<select
id="touchModeInput"
value={touchMode}
onChange={(e) => setTouchMode(e.target.value as TouchMode)}
>
<option value="dualStick">Dual Stick</option>
<option value="moveLookStick">Single Stick</option>
</select>
</div>
</div>
)}
</>
);
return (
<div
@ -46,77 +185,27 @@ export function InspectorControls({
missionType={missionType}
onChange={onChangeMission}
/>
<CopyCoordinatesButton cameraRef={cameraRef} />
<div className="CheckboxField">
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
<div className="CheckboxField">
<input
id="audioInput"
type="checkbox"
checked={audioEnabled}
onChange={(event) => {
setAudioEnabled(event.target.checked);
}}
/>
<label htmlFor="audioInput">Audio?</label>
</div>
<div className="CheckboxField">
<input
id="animationInput"
type="checkbox"
checked={animationEnabled}
onChange={(event) => {
setAnimationEnabled(event.target.checked);
}}
/>
<label htmlFor="animationInput">Animation?</label>
</div>
<div className="CheckboxField">
<input
id="debugInput"
type="checkbox"
checked={debugMode}
onChange={(event) => {
setDebugMode(event.target.checked);
}}
/>
<label htmlFor="debugInput">Debug?</label>
</div>
<div className="Field">
<label htmlFor="fovInput">FOV</label>
<input
id="fovInput"
type="range"
min={75}
max={120}
step={5}
value={fov}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
<output htmlFor="speedInput">{fov}</output>
</div>
<div className="Field">
<label htmlFor="speedInput">Speed</label>
<input
id="speedInput"
type="range"
min={0.1}
max={5}
step={0.05}
value={speedMultiplier}
onChange={(event) =>
setSpeedMultiplier(parseFloat(event.target.value))
}
/>
<button
ref={buttonRef}
className="IconButton Controls-toggle"
onClick={() => setSettingsOpen((isOpen) => !isOpen)}
onDoubleClick={(e) => e.preventDefault()}
aria-expanded={settingsOpen}
aria-controls="settingsPanel"
aria-label="Settings"
>
<FiSettings />
</button>
<div
className="Controls-dropdown"
ref={dropdownRef}
id="settingsPanel"
tabIndex={-1}
onKeyDown={handlePanelKeyDown}
onBlur={handleDropdownBlur}
data-open={settingsOpen}
>
{settingsFields}
</div>
</div>
);

View file

@ -11,6 +11,8 @@ import {
type StateSetter<T> = ReturnType<typeof useState<T>>[1];
export type TouchMode = "dualStick" | "moveLookStick";
type SettingsContext = {
fogEnabled: boolean;
setFogEnabled: StateSetter<boolean>;
@ -32,6 +34,8 @@ type DebugContext = {
type ControlsContext = {
speedMultiplier: number;
setSpeedMultiplier: StateSetter<number>;
touchMode: TouchMode;
setTouchMode: StateSetter<TouchMode>;
};
const SettingsContext = createContext<SettingsContext | null>(null);
@ -46,6 +50,7 @@ type PersistedSettings = {
audioEnabled?: boolean;
animationEnabled?: boolean;
debugMode?: boolean;
touchMode?: TouchMode;
};
export function useSettings() {
@ -68,6 +73,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [audioEnabled, setAudioEnabled] = useState(false);
const [animationEnabled, setAnimationEnabled] = useState(true);
const [debugMode, setDebugMode] = useState(false);
const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick");
const settingsContext: SettingsContext = useMemo(
() => ({
@ -91,8 +97,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
);
const controlsContext: ControlsContext = useMemo(
() => ({ speedMultiplier, setSpeedMultiplier }),
[speedMultiplier, setSpeedMultiplier],
() => ({ speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode }),
[speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode],
);
// Read persisted settings from localStorage.
@ -124,6 +130,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
if (savedSettings.fov != null) {
setFov(savedSettings.fov);
}
if (savedSettings.touchMode != null) {
setTouchMode(savedSettings.touchMode);
}
}, []);
// Persist settings to localStorage with debouncing to avoid excessive writes
@ -145,6 +154,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
audioEnabled,
animationEnabled,
debugMode,
touchMode,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
@ -166,6 +176,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
audioEnabled,
animationEnabled,
debugMode,
touchMode,
]);
return (

View file

@ -6,6 +6,8 @@ import { useControls } from "./SettingsProvider";
const BASE_SPEED = 80;
const LOOK_SENSITIVITY = 0.004;
const STICK_LOOK_SENSITIVITY = 2.5;
const SINGLE_STICK_DEADZONE = 0.25;
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
export type JoystickState = {
@ -16,10 +18,19 @@ export type JoystickState = {
type SharedProps = {
joystickState: RefObject<JoystickState>;
joystickZone: RefObject<HTMLDivElement | null>;
lookJoystickState: RefObject<JoystickState>;
lookJoystickZone: RefObject<HTMLDivElement | null>;
};
/** Renders the joystick zone. Place inside canvasContainer, outside Canvas. */
export function TouchJoystick({ joystickState, joystickZone }: SharedProps) {
/** Renders the joystick zone(s). Place inside canvasContainer, outside Canvas. */
export function TouchJoystick({
joystickState,
joystickZone,
lookJoystickState,
lookJoystickZone,
}: SharedProps) {
const { touchMode } = useControls();
// Move joystick
useEffect(() => {
const zone = joystickZone.current;
if (!zone) return;
@ -39,7 +50,6 @@ export function TouchJoystick({ joystickState, joystickZone }: SharedProps) {
manager.on("move", (_event, data) => {
joystickState.current.angle = data.angle.radian;
// Clamp force to 01 range (nipplejs force can exceed 1)
joystickState.current.force = Math.min(1, data.force);
});
@ -52,17 +62,77 @@ export function TouchJoystick({ joystickState, joystickZone }: SharedProps) {
cancelled = true;
manager?.destroy();
};
}, [joystickState, joystickZone]);
}, [joystickState, joystickZone, touchMode]);
return <div ref={joystickZone} className="TouchJoystick" />;
// Look joystick (dual stick mode only)
useEffect(() => {
if (touchMode !== "dualStick") return;
const zone = lookJoystickZone.current;
if (!zone) return;
let manager: nipplejs.JoystickManager | null = null;
let cancelled = false;
import("nipplejs").then((mod) => {
if (cancelled) return;
manager = mod.default.create({
zone,
mode: "static",
position: { right: "70px", bottom: "70px" },
size: 120,
restOpacity: 0.9,
});
manager.on("move", (_event, data) => {
lookJoystickState.current.angle = data.angle.radian;
lookJoystickState.current.force = Math.min(1, data.force);
});
manager.on("end", () => {
lookJoystickState.current.force = 0;
});
});
return () => {
cancelled = true;
manager?.destroy();
};
}, [touchMode, lookJoystickState, lookJoystickZone]);
if (touchMode === "dualStick") {
return (
<>
<div
ref={joystickZone}
className="TouchJoystick TouchJoystick--left"
onContextMenu={(e) => e.preventDefault()}
/>
<div
ref={lookJoystickZone}
className="TouchJoystick TouchJoystick--right"
onContextMenu={(e) => e.preventDefault()}
/>
</>
);
}
return (
<div
ref={joystickZone}
className="TouchJoystick"
onContextMenu={(e) => e.preventDefault()}
/>
);
}
/** Handles touch look and joystick-driven movement. Place inside Canvas. */
export function TouchCameraMovement({
joystickState,
joystickZone,
lookJoystickState,
}: SharedProps) {
const { speedMultiplier } = useControls();
const { speedMultiplier, touchMode } = useControls();
const { camera, gl } = useThree();
// Touch look state
@ -80,8 +150,10 @@ export function TouchCameraMovement({
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
}, [camera]);
// Touch look handling
// Touch-drag look handling (moveLookStick mode)
useEffect(() => {
if (touchMode !== "moveLookStick") return;
const canvas = gl.domElement;
const isTouchOnJoystick = (touch: Touch) => {
@ -149,38 +221,82 @@ export function TouchCameraMovement({
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
canvas.removeEventListener("touchcancel", handleTouchEnd);
lookTouchId.current = null;
};
}, [camera, gl.domElement, joystickZone]);
}, [camera, gl.domElement, joystickZone, touchMode]);
useFrame((_state, delta) => {
const { force, angle } = joystickState.current;
if (force === 0) return;
const speed = BASE_SPEED * speedMultiplier * force;
if (touchMode === "dualStick") {
// Right stick → camera rotation
const look = lookJoystickState.current;
if (look.force > 0) {
const lookX = Math.cos(look.angle);
const lookY = Math.sin(look.angle);
// Decompose joystick angle into forward/sideways components
// nipplejs angle: 0 = right, π/2 = up, π = left, 3π/2 = down
const joyX = Math.cos(angle); // right component
const joyY = Math.sin(angle); // forward component
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -= lookX * look.force * STICK_LOOK_SENSITIVITY * delta;
euler.current.x += lookY * look.force * STICK_LOOK_SENSITIVITY * delta;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
}
// Forward/backward: use full camera direction (including Y) so
// looking up and pushing forward moves upward, like ObserverControls.
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
// Left stick → movement
if (force > 0) {
const speed = BASE_SPEED * speedMultiplier * force;
const joyX = Math.cos(angle);
const joyY = Math.sin(angle);
// Left/right: move along XZ plane
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
sideVec.current
.crossVectors(camera.up, forwardVec.current)
.normalize();
// Combine: joyY pushes forward, joyX pushes right.
// sideVec points left (up × forward), so negate joyX.
moveVec.current
.set(0, 0, 0)
.addScaledVector(forwardVec.current, joyY)
.addScaledVector(sideVec.current, -joyX);
moveVec.current
.set(0, 0, 0)
.addScaledVector(forwardVec.current, joyY)
.addScaledVector(sideVec.current, -joyX);
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
}
}
} else if (touchMode === "moveLookStick") {
if (force > 0) {
// Move forward at half the configured speed.
const speed = BASE_SPEED * speedMultiplier * 0.5;
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
moveVec.current
.copy(forwardVec.current)
.multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
if (force >= SINGLE_STICK_DEADZONE) {
// Outer zone: also control camera look (yaw + pitch).
const lookX = Math.cos(angle);
const lookY = Math.sin(angle);
const lookForce =
(force - SINGLE_STICK_DEADZONE) / (1 - SINGLE_STICK_DEADZONE);
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -=
lookX * lookForce * STICK_LOOK_SENSITIVITY * 0.5 * delta;
euler.current.x +=
lookY * lookForce * STICK_LOOK_SENSITIVITY * 0.5 * delta;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
}
}
}
});