input and tour improvements, bug fixes

This commit is contained in:
Brian Beck 2026-03-22 21:11:02 -07:00
parent fe90146e1e
commit 90ec7cbae2
110 changed files with 2802 additions and 1286 deletions

View file

@ -0,0 +1,53 @@
import { useRecording } from "./RecordingProvider";
import { useInputMode } from "./InputContext";
import { useCameraTour } from "../state/cameraTourStore";
import { InputBindings } from "./InputBindings";
import {
FREE_FLY_INPUT,
MOVABLE_CAMERA_INPUT,
POINTER_LOCKABLE_INPUT,
MAP_MODE_INPUT,
DEMO_MODE_INPUT,
LIVE_OBSERVER_INPUT,
LIVE_FOLLOW_INPUT,
TOUR_MODE_INPUT,
} from "./inputMap";
/**
* Mounts the appropriate `InputBindings` components based on the current
* app mode. Each binding group has its own lifecycle when a group
* unmounts, its actions are automatically cleaned up from the store.
*/
export function ActiveInputBindings() {
const recording = useRecording();
const inputMode = useInputMode();
const isTourActive = useCameraTour((s) => s.animation !== null);
const isDemo = recording?.source === "demo";
const isLive = recording?.source === "live";
const isMap = !recording;
// Free-fly movement: map mode (no tour) or live free-fly.
const showFreeFly =
(isMap && !isTourActive) || (isLive && inputMode === "fly");
// Camera can be moved by drag/touch in most modes.
const showMovableCamera = !isTourActive;
// Pointer lock: available when not touring.
const showPointerLockable = !isTourActive;
return (
<>
{showFreeFly && <InputBindings map={FREE_FLY_INPUT} />}
{showMovableCamera && <InputBindings map={MOVABLE_CAMERA_INPUT} />}
{showPointerLockable && <InputBindings map={POINTER_LOCKABLE_INPUT} />}
{isMap && !isTourActive && <InputBindings map={MAP_MODE_INPUT} />}
{isDemo && <InputBindings map={DEMO_MODE_INPUT} />}
{isLive && <InputBindings map={LIVE_OBSERVER_INPUT} />}
{isLive && inputMode === "follow" && (
<InputBindings map={LIVE_FOLLOW_INPUT} />
)}
{isTourActive && <InputBindings map={TOUR_MODE_INPUT} />}
</>
);
}

View file

@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FeaturesProvider } from "@/src/components/FeaturesProvider";
import { MapInspector } from "@/src/components/MapInspector";
import { SettingsProvider } from "@/src/components/SettingsProvider";
// Three.js has its own loaders for textures and models, but we need to load other
// stuff too, e.g. missions, terrains, and more. This client is used for those.
const queryClient = new QueryClient();

View file

@ -425,6 +425,7 @@ export const AudioEmitter = memo(function AudioEmitter({
}, [audioEnabled]);
return debugMode ? (
// eslint-disable-next-line react-hooks/refs
<mesh position={emitterPosRef.current}>
<sphereGeometry args={[minDistance, 12, 12]} />
<meshBasicMaterial

View file

@ -1,5 +1,6 @@
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useInputAction } from "./InputControls";
import {
Box3,
Camera,
@ -23,9 +24,9 @@ function easeInOutCubic(t: number): number {
const FALLBACK_ORBIT_RADIUS = 3;
const DEFAULT_ORBIT_HEIGHT = 2;
const MIN_ORBIT_RADIUS = 1.5;
const MIN_ORBIT_RADIUS = 1.8;
/** Extra orbit radius added beyond half the object's height. */
const ORBIT_PAD_VERTICAL = 1.6;
const ORBIT_PAD_VERTICAL = 1.8;
/** Extra orbit radius added beyond half the object's spread (width/length). */
const ORBIT_PAD_HORIZONTAL = 1.2;
const ORBIT_ANGULAR_SPEED = 0.6; // rad/s
@ -337,6 +338,24 @@ export function CameraTourConsumer() {
const scene = useThree((s) => s.scene);
const prevAnimationRef = useRef<TourAnimation | null>(null);
// Click to advance to next stop, or exit if on the last target.
useInputAction("nextStop", () => {
const animation = cameraTourStore.getState().animation;
if (!animation) return;
const isLastTarget =
animation.currentIndex >= animation.targets.length - 1;
if (isLastTarget) {
cameraTourStore.getState().cancel();
} else {
cameraTourStore.getState().advanceTarget();
}
});
// Escape to exit tour.
useInputAction("exitTour", () => {
cameraTourStore.getState().cancel();
});
useFrame((_state, delta) => {
const animation = cameraTourStore.getState().animation;
if (!animation) {

View file

@ -19,7 +19,7 @@
border-radius: 4px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
font-size: 14px;
font-size: 11px;
cursor: pointer;
}
@ -41,12 +41,26 @@
max-width: none;
}
.Speed {
.Speed,
.CameraMode {
flex-shrink: 0;
padding: 2px 4px;
padding: 3px 4px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
font-size: 13px;
}
.Field {
display: flex;
align-items: center;
gap: 8px;
}
.Field label {
text-transform: uppercase;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
font-size: 11px;
}

View file

@ -1,4 +1,6 @@
import { useCallback, useEffect, type ChangeEvent } from "react";
import { useInputAction } from "./InputControls";
// import { useStore } from "zustand";
import {
usePlaybackActions,
useCurrentTime,
@ -6,10 +8,21 @@ import {
useIsPlaying,
useRecording,
useSpeed,
SPEED_OPTIONS,
} from "./RecordingProvider";
// import {
// streamPlaybackStore,
// type DemoCameraMode,
// } from "../state/streamPlaybackStore";
// import { useEngineStoreApi } from "../state/engineStore";
import { GrPauseFill, GrPlayFill } from "react-icons/gr";
import styles from "./DemoPlaybackControls.module.css";
const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
// const CAMERA_MODE_OPTIONS: { value: DemoCameraMode; label: string }[] = [
// { value: "original", label: "Original" },
// { value: "freeFly", label: "Free Fly" },
// { value: "orbitOverride", label: "Orbit Target" },
// ];
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
@ -24,6 +37,8 @@ export function DemoPlaybackControls() {
const duration = useDuration();
const speed = useSpeed();
const { play, pause, seek, setSpeed } = usePlaybackActions();
// const cameraMode = useStore(streamPlaybackStore, (s) => s.cameraMode);
// const engineStore = useEngineStoreApi();
// Spacebar toggles play/pause during demo playback.
useEffect(() => {
@ -51,6 +66,16 @@ export function DemoPlaybackControls() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [recording, isPlaying, play, pause]);
useInputAction("decreasePlaybackSpeed", () => {
const idx = SPEED_OPTIONS.indexOf(speed);
if (idx > 0) setSpeed(SPEED_OPTIONS[idx - 1]);
});
useInputAction("increasePlaybackSpeed", () => {
const idx = SPEED_OPTIONS.indexOf(speed);
if (idx < SPEED_OPTIONS.length - 1) setSpeed(SPEED_OPTIONS[idx + 1]);
});
const handleSeek = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
seek(parseFloat(e.target.value));
@ -65,22 +90,36 @@ export function DemoPlaybackControls() {
[setSpeed],
);
// const handleCameraModeChange = useCallback(
// (e: ChangeEvent<HTMLSelectElement>) => {
// const newMode = e.target.value as DemoCameraMode;
// if (newMode === "orbitOverride") {
// // Seed yaw/pitch from current stream camera to avoid a jump.
// const cam =
// engineStore.getState().playback.streamSnapshot?.camera ?? null;
// streamPlaybackStore.setState({
// cameraMode: newMode,
// orbitOverrideYaw: cam?.yaw ?? 0,
// orbitOverridePitch: cam?.pitch ?? 0,
// });
// } else {
// streamPlaybackStore.setState({ cameraMode: newMode });
// }
// },
// [engineStore],
// );
if (!recording || !Number.isFinite(recording.duration)) return null;
return (
<div
className={styles.Root}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.Root}>
<button
className={styles.PlayPause}
onClick={isPlaying ? pause : play}
aria-label={isPlaying ? "Pause" : "Play"}
autoFocus
>
{isPlaying ? "\u275A\u275A" : "\u25B6"}
{isPlaying ? <GrPauseFill /> : <GrPlayFill />}
</button>
<span className={styles.Time}>
{`${formatTime(currentTime)} / ${formatTime(duration)}`}
@ -94,17 +133,32 @@ export function DemoPlaybackControls() {
value={currentTime}
onChange={handleSeek}
/>
<select
className={styles.Speed}
value={speed}
onChange={handleSpeedChange}
<div className={styles.Field}>
<label htmlFor="playbackSpeed">Speed</label>
<select
id="playbackSpeed"
className={styles.Speed}
value={speed}
onChange={handleSpeedChange}
>
{SPEED_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}x
</option>
))}
</select>
</div>
{/* <select
className={styles.CameraMode}
value={cameraMode}
onChange={handleCameraModeChange}
>
{SPEED_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}x
{CAMERA_MODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</select> */}
</div>
);
}

View file

@ -51,20 +51,24 @@ const EntityLayer = memo(function EntityLayer() {
const currentIds = new Set<string>();
for (const entity of entities) {
currentIds.add(entity.id);
cache.set(entity.id, entity);
cache.set(entity.id, entity); // eslint-disable-line react-hooks/refs
}
// Remove entities no longer in the set
// eslint-disable-next-line react-hooks/refs
for (const id of cache.keys()) {
if (!currentIds.has(id)) {
cache.delete(id);
cache.delete(id); // eslint-disable-line react-hooks/refs
}
}
return (
<>
{[...cache.values()].map((entity) => (
<EntityWrapper key={entity.id} entity={entity} />
))}
{
// eslint-disable-next-line react-hooks/refs
[...cache.values()].map((entity) => (
<EntityWrapper key={entity.id} entity={entity} />
))
}
</>
);
});
@ -115,7 +119,7 @@ function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
: undefined;
return ((flags ?? 0) & 0x2) !== 0;
});
flagRef.current = isFlag;
flagRef.current = isFlag; // eslint-disable-line react-hooks/refs
useFrame(() => {
const flags =

View file

@ -79,7 +79,11 @@ export const FloatingLabel = memo(function FloatingLabel({
<group ref={groupRef}>
{isVisible ? (
<Html position={position} center>
<div ref={labelRef} className={styles.Label} style={{ color }}>
<div
ref={labelRef}
className={styles.Label}
style={{ color, opacity: fadeWithDistance ? 0 : undefined }}
>
{children}
</div>
</Html>

View file

@ -301,7 +301,7 @@ export function FogProvider({
// Initial update
useMemo(() => {
updateFogUniforms(uniformsRef.current, fogState, 0);
updateFogUniforms(uniformsRef.current, fogState, 0); // eslint-disable-line react-hooks/refs
}, [fogState]);
return (

View file

@ -4,7 +4,7 @@ import { useDataSource } from "../state/gameEntityStore";
import { useRecording } from "./RecordingProvider";
import { AudioProvider } from "./AudioContext";
import { CamerasProvider } from "./CamerasProvider";
import { InputProducers } from "./InputHandlers";
import { InputProducer } from "./InputProducer";
import { SceneLighting } from "./SceneLighting";
import { ThreeCanvas } from "./ThreeCanvas";
import { TickProvider } from "./TickProvider";
@ -14,6 +14,7 @@ import { AudioEnabled } from "./AudioEnabled";
import { DebugEnabled } from "./DebugEnabled";
import { InputConsumer } from "./InputConsumer";
import { CameraTourConsumer } from "./CameraTourConsumer";
import { ActiveInputBindings } from "./ActiveInputBindings";
function createLazy(
name: string,
@ -59,7 +60,8 @@ export const GameView = memo(function GameView({
<ThreeCanvas dpr={dpr} onCreated={onCreated}>
<TickProvider>
<CamerasProvider>
<InputProducers />
<ActiveInputBindings />
<InputProducer />
<AudioProvider>
<SceneLighting />
<Suspense>

View file

@ -0,0 +1,466 @@
import { useEffect, useMemo } from "react";
import { useThree } from "@react-three/fiber";
import {
inputControlsStore,
notifySubscribers,
keySetModifiersMatch,
eventModifiersMatch,
parseBinding,
defaultStateForBinding,
defaultDragState,
defaultTouchState,
type InputMapEntry,
type ActionState,
type DragState,
type KeyState,
type ScrollState,
type TouchState,
type ParsedAction,
type Modifier,
} from "./InputControls";
const DRAG_THRESHOLD = 3;
/**
* Parses the input map and attaches event listeners that write to the
* InputControls store. Place inside the r3f Canvas.
* Multiple InputBindings instances can coexist.
*
* Keyboard state is tracked centrally in InputControls (module-level
* keydown/keyup listeners). This component subscribes to key changes
* and derives its action state from them.
*/
export function InputBindings<T extends string = string>({
map,
}: {
map: readonly InputMapEntry<T>[] | InputMapEntry<T>[];
}) {
const store = inputControlsStore;
const canvas = useThree((state) => state.gl.domElement);
const bindings = useMemo(() => {
// Parse the map.
const actions: ParsedAction[] = map.map((entry) => {
const keys = Array.isArray(entry.keys) ? entry.keys : [entry.keys];
return { name: entry.name, bindings: keys.map(parseBinding) };
});
// Build default action state (applied in useEffect, not here).
const initialActions: Record<string, ActionState> = {};
for (const action of actions) {
initialActions[action.name] = defaultStateForBinding(action.bindings[0]);
}
// Build lookup indices.
const keyBindings = new Map<
string,
{
action: ParsedAction;
binding: { type: "key"; code: string; modifiers?: Modifier[] };
}[]
>();
const clickBindings: {
action: ParsedAction;
binding: {
type: "click";
button?: number;
modifiers?: Modifier[];
whenPointerLocked?: boolean;
};
}[] = [];
const dragBindings: {
action: ParsedAction;
binding: { type: "drag"; button?: number; whenPointerLocked?: boolean };
}[] = [];
const pointerLockMoveBindings: { action: ParsedAction }[] = [];
const scrollBindings: { action: ParsedAction }[] = [];
const touchBindings: { action: ParsedAction }[] = [];
for (const action of actions) {
for (const binding of action.bindings) {
switch (binding.type) {
case "key": {
let list = keyBindings.get(binding.code);
if (!list) {
list = [];
keyBindings.set(binding.code, list);
}
list.push({ action, binding });
break;
}
case "click":
clickBindings.push({ action, binding });
break;
case "drag":
dragBindings.push({ action, binding });
break;
case "pointerLockMove":
pointerLockMoveBindings.push({ action });
break;
case "scroll":
scrollBindings.push({ action });
break;
case "touch":
touchBindings.push({ action });
break;
}
}
}
/** Check if a binding's whenPointerLocked constraint is satisfied. */
function pointerLockMatches(whenPointerLocked?: boolean): boolean {
if (whenPointerLocked == null) return true;
return whenPointerLocked === !!document.pointerLockElement;
}
// ── Key action derivation ──
/**
* Derive key action state from the raw pressed-key set.
* Called whenever the global key set changes.
*/
function deriveKeyActions(keys: Set<string>) {
const { actions } = store.getState();
const updates: Record<string, ActionState> = {};
for (const [, entries] of keyBindings) {
for (const { action, binding } of entries) {
const shouldBePressed =
keys.has(binding.code) &&
keySetModifiersMatch(keys, binding.modifiers);
const prev = actions[action.name] as KeyState | undefined;
const wasPressed = prev?.pressed ?? false;
if (shouldBePressed && !wasPressed) {
updates[action.name] = { pressed: true };
notifySubscribers(action.name);
} else if (!shouldBePressed && wasPressed) {
updates[action.name] = { pressed: false };
}
}
}
if (Object.keys(updates).length > 0) {
store.setState((prev) => ({
...prev,
actions: { ...prev.actions, ...updates },
}));
}
}
// ── Mouse handlers ──
let mouseDownButton = -1;
let mouseDownX = 0;
let mouseDownY = 0;
let isDragging = false;
function setAction(name: string, state: ActionState) {
store.setState((prev) => ({
...prev,
actions: { ...prev.actions, [name]: state },
}));
}
function handleMouseDown(e: MouseEvent) {
const isLocked = !!document.pointerLockElement;
// Click bindings: set pressed on mousedown.
for (const { action, binding } of clickBindings) {
if (!pointerLockMatches(binding.whenPointerLocked)) continue;
const button = binding.button ?? 0;
if (e.button !== button) continue;
if (!eventModifiersMatch(e, binding.modifiers)) continue;
setAction(action.name, { pressed: true } satisfies KeyState);
}
// Drag tracking only when not pointer-locked (locked movement
// is handled by pointerLockMove bindings in handleMouseMove).
if (!isLocked) {
mouseDownButton = e.button;
mouseDownX = e.clientX;
mouseDownY = e.clientY;
isDragging = false;
}
}
function handleMouseMove(e: MouseEvent) {
// Pointer lock: accumulate deltas for pointerLockMove bindings.
// Mutate in place and batch into a single setState.
if (document.pointerLockElement) {
if (pointerLockMoveBindings.length > 0) {
const { actions } = store.getState();
const updates: Record<string, ActionState> = {};
for (const { action } of pointerLockMoveBindings) {
const prev = actions[action.name] as DragState;
updates[action.name] = {
...prev,
deltaX: prev.deltaX + e.movementX,
deltaY: prev.deltaY + e.movementY,
};
}
store.setState((prev) => ({
...prev,
actions: { ...prev.actions, ...updates },
}));
}
return;
}
// Non-locked: check drag threshold.
if (mouseDownButton < 0) return;
if (!isDragging) {
const dx = e.clientX - mouseDownX;
const dy = e.clientY - mouseDownY;
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) {
return;
}
isDragging = true;
// Crossed drag threshold — cancel matching click bindings.
for (const { action, binding } of clickBindings) {
if (!pointerLockMatches(binding.whenPointerLocked)) continue;
if ((binding.button ?? 0) !== mouseDownButton) continue;
const prev = store.getState().actions[action.name] as KeyState;
if (prev.pressed) {
setAction(action.name, { pressed: false } satisfies KeyState);
}
}
for (const { action, binding } of dragBindings) {
if (!pointerLockMatches(binding.whenPointerLocked)) continue;
if ((binding.button ?? 0) !== mouseDownButton) continue;
setAction(action.name, {
dragging: true,
deltaX: 0,
deltaY: 0,
startX: mouseDownX,
startY: mouseDownY,
} satisfies DragState);
}
}
// Accumulate drag deltas — batch into a single setState.
const { actions } = store.getState();
const dragUpdates: Record<string, ActionState> = {};
for (const { action, binding } of dragBindings) {
if (!pointerLockMatches(binding.whenPointerLocked)) continue;
if ((binding.button ?? 0) !== mouseDownButton) continue;
const prev = actions[action.name] as DragState;
dragUpdates[action.name] = {
...prev,
deltaX: prev.deltaX + e.movementX,
deltaY: prev.deltaY + e.movementY,
};
}
if (Object.keys(dragUpdates).length > 0) {
store.setState((prev) => ({
...prev,
actions: { ...prev.actions, ...dragUpdates },
}));
}
}
function handleMouseUp(e: MouseEvent) {
const isLocked = !!document.pointerLockElement;
// Click bindings: if still pressed (not cancelled by drag),
// this is a confirmed click — notify subscribers then release.
for (const { action, binding } of clickBindings) {
if (!pointerLockMatches(binding.whenPointerLocked)) continue;
const button = binding.button ?? 0;
if (e.button !== button) continue;
const prev = store.getState().actions[action.name] as KeyState;
if (prev.pressed) {
notifySubscribers(action.name);
setAction(action.name, { pressed: false } satisfies KeyState);
}
}
// Drag bindings: end dragging (only relevant when not locked).
if (!isLocked && e.button === mouseDownButton) {
for (const { action, binding } of dragBindings) {
if (!pointerLockMatches(binding.whenPointerLocked)) continue;
if ((binding.button ?? 0) !== mouseDownButton) continue;
const prev = store.getState().actions[action.name] as DragState;
if (prev.dragging) {
setAction(action.name, defaultDragState());
}
}
mouseDownButton = -1;
isDragging = false;
}
}
function handleWheel(e: WheelEvent) {
for (const { action } of scrollBindings) {
setAction(action.name, {
deltaX: e.deltaX,
deltaY: e.deltaY,
} satisfies ScrollState);
notifySubscribers(action.name);
}
}
// ── Touch handlers ──
let touchId: number | null = null;
let lastTouchX = 0;
let lastTouchY = 0;
function handleTouchStart(e: TouchEvent) {
if (touchId !== null) return;
if (touchBindings.length === 0) return;
const touch = e.changedTouches[0];
if (!touch) return;
touchId = touch.identifier;
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
for (const { action } of touchBindings) {
setAction(action.name, {
touching: true,
dragging: false,
deltaX: 0,
deltaY: 0,
} satisfies TouchState);
}
}
function handleTouchMove(e: TouchEvent) {
if (touchId === null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
if (touch.identifier !== touchId) continue;
const dx = touch.clientX - lastTouchX;
const dy = touch.clientY - lastTouchY;
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
for (const { action } of touchBindings) {
const prev = store.getState().actions[action.name] as TouchState;
setAction(action.name, {
touching: true,
dragging: true,
deltaX: prev.deltaX + dx,
deltaY: prev.deltaY + dy,
} satisfies TouchState);
}
break;
}
}
function handleTouchEnd(e: TouchEvent) {
if (touchId === null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier !== touchId) continue;
touchId = null;
for (const { action } of touchBindings) {
setAction(action.name, defaultTouchState());
}
break;
}
}
const actionNames = actions.map((a) => a.name);
const hasKeyBindings = keyBindings.size > 0;
return {
actionNames,
initialActions,
deriveKeyActions,
hasKeyBindings,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
hasMouseBindings:
clickBindings.length > 0 ||
dragBindings.length > 0 ||
pointerLockMoveBindings.length > 0,
hasScrollBindings: scrollBindings.length > 0,
hasTouchBindings: touchBindings.length > 0,
};
}, [map, store]);
// Initialize action state, subscribe to key changes, and attach
// mouse/touch/scroll listeners.
useEffect(() => {
store.setState((prev) => ({
...prev,
actions: { ...prev.actions, ...bindings.initialActions },
}));
// Subscribe to global key set changes to derive key actions.
let unsubKeys: (() => void) | undefined;
if (bindings.hasKeyBindings) {
// Derive immediately from current key state.
bindings.deriveKeyActions(store.getState().keys);
unsubKeys = store.subscribe(
(state) => state.keys,
(keys) => bindings.deriveKeyActions(keys),
);
}
if (bindings.hasMouseBindings) {
canvas.addEventListener("mousedown", bindings.handleMouseDown);
document.addEventListener("mousemove", bindings.handleMouseMove);
document.addEventListener("mouseup", bindings.handleMouseUp);
}
if (bindings.hasScrollBindings) {
canvas.addEventListener("wheel", bindings.handleWheel, {
passive: true,
});
}
if (bindings.hasTouchBindings) {
canvas.addEventListener("touchstart", bindings.handleTouchStart, {
passive: true,
});
document.addEventListener("touchmove", bindings.handleTouchMove, {
passive: true,
});
document.addEventListener("touchend", bindings.handleTouchEnd, {
passive: true,
});
document.addEventListener("touchcancel", bindings.handleTouchEnd, {
passive: true,
});
}
return () => {
unsubKeys?.();
if (bindings.hasMouseBindings) {
canvas.removeEventListener("mousedown", bindings.handleMouseDown);
document.removeEventListener("mousemove", bindings.handleMouseMove);
document.removeEventListener("mouseup", bindings.handleMouseUp);
}
if (bindings.hasScrollBindings) {
canvas.removeEventListener("wheel", bindings.handleWheel);
}
if (bindings.hasTouchBindings) {
canvas.removeEventListener("touchstart", bindings.handleTouchStart);
document.removeEventListener("touchmove", bindings.handleTouchMove);
document.removeEventListener("touchend", bindings.handleTouchEnd);
document.removeEventListener("touchcancel", bindings.handleTouchEnd);
}
// Remove this instance's actions from the store.
store.setState((prev) => {
const nextActions = { ...prev.actions };
for (const name of bindings.actionNames) {
delete nextActions[name];
}
return { ...prev, actions: nextActions };
});
};
}, [bindings, store, canvas]);
return null;
}

View file

@ -17,7 +17,7 @@ import type { ClientMove } from "../../relay/types";
const log = createLogger("InputConsumer");
const MAX_SPEED = 300;
const MAX_SPEED = 270;
const LOCAL_MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
/**
@ -261,6 +261,15 @@ export function InputConsumer() {
}
}, [liveReady, setMode]);
// Set input mode to "follow" during orbit override so
// MouseAndKeyboardHandler flips drag direction correctly.
useEffect(() => {
if (isLive) return;
return streamPlaybackStore.subscribe((state) => {
setMode(state.cameraMode === "orbitOverride" ? "follow" : "local");
});
}, [isLive, setMode]);
// ── processTick: send moves at the Torque tick rate (32Hz). ──
useTick(() => {
if (!activeAdapterRef.current || gameStatus !== "connected" || !liveReady)
@ -453,7 +462,19 @@ export function InputConsumer() {
} else {
// Local mode: apply input directly to camera.
const spState = streamPlaybackStore.getState();
if (spState.playback && !spState.freeFlyCamera) return;
if (spState.playback) {
if (spState.cameraMode === "freeFly") {
applyLocalCamera(camera, dYaw, dPitch, x, y, z, frameDelta);
} else if (spState.cameraMode === "orbitOverride") {
// Accumulate orbit yaw/pitch for StreamingController to read.
spState.orbitOverrideYaw += dYaw;
spState.orbitOverridePitch = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, spState.orbitOverridePitch + dPitch),
);
}
return;
}
applyLocalCamera(camera, dYaw, dPitch, x, y, z, frameDelta);
return;

View file

@ -0,0 +1,368 @@
import { useEffect, useEffectEvent, useMemo } from "react";
import { createStore } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import { useStoreWithEqualityFn } from "zustand/traditional";
// ── Types ──
export type Modifier = "Ctrl" | "Shift" | "Alt";
export type InputBinding =
| { type: "key"; code: string; modifiers?: Modifier[] }
| {
type: "click";
button?: number;
modifiers?: Modifier[];
whenPointerLocked?: boolean;
}
| { type: "drag"; button?: number; whenPointerLocked?: boolean }
| { type: "pointerLockMove" }
| { type: "scroll" }
| { type: "touch" };
/** String shorthand: `"KeyW"`, `"Shift-KeyA"`, or an InputBinding object. */
export type BindingShorthand = string | InputBinding;
export type InputMapEntry<T extends string = string> = {
name: T;
keys: BindingShorthand | BindingShorthand[];
};
export interface KeyState {
pressed: boolean;
}
export interface DragState {
dragging: boolean;
deltaX: number;
deltaY: number;
startX: number;
startY: number;
}
export interface ScrollState {
deltaX: number;
deltaY: number;
}
export interface TouchState {
touching: boolean;
dragging: boolean;
deltaX: number;
deltaY: number;
}
export type ActionState = KeyState | DragState | ScrollState | TouchState;
/** The full store state: raw keys + derived action state. */
export interface InputStoreState {
/** Physical key codes currently held down. */
keys: Set<string>;
/** Derived action state, keyed by action name. */
actions: Record<string, ActionState>;
}
// ── Modifier parsing ──
const MODIFIER_NAMES = new Set<string>(["Ctrl", "Shift", "Alt"]);
/** All physical key codes that are modifier keys (including Meta for
* key-tracking purposes, even though Meta bindings are not supported). */
const MODIFIER_CODE_SET = new Set([
"MetaLeft",
"MetaRight",
"ControlLeft",
"ControlRight",
"ShiftLeft",
"ShiftRight",
"AltLeft",
"AltRight",
]);
export function parseBinding(shorthand: BindingShorthand): InputBinding {
if (typeof shorthand !== "string") return shorthand;
const parts = shorthand.split("-");
const code = parts.pop()!;
const modifiers: Modifier[] = [];
for (const part of parts) {
if (MODIFIER_NAMES.has(part)) {
modifiers.push(part as Modifier);
}
}
return {
type: "key",
code,
modifiers: modifiers.length > 0 ? modifiers : undefined,
};
}
/** Check if the required modifiers match the pressed key set. */
export function keySetModifiersMatch(
keys: Set<string>,
required?: Modifier[],
): boolean {
const hasCtrl = keys.has("ControlLeft") || keys.has("ControlRight");
const hasShift = keys.has("ShiftLeft") || keys.has("ShiftRight");
const hasAlt = keys.has("AltLeft") || keys.has("AltRight");
return (
hasCtrl === (required?.includes("Ctrl") ?? false) &&
hasShift === (required?.includes("Shift") ?? false) &&
hasAlt === (required?.includes("Alt") ?? false)
);
}
/** Check if the required modifiers match a DOM event's modifier flags. */
export function eventModifiersMatch(
e: KeyboardEvent | MouseEvent,
required?: Modifier[],
): boolean {
const wantCtrl = required?.includes("Ctrl") ?? false;
const wantShift = required?.includes("Shift") ?? false;
const wantAlt = required?.includes("Alt") ?? false;
return (
e.ctrlKey === wantCtrl &&
e.shiftKey === wantShift &&
e.altKey === wantAlt
);
}
// ── Default state factories ──
export function defaultKeyState(): KeyState {
return { pressed: false };
}
export function defaultDragState(): DragState {
return { dragging: false, deltaX: 0, deltaY: 0, startX: 0, startY: 0 };
}
export function defaultScrollState(): ScrollState {
return { deltaX: 0, deltaY: 0 };
}
export function defaultTouchState(): TouchState {
return { touching: false, dragging: false, deltaX: 0, deltaY: 0 };
}
export function defaultStateForBinding(binding: InputBinding): ActionState {
switch (binding.type) {
case "key":
case "click":
return defaultKeyState();
case "drag":
case "pointerLockMove":
return defaultDragState();
case "scroll":
return defaultScrollState();
case "touch":
return defaultTouchState();
}
}
// ── Internal types ──
type ActionCallback = () => void;
export interface ParsedAction {
name: string;
bindings: InputBinding[];
}
// ── Module-level store and subscriber registry ──
export const inputControlsStore = createStore<InputStoreState>()(
subscribeWithSelector(() => ({
keys: new Set<string>(),
actions: {} as Record<string, ActionState>,
})),
);
const actionSubscribers = new Map<string, Set<ActionCallback>>();
export function subscribeAction(
action: string,
callback: ActionCallback,
): () => void {
let set = actionSubscribers.get(action);
if (!set) {
set = new Set();
actionSubscribers.set(action, set);
}
set.add(callback);
return () => {
set!.delete(callback);
if (set!.size === 0) actionSubscribers.delete(action);
};
}
export function notifySubscribers(action: string) {
const set = actionSubscribers.get(action);
if (set) {
for (const cb of set) cb();
}
}
// ── Centralized key tracking ──
// A single pair of keydown/keyup listeners manages the `keys` Set.
// InputBindings instances subscribe to key changes to derive actions,
// instead of each attaching their own keyboard listeners.
// Input types that accept text entry — all keys should be ignored.
const TEXT_INPUT_TYPES = new Set([
"text",
"search",
"url",
"tel",
"email",
"password",
"number",
"date",
"datetime-local",
"month",
"week",
"time",
]);
// Keys that interactive (non-text) elements use natively.
const INTERACTIVE_KEYS = new Set([
"Space",
"Enter",
"NumpadEnter",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
]);
function shouldIgnoreForFocus(e: KeyboardEvent): boolean {
// Tab: capture when pointer is locked (prevent focus shift), otherwise
// let the browser handle focus navigation.
if (e.code === "Tab") {
if (document.pointerLockElement) {
e.preventDefault();
return false;
}
return true;
}
const el = document.activeElement;
if (!el || el === document.body) return false;
const tag = el.tagName;
if ((el as HTMLElement).isContentEditable) return true;
if (tag === "TEXTAREA") return true;
if (tag === "INPUT") {
const type = (el as HTMLInputElement).type.toLowerCase();
if (TEXT_INPUT_TYPES.has(type)) return true;
return INTERACTIVE_KEYS.has(e.code);
}
if (
tag === "BUTTON" ||
tag === "SELECT" ||
tag === "A" ||
tag === "SUMMARY"
) {
return INTERACTIVE_KEYS.has(e.code);
}
return false;
}
function handleGlobalKeyDown(e: KeyboardEvent) {
// Meta (Cmd) is not supported as a modifier — macOS doesn't fire
// reliable keyup events while it's held, causing stale input state.
if (e.metaKey) return;
if (shouldIgnoreForFocus(e)) return;
const { keys: prevKeys } = inputControlsStore.getState();
if (prevKeys.has(e.code)) return; // Already tracked (repeat).
const nextKeys = new Set(prevKeys);
nextKeys.add(e.code);
inputControlsStore.setState((prev) => ({ ...prev, keys: nextKeys }));
}
function handleGlobalKeyUp(e: KeyboardEvent) {
const { keys: prevKeys } = inputControlsStore.getState();
if (!prevKeys.has(e.code)) return;
const nextKeys = new Set(prevKeys);
nextKeys.delete(e.code);
// macOS Cocoa quirk: keyup not fired for keys released while Cmd held.
// Clear non-modifier keys since we can't trust their state.
if (e.code === "MetaLeft" || e.code === "MetaRight") {
for (const code of nextKeys) {
if (!MODIFIER_CODE_SET.has(code)) {
nextKeys.delete(code);
}
}
}
inputControlsStore.setState((prev) => ({ ...prev, keys: nextKeys }));
}
function handleGlobalBlur() {
const { keys } = inputControlsStore.getState();
if (keys.size === 0) return;
inputControlsStore.setState((prev) => ({
...prev,
keys: new Set<string>(),
}));
}
// Attach once at module load.
window.addEventListener("keydown", handleGlobalKeyDown);
window.addEventListener("keyup", handleGlobalKeyUp);
window.addEventListener("blur", handleGlobalBlur);
// ── Public hooks ──
/** Reactive selector for input action state. */
export function useInputControls<T>(
selector: (state: Record<string, ActionState>) => T,
): T {
return useStoreWithEqualityFn(inputControlsStore, (s) => selector(s.actions));
}
/** Imperative access to input state for useFrame callbacks. */
export function useInputState() {
return useMemo(
() =>
[
inputControlsStore.subscribe,
() => inputControlsStore.getState().actions,
] as const,
[],
);
}
/** Zero accumulated deltas for drag, scroll, and touch actions.
* Does NOT reset `dragging` that is managed by binding lifecycle.
* Call after reading deltas in your useFrame. */
export function clearInputDeltas() {
const { actions } = inputControlsStore.getState();
const updates: Record<string, ActionState> = {};
for (const [name, s] of Object.entries(actions)) {
if ("deltaX" in s && (s.deltaX !== 0 || s.deltaY !== 0)) {
updates[name] = { ...s, deltaX: 0, deltaY: 0 };
}
}
if (Object.keys(updates).length > 0) {
inputControlsStore.setState((prev) => ({
...prev,
actions: { ...prev.actions, ...updates },
}));
}
}
/**
* Register a callback for a one-shot action (key press, click, etc.).
* The callback always sees current closure state via useEffectEvent.
*/
export function useInputAction(action: string, callback: () => void) {
const stableCallback = useEffectEvent(callback);
useEffect(() => {
return subscribeAction(action, stableCallback);
}, [action]);
}

View file

@ -6,13 +6,9 @@ import {
useRef,
useState,
} from "react";
import { KeyboardControls } from "@react-three/drei";
import { JoystickProvider } from "./JoystickContext";
import { useTouchDevice } from "./useTouchDevice";
import {
MouseAndKeyboardHandler,
KEYBOARD_CONTROLS,
} from "./MouseAndKeyboardHandler";
import { MouseAndKeyboardHandler } from "./MouseAndKeyboardHandler";
import {
InputContext,
type InputFrame,
@ -36,14 +32,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
return (
<InputContext.Provider value={{ moveQueue, onInput, mode, setMode }}>
<KeyboardControls map={KEYBOARD_CONTROLS}>
<JoystickProvider>{children}</JoystickProvider>
</KeyboardControls>
<JoystickProvider>{children}</JoystickProvider>
</InputContext.Provider>
);
}
export function InputProducers() {
export function InputProducer() {
const isTouch = useTouchDevice();
return (
@ -57,6 +51,3 @@ export function InputProducers() {
</>
);
}
/** @deprecated Use `InputProducers` instead. */
export const InputHandlers = InputProducers;

View file

@ -74,6 +74,8 @@ export const InspectorControls = memo(function InspectorControls({
setAnimationEnabled,
fpsLimit,
setFpsLimit,
showInputOverlay,
setShowInputOverlay,
} = useSettings();
const {
speedMultiplier,
@ -329,6 +331,22 @@ export const InspectorControls = memo(function InspectorControls({
/>
</div>
</div>
<div className={styles.CheckboxField}>
<input
id="showInputOverlayInput"
type="checkbox"
checked={showInputOverlay}
onChange={(event) => {
setShowInputOverlay(event.target.checked);
}}
/>
<label
className={styles.Label}
htmlFor="showInputOverlayInput"
>
Show input overlay
</label>
</div>
</Accordion>
<Accordion value="audio" label="Audio">
<div className={styles.CheckboxField}>

View file

@ -14,9 +14,14 @@
display: flex;
gap: 4px;
flex-direction: column;
justify-content: center;
align-items: stretch;
justify-content: flex-end;
}
/* .Column[data-height="compact"] {
gap: 2px;
} */
.Row {
display: flex;
gap: 4px;
@ -27,29 +32,153 @@
width: 32px;
}
.Sep {
opacity: 0.5;
}
.Key {
min-width: 32px;
height: 32px;
display: flex;
flex: 1 0 0;
align-items: center;
justify-content: center;
padding: 0 8px;
align-items: stretch;
margin: 0 auto;
border-radius: 4px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
}
.Key[data-pressed="true"] {
background: rgba(52, 187, 171, 0.6);
border-color: rgba(35, 253, 220, 0.5);
/* .Column[data-height="compact"] .Key {
font-size: 10px;
font-weight: 500;
height: 26px;
} */
.Key[data-size="auto"] {
flex: 0 0 auto;
}
.Key[data-size="fill"] {
flex: 1 0 auto;
}
.Key[data-disabled="true"] {
opacity: 0.6;
}
.Key[data-pressed="true"]:not([data-disabled="true"]) {
background: rgba(35, 145, 132, 0.6);
border-color: rgba(24, 197, 171, 0.4);
}
.Label {
min-width: 30px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
background: rgba(200, 200, 200, 0.1);
}
.Key[data-pressed="true"]:not([data-disabled="true"]) .Label {
color: rgba(255, 255, 255, 0.7);
}
.Label[data-size="auto"] {
flex: 0 0 auto;
}
.Label[data-size="fill"] {
flex: 1 0 auto;
}
.Label:first-child {
border-right: 1px solid rgba(255, 255, 255, 0.2);
}
.Label:last-child {
border-left: 1px solid rgba(255, 255, 255, 0.2);
}
/* .Column[data-height="compact"] .Label:first-child {
padding-right: 5px;
} */
/* .Column[data-height="compact"] .Label:last-child {
padding-left: 5px;
} */
.Key[data-pressed="true"]:not([data-disabled="true"]) .Label {
border-color: rgba(24, 197, 171, 0.4);
color: rgba(162, 255, 222, 0.8);
}
.MultiInput {
font-family: var(--monospace-font);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.Input {
min-width: 30px;
display: flex;
align-items: center;
justify-content: center;
line-height: 0.8;
padding: 0 8px;
}
/* .Column[data-height="compact"] .Key .Input {
min-width: 26px;
} */
.Input[data-size="auto"],
.MultiInput[data-size="auto"] {
flex: 0 0 auto;
}
.Input[data-size="fill"],
.MultiInput[data-size="fill"] {
flex: 1 0 auto;
}
.Key[data-pressed="true"]:not([data-disabled="true"]) .Input {
color: #fff;
}
.Arrow {
margin-right: 3px;
.MultiInput .Input {
flex: 0 0 auto;
border: 0;
background: transparent;
}
.ColumnLabel {
font-size: 9px;
color: rgb(255, 255, 255, 0.5);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
text-transform: uppercase;
padding: 2px 0;
text-align: center;
}
.PlayPauseIcon {
font-size: 9px;
}
.MouseIcon {
font-size: 19px;
}
/* .Column[data-height="compact"] .Key .MouseIcon {
font-size: 16px;
} */
.Input:has(.MouseIcon) {
padding: 0 4px;
}

View file

@ -1,82 +1,463 @@
import { useKeyboardControls } from "@react-three/drei";
import { type ControlName } from "./MouseAndKeyboardHandler";
import { useRecording } from "./RecordingProvider";
import { useLiveSelector } from "../state/liveConnectionStore";
import {
ScrollState,
useInputControls,
type ActionState,
type DragState,
type KeyState,
} from "./InputControls";
import {
SPEED_OPTIONS,
useIsPlaying,
useRecording,
useSpeed,
} from "./RecordingProvider";
import { useInputMode } from "./InputContext";
import { useCameraTour } from "../state/cameraTourStore";
import {
useDataSource,
useGameEntitiesByRenderType,
} from "../state/gameEntityStore";
import { FaAngleDoubleDown, FaAngleDoubleUp } from "react-icons/fa";
import { PiMouseLeftClickFill, PiMouseScroll } from "react-icons/pi";
import { ReactNode, useEffect, useRef, useState } from "react";
import {
FaArrowDown,
FaArrowLeft,
FaArrowRight,
FaArrowUp,
} from "react-icons/fa6";
import { GrPauseFill, GrPlayFill } from "react-icons/gr";
import { usePointerLocked } from "./usePointerLocked";
import styles from "./KeyboardOverlay.module.css";
import { useControls } from "./SettingsProvider";
import { MdSwipe } from "react-icons/md";
export function KeyboardOverlay() {
const recording = useRecording();
const liveReady = useLiveSelector((s) => s.liveReady);
const forward = useKeyboardControls<ControlName>((s) => s.forward);
const backward = useKeyboardControls<ControlName>((s) => s.backward);
const left = useKeyboardControls<ControlName>((s) => s.left);
const right = useKeyboardControls<ControlName>((s) => s.right);
const up = useKeyboardControls<ControlName>((s) => s.up);
const down = useKeyboardControls<ControlName>((s) => s.down);
const lookUp = useKeyboardControls<ControlName>((s) => s.lookUp);
const lookDown = useKeyboardControls<ControlName>((s) => s.lookDown);
const lookLeft = useKeyboardControls<ControlName>((s) => s.lookLeft);
const lookRight = useKeyboardControls<ControlName>((s) => s.lookRight);
type InputState = Record<string, ActionState>;
type ActionSelector = (state: InputState) => boolean;
// Show when no recording (map browsing) or during live mode once ready.
// Hidden during demo playback and during live map transitions.
if (recording && recording.source !== "live") return null;
if (recording?.source === "live" && !liveReady) return null;
function actionPressed(state: InputState, name: string): boolean {
const s = state[name];
return s != null && "pressed" in s && (s as KeyState).pressed;
}
function Key({
action,
input,
label,
labelPosition = "hidden",
labelSize = "fill",
inputSize = "fill",
size = "fill",
disabled = false,
debounce,
}: {
action: string | ActionSelector;
input: ReactNode;
label: ReactNode;
labelPosition?: "left" | "right" | "hidden";
labelSize?: "auto" | "fill";
inputSize?: "auto" | "fill";
size?: "auto" | "fill";
debounce?: number;
disabled?: boolean;
}) {
// Debounce state: when the raw value goes false within the debounce
// window, the selector keeps returning true (no re-render). A timer
// triggers one final re-render after the window expires.
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [held, setHeld] = useState(false);
const baseSelector =
typeof action === "function"
? action
: (s: InputState) => actionPressed(s, action);
const rawIsPressed = useInputControls(baseSelector);
useEffect(() => {
if (!debounce) return;
if (rawIsPressed) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
setHeld(true);
} else {
timerRef.current = setTimeout(() => {
timerRef.current = undefined;
setHeld(false);
}, debounce);
return () => clearTimeout(timerRef.current);
}
}, [rawIsPressed, debounce]);
const isPressed = debounce ? held : rawIsPressed;
return (
<div className={styles.Root}>
<div
className={styles.Key}
data-pressed={isPressed}
data-size={size}
data-disabled={disabled}
>
{labelPosition === "left" ? (
<span className={styles.Label} data-size={labelSize}>
{label}
</span>
) : null}
{Array.isArray(input) ? (
<div className={styles.MultiInput} data-size={inputSize}>
{input.map((input, i) => (
<span className={styles.Input} key={i}>
{input}
</span>
))}
</div>
) : (
<span className={styles.Input} data-size={inputSize}>
{input}
</span>
)}
{labelPosition === "right" ? (
<span className={styles.Label} data-size={labelSize}>
{label}
</span>
) : null}
</div>
);
}
function PointerLockKey() {
const isPointerLocked = usePointerLocked();
// When pointer lock exits, briefly keep showing the "Unlock mouse" UI
// so the Esc key appears highlighted (the browser consumes the keydown
// so we can't detect it directly).
const [justUnlocked, setJustUnlocked] = useState(false);
const wasLockedRef = useRef(false);
useEffect(() => {
if (wasLockedRef.current && !isPointerLocked) {
setJustUnlocked(true);
const id = setTimeout(() => setJustUnlocked(false), 150);
return () => clearTimeout(id);
}
wasLockedRef.current = isPointerLocked;
}, [isPointerLocked]);
const showLockedUI = isPointerLocked || justUnlocked;
return (
<Key
action={showLockedUI ? () => justUnlocked : "canvasClick"}
label={showLockedUI ? "Unlock mouse" : "Capture mouse"}
input={
showLockedUI ? (
"Esc"
) : (
<PiMouseLeftClickFill className={styles.MouseIcon} />
)
}
labelPosition="right"
inputSize="auto"
/>
);
}
function MoveKeys() {
return (
<>
<div className={styles.Column}>
<div className={styles.Row}>
<div className={styles.Spacer} />
<div className={styles.Key} data-pressed={forward}>
W
</div>
<Key action="moveForward" input="W" label="Forward" />
<div className={styles.Spacer} />
</div>
<div className={styles.Row}>
<div className={styles.Key} data-pressed={left}>
A
</div>
<div className={styles.Key} data-pressed={backward}>
S
</div>
<div className={styles.Key} data-pressed={right}>
D
</div>
<Key action="moveLeft" input="A" label="Strafe left" />
<Key action="moveBackward" input="S" label="Backward" />
<Key action="moveRight" input="D" label="Strafe right" />
</div>
</div>
<div className={styles.Column}>
<div className={styles.Row}>
<div className={styles.Key} data-pressed={up}>
<span className={styles.Arrow}>&uarr;</span> Space
</div>
<Key
action="moveUp"
input="E"
label={<FaAngleDoubleUp />}
labelPosition="left"
labelSize="auto"
/>
</div>
<div className={styles.Row}>
<div className={styles.Key} data-pressed={down}>
<span className={styles.Arrow}>&darr;</span> Shift
</div>
<Key
action="moveDown"
input="Q"
label={<FaAngleDoubleDown />}
labelPosition="left"
labelSize="auto"
/>
</div>
</div>
<div className={styles.Column}>
<div className={styles.Row}>
<div className={styles.Spacer} />
<div className={styles.Key} data-pressed={lookUp}>
&uarr;
</div>
<div className={styles.Spacer} />
</div>
<div className={styles.Row}>
<div className={styles.Key} data-pressed={lookLeft}>
&larr;
</div>
<div className={styles.Key} data-pressed={lookDown}>
&darr;
</div>
<div className={styles.Key} data-pressed={lookRight}>
&rarr;
</div>
</div>
</>
);
}
function LookKeys() {
return (
<div className={styles.Column}>
<div className={styles.Row}>
<div className={styles.Spacer} />
<Key action="lookUp" input={<FaArrowUp />} label="Look up" />
<div className={styles.Spacer} />
</div>
<div className={styles.Row}>
<Key action="lookLeft" input={<FaArrowLeft />} label="Look left" />
<Key action="lookDown" input={<FaArrowDown />} label="Look down" />
<Key action="lookRight" input={<FaArrowRight />} label="Look right" />
</div>
</div>
);
}
function FlySpeedKey() {
const { speedMultiplier } = useControls();
const [speedMultiplierChanged, setSpeedMultiplierChanged] = useState<
boolean | null
>(null);
useEffect(() => {
setSpeedMultiplierChanged((value) => (value == null ? false : true));
const timeoutId = setTimeout(() => {
setSpeedMultiplierChanged(false);
}, 100);
return () => clearTimeout(timeoutId);
}, [speedMultiplier]);
return (
<Key
action={(s) =>
((s.adjustSpeed as ScrollState)?.deltaY ?? 0) !== 0 &&
(speedMultiplierChanged ?? false)
}
debounce={50}
label="Adjust speed"
input={<PiMouseScroll className={styles.MouseIcon} />}
labelPosition="right"
inputSize="auto"
/>
);
}
function RotateCameraKey() {
return (
<Key
action={(s) => (s.dragLook as DragState | undefined)?.dragging ?? false}
input={<MdSwipe className={styles.MouseIcon} />}
label="Rotate camera"
labelPosition="right"
inputSize="auto"
/>
);
}
function SelectCameraKey() {
const dataSource = useDataSource();
const isMapMode = dataSource === "map";
const cameraEntities = useGameEntitiesByRenderType("Camera");
const cameraCount = isMapMode ? cameraEntities.length : 0;
return (
<Key
action={(s) =>
Array.from({ length: cameraCount }, (_, i) =>
actionPressed(s, `camera${i + 1}`),
).some((pressed) => pressed)
}
input={
cameraCount === 1 ? "1" : <>1&thinsp;&ndash;&thinsp;{cameraCount}</>
}
label="Select camera"
labelPosition="right"
/>
);
}
function FreeFlyOverlay() {
const isPointerLocked = usePointerLocked();
const dataSource = useDataSource();
const isMapMode = dataSource === "map";
const cameraEntities = useGameEntitiesByRenderType("Camera");
const cameraCount = isMapMode ? cameraEntities.length : 0;
return (
<>
<MoveKeys />
<LookKeys />
<div className={styles.Column} data-height="compact">
<div className={styles.Row}>
<FlySpeedKey />
</div>
<div className={styles.Row}>
<PointerLockKey />
</div>
</div>
<div className={styles.Column} data-height="compact">
{!isPointerLocked ? (
<div className={styles.Row}>
<RotateCameraKey />
</div>
) : null}
{cameraCount > 0 && (
<div className={styles.Row}>
<SelectCameraKey />
</div>
)}
</div>
</>
);
}
function DemoOverlay() {
const isPlaying = useIsPlaying();
const speed = useSpeed();
const nextSpeedIndex = SPEED_OPTIONS.indexOf(speed) + 1;
const prevSpeedIndex = SPEED_OPTIONS.indexOf(speed) - 1;
const atMaxSpeed = nextSpeedIndex >= SPEED_OPTIONS.length;
const atMinSpeed = prevSpeedIndex < 0;
return (
<>
<div className={styles.Column}>
<div className={styles.Row}>
<Key
action="decreasePlaybackSpeed"
label="Slow down"
input={["<", ","]}
labelPosition="right"
disabled={atMinSpeed}
/>
<Key
action="playPause"
label={
isPlaying ? (
<GrPauseFill className={styles.PlayPauseIcon} />
) : (
<GrPlayFill className={styles.PlayPauseIcon} />
)
}
input="Space"
labelPosition="left"
size="auto"
/>
<Key
action="increasePlaybackSpeed"
input={[">", "."]}
label="Speed up"
labelPosition="left"
disabled={atMaxSpeed}
/>
</div>
</div>
<div className={styles.Column}>
<div className={styles.Row}>
<PointerLockKey />
</div>
</div>
</>
);
}
function TourOverlay() {
return (
<>
<div className={styles.Column}>
<div className={styles.Row}>
<Key
action="nextStop"
label="Skip to next stop"
input={<PiMouseLeftClickFill className={styles.MouseIcon} />}
labelPosition="right"
/>
<Key
action="exitTour"
label="Exit tour"
input="Esc"
labelPosition="right"
/>
</div>
</div>
</>
);
}
function ObserverOverlay() {
const inputMode = useInputMode();
const isPointerLocked = usePointerLocked();
return (
<>
{inputMode === "fly" ? <MoveKeys /> : null}
<LookKeys />
<div className={styles.Column} data-height="compact">
{inputMode === "fly" ? (
<div className={styles.Row}>
<FlySpeedKey />
</div>
) : null}
<div className={styles.Row}>
<PointerLockKey />
</div>
</div>
<div className={styles.Column} data-height="compact">
{!isPointerLocked ? (
<div className={styles.Row}>
<RotateCameraKey />
</div>
) : null}
{inputMode === "follow" && isPointerLocked ? (
<div className={styles.Row}>
<Key
action="nextPlayer"
label="Next player"
input={<PiMouseLeftClickFill className={styles.MouseIcon} />}
labelPosition="right"
inputSize="auto"
/>
</div>
) : null}
<div className={styles.Row}>
<Key
action="toggleObserverMode"
label={inputMode === "follow" ? "Fly mode" : "Follow mode"}
input="Space"
labelPosition="right"
inputSize="auto"
/>
</div>
</div>
</>
);
}
export function KeyboardOverlay() {
const recording = useRecording();
const inputMode = useInputMode();
const isTourActive = useCameraTour((s) => s.animation !== null);
const isDemo = recording?.source === "demo";
const isLive = recording?.source === "live";
const isMap = !recording;
const isLiveObserver =
isLive && (inputMode === "fly" || inputMode === "follow");
const showFreeFly = isMap && !isTourActive;
return (
<div className={styles.Root}>
{showFreeFly && <FreeFlyOverlay />}
{isLiveObserver && <ObserverOverlay />}
{isDemo && <DemoOverlay />}
{isTourActive && <TourOverlay />}
</div>
);
}

View file

@ -104,8 +104,10 @@
opacity: 0.6;
}
.ToggleSidebarButton:active {
.ToggleSidebarButton:active,
.ToggleSidebarButton[data-active="true"] {
opacity: 1;
transform: translateY(1px);
}
.ToggleSidebarButton[data-orientation="top"] {

View file

@ -31,7 +31,7 @@ import {
CurrentMission,
useMissionQueryState,
} from "@/src/components/useQueryParams";
import { InputProvider } from "./InputHandlers";
import { InputProvider } from "./InputProducer";
import { VisualInput } from "./VisualInput";
import { LoadingIndicator } from "./LoadingIndicator";
import { engineStore } from "../state/engineStore";
@ -180,6 +180,18 @@ export function MapInspector() {
}
}, [isTouch, isTourActive, setSidebarOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Backslash" && (e.metaKey || e.ctrlKey)) {
e.stopPropagation();
e.preventDefault();
setSidebarOpen((open) => !open);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [setSidebarOpen]);
const loadingProgress = missionLoadingProgress;
const isLoading = loadingProgress < 1;
@ -220,12 +232,7 @@ export function MapInspector() {
return (
<main className={styles.Frame}>
<RecordingProvider>
<header
className={styles.Toolbar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<header className={styles.Toolbar}>
<button
type="button"
className={styles.ToggleSidebarButton}
@ -286,13 +293,7 @@ export function MapInspector() {
</header>
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
<Activity mode={sidebarOpen ? "visible" : "hidden"}>
<div
className={styles.Sidebar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
data-open={sidebarOpen}
>
<div className={styles.Sidebar} data-open={sidebarOpen}>
<InspectorControls
missionName={missionName}
missionType={missionType}
@ -342,12 +343,7 @@ export function MapInspector() {
)}
</div>
</InputProvider>
<footer
className={styles.PlayerBar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<footer className={styles.PlayerBar}>
{recording?.source === "demo" ? (
<Suspense>
<DemoPlaybackControls />

View file

@ -10,6 +10,33 @@
text-align: center;
}
.TourAllButton {
align-self: flex-start;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
}
@media (hover: hover) {
.TourAllButton:hover {
/* background: rgba(2, 93, 153, 0.4); */
color: #fff;
}
}
.TourAllButton:active {
/* background: rgba(0, 85, 177, 0.5); */
}
.CategoryHeader {
display: flex;
align-items: baseline;

View file

@ -12,6 +12,8 @@ import styles from "./MapTourPanel.module.css";
import { BsPlayFill } from "react-icons/bs";
import { HiMiniArrowLeftEndOnRectangle } from "react-icons/hi2";
const ALL_FEATURES_TOUR = "__all__";
function selectTourState(state: {
animation: {
targets: TourTarget[];
@ -51,6 +53,28 @@ export function MapTourPanel() {
);
const tourState = useCameraTour(selectTourState, tourStateEqual);
const allTargets = useMemo(() => {
// Build a lookup from target → category index for sorting by type.
const categoryIndex = new Map<TourTarget, number>();
for (let i = 0; i < categories.length; i++) {
for (const target of categories[i].targets) {
categoryIndex.set(target, i);
}
}
// Sort by [team, type, name] with "no team" (undefined/0) last.
return categories
.flatMap((c) => c.targets)
.sort((a, b) => {
const aTeam = a.teamId != null && a.teamId > 0 ? a.teamId : Infinity;
const bTeam = b.teamId != null && b.teamId > 0 ? b.teamId : Infinity;
if (aTeam !== bTeam) return aTeam - bTeam;
const aCat = categoryIndex.get(a) ?? 0;
const bCat = categoryIndex.get(b) ?? 0;
if (aCat !== bCat) return aCat - bCat;
return a.label.localeCompare(b.label);
});
}, [categories]);
if (categories.length === 0) {
return (
<div className={styles.Root}>
@ -59,8 +83,37 @@ export function MapTourPanel() {
);
}
const isTouringAll =
tourState !== null && tourState.categoryName === ALL_FEATURES_TOUR;
const handleTourAllClick = () => {
if (isTouringAll) {
cameraTourStore.getState().cancel();
} else {
cameraTourStore.getState().startTour(allTargets, ALL_FEATURES_TOUR);
}
};
return (
<div className={styles.Root}>
<button
type="button"
className={styles.TourAllButton}
data-active={isTouringAll}
onClick={handleTourAllClick}
>
{isTouringAll ? (
<>
<HiMiniArrowLeftEndOnRectangle className={styles.ExitIcon} /> Exit
tour
</>
) : (
<>
<BsPlayFill className={styles.PlayIcon} />{" "}
<span className={styles.ButtonLabel}>Tour all features</span>
</>
)}
</button>
{categories.map((category) => (
<CategoryGroup
key={category.name}
@ -120,8 +173,8 @@ function CategoryGroup({
const isActive =
(isTouringCategory && tourState!.currentIndex === index) ||
(tourState !== null &&
tourState.targets.length === 1 &&
tourState.targets[0].entityId === target.entityId);
tourState.targets[tourState.currentIndex]?.entityId ===
target.entityId);
return (
<button
key={target.entityId}

View file

@ -213,7 +213,8 @@ export function MissionSelect({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
if (e.code === "KeyK" && (e.metaKey || e.ctrlKey)) {
e.stopPropagation();
e.preventDefault();
inputRef.current?.focus();
combobox.show();
@ -283,7 +284,14 @@ export function MissionSelect({
<Activity mode={isOpen ? "visible" : "hidden"}>
<div className={styles.Backdrop} />
</Activity>
<div className={styles.InputWrapper}>
<div
className={styles.InputWrapper}
onKeyDown={(event) => {
if (!event.metaKey) {
event.stopPropagation();
}
}}
>
<Combobox
ref={inputRef}
autoSelect
@ -322,6 +330,11 @@ export function MissionSelect({
fitViewport
autoFocusOnHide={false}
className={styles.Popover}
onKeyDown={(event) => {
if (!event.metaKey) {
event.stopPropagation();
}
}}
>
<ComboboxList className={styles.List}>
{filteredResults.type === "flat"

View file

@ -1,7 +1,5 @@
import { useEffect, useEffectEvent, useRef } from "react";
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import { PointerLockControls } from "three-stdlib";
import {
MAX_SPEED_MULTIPLIER,
MIN_SPEED_MULTIPLIER,
@ -11,99 +9,39 @@ import { useCameras } from "./CamerasProvider";
import { useInputContext } from "./InputContext";
import { useTouchDevice } from "./useTouchDevice";
import { cameraTourStore } from "../state/cameraTourStore";
import {
useInputAction,
useInputState,
clearInputDeltas,
type ActionState,
type DragState,
type KeyState,
type ScrollState,
} from "./InputControls";
export const Controls = {
forward: "forward",
backward: "backward",
left: "left",
right: "right",
up: "up",
down: "down",
lookUp: "lookUp",
lookDown: "lookDown",
lookLeft: "lookLeft",
lookRight: "lookRight",
camera1: "camera1",
camera2: "camera2",
camera3: "camera3",
camera4: "camera4",
camera5: "camera5",
camera6: "camera6",
camera7: "camera7",
camera8: "camera8",
camera9: "camera9",
} as const;
export type ControlName = (typeof Controls)[keyof typeof Controls];
export const KEYBOARD_CONTROLS = [
{ name: Controls.forward, keys: ["KeyW"] },
{ name: Controls.backward, keys: ["KeyS"] },
{ name: Controls.left, keys: ["KeyA"] },
{ name: Controls.right, keys: ["KeyD"] },
{ name: Controls.up, keys: ["Space"] },
{ name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
{ name: Controls.lookUp, keys: ["ArrowUp"] },
{ name: Controls.lookDown, keys: ["ArrowDown"] },
{ name: Controls.lookLeft, keys: ["ArrowLeft"] },
{ name: Controls.lookRight, keys: ["ArrowRight"] },
{ name: Controls.camera1, keys: ["Digit1"] },
{ name: Controls.camera2, keys: ["Digit2"] },
{ name: Controls.camera3, keys: ["Digit3"] },
{ name: Controls.camera4, keys: ["Digit4"] },
{ name: Controls.camera5, keys: ["Digit5"] },
{ name: Controls.camera6, keys: ["Digit6"] },
{ name: Controls.camera7, keys: ["Digit7"] },
{ name: Controls.camera8, keys: ["Digit8"] },
{ name: Controls.camera9, keys: ["Digit9"] },
];
const TOUR_CANCEL_KEYS = new Set([
"KeyW", "KeyA", "KeyS", "KeyD",
"Space", "ShiftLeft", "ShiftRight",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
]);
const MIN_SPEED_ADJUSTMENT = 2;
const MAX_SPEED_ADJUSTMENT = 11;
const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
/** Hardcoded drag sensitivity (not affected by user setting). */
const DRAG_SENSITIVITY = 0.002;
export const ARROW_LOOK_SPEED = 1; // radians/sec
const MIN_SPEED_ADJUSTMENT = 1;
const MAX_SPEED_ADJUSTMENT = 11;
/** Hardcoded drag sensitivity for non-locked drag (not affected by user setting). */
const DRAG_SENSITIVITY = 0.002;
function quantizeSpeed(speedMultiplier: number): number {
// Map [0.01, 1] → [1/16, 1], snapped to the 6-bit grid (multiples of 1/16).
const t =
(speedMultiplier - MIN_SPEED_MULTIPLIER) / (1 - MIN_SPEED_MULTIPLIER);
const steps = Math.round(t * 15); // 0..15 → 16 levels (1/16 to 16/16)
const steps = Math.round(t * 15);
return (steps + 1) / 16;
}
function isPressed(state: Record<string, ActionState>, name: string): boolean {
const s = state[name];
return s != null && "pressed" in s && (s as KeyState).pressed;
}
export function MouseAndKeyboardHandler() {
const isTouch = useTouchDevice();
// Don't let KeyboardControls handle stuff when metaKey is held.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
// Let Cmd/Ctrl+K pass through for search focus.
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
return;
}
if (e.metaKey) {
e.stopImmediatePropagation();
}
};
window.addEventListener("keydown", handleKey, { capture: true });
window.addEventListener("keyup", handleKey, { capture: true });
return () => {
window.removeEventListener("keydown", handleKey, { capture: true });
window.removeEventListener("keyup", handleKey, { capture: true });
};
}, []);
const {
speedMultiplier,
setSpeedMultiplier,
@ -112,269 +50,136 @@ export function MouseAndKeyboardHandler() {
invertDrag,
} = useControls();
const { onInput, mode } = useInputContext();
const [subscribe, getKeys] = useKeyboardControls<ControlName>();
const camera = useThree((state) => state.camera);
const [, getInputState] = useInputState();
const gl = useThree((state) => state.gl);
const { nextCamera, setCameraIndex, cameraCount } = useCameras();
const controlsRef = useRef<PointerLockControls | null>(null);
const getInvertScroll = useEffectEvent(() => invertScroll);
const getInvertDrag = useEffectEvent(() => invertDrag);
const getMode = useEffectEvent(() => mode);
const getMouseSensitivity = useEffectEvent(() => mouseSensitivity);
const getIsTouch = useEffectEvent(() => isTouch);
// Accumulated mouse deltas between frames.
const mouseDeltaYaw = useRef(0);
const mouseDeltaPitch = useRef(0);
const { setCameraIndex, cameraCount } = useCameras();
// Trigger flags set by event handlers, consumed in useFrame.
const triggerFire = useRef(false);
const triggerObserve = useRef(false);
// Setup pointer lock controls
// Exit pointer lock when switching to touch mode.
useEffect(() => {
const controls = new PointerLockControls(camera, gl.domElement);
controlsRef.current = controls;
return () => {
controls.dispose();
};
}, [camera, gl.domElement]);
// Exit pointer lock when switching to touch mode or when a tour starts.
useEffect(() => {
if (isTouch && controlsRef.current?.isLocked) {
controlsRef.current.unlock();
if (isTouch && document.pointerLockElement) {
document.exitPointerLock();
}
}, [isTouch]);
// Exit pointer lock when a tour starts.
useEffect(() => {
return cameraTourStore.subscribe((state) => {
if (state.animation && controlsRef.current?.isLocked) {
controlsRef.current.unlock();
if (state.animation && document.pointerLockElement) {
document.exitPointerLock();
}
});
}, []);
// Mouse handling: accumulate deltas for input frames.
// In local mode, drag-to-look works without pointer lock.
// Pointer lock and click behavior depend on mode.
useEffect(() => {
const canvas = gl.domElement;
let dragging = false;
let didDrag = false;
let startX = 0;
let startY = 0;
// Canvas click: lock pointer (only fires when not already locked).
useInputAction("canvasClick", () => {
if (!isTouch && !cameraTourStore.getState().animation) {
gl.domElement.requestPointerLock();
}
});
const handleMouseDown = (e: MouseEvent) => {
if (controlsRef.current?.isLocked) return;
if (e.target !== canvas) return;
dragging = true;
didDrag = false;
startX = e.clientX;
startY = e.clientY;
};
// Next player (live observer follow mode): fire trigger 0.
useInputAction("nextPlayer", () => {
triggerFire.current = true;
});
const handleMouseMove = (e: MouseEvent) => {
if (controlsRef.current?.isLocked) {
// Pointer is locked: accumulate raw deltas.
const sens = getMouseSensitivity();
mouseDeltaYaw.current += e.movementX * sens;
mouseDeltaPitch.current += e.movementY * sens;
return;
}
// Handle mousewheel for speed adjustment.
useInputAction("adjustSpeed", () => {
const scroll = getInputState().adjustSpeed as ScrollState | undefined;
if (!scroll || scroll.deltaY === 0) return;
if (!dragging) return;
if (
!didDrag &&
Math.abs(e.clientX - startX) < DRAG_THRESHOLD &&
Math.abs(e.clientY - startY) < DRAG_THRESHOLD
) {
return;
}
didDrag = true;
const scrollSign = invertScroll ? -1 : 1;
const direction = (scroll.deltaY > 0 ? -1 : 1) * scrollSign;
const scaledDeltaY = Math.ceil(Math.log2(Math.abs(scroll.deltaY) + 1));
const speedDelta =
Math.max(
MIN_SPEED_ADJUSTMENT,
Math.min(MAX_SPEED_ADJUSTMENT, scaledDeltaY),
) * direction;
// In follow/orbit mode, drag direction is reversed because the camera
// orbits around a target — dragging right should move the camera right
// (decreasing yaw), opposite of fly mode.
const orbitFlip = getMode() === "follow" ? -1 : 1;
const dragSign = (getInvertDrag() ? 1 : -1) * orbitFlip;
mouseDeltaYaw.current += dragSign * e.movementX * DRAG_SENSITIVITY;
mouseDeltaPitch.current += dragSign * e.movementY * DRAG_SENSITIVITY;
};
const handleMouseUp = () => {
dragging = false;
};
const handleClick = (e: MouseEvent) => {
const controls = controlsRef.current;
if (controls?.isLocked) {
if (mode === "follow") {
// In follow mode, click cycles to next player (trigger 0).
triggerFire.current = true;
} else if (mode === "local") {
// In local mode, click while locked cycles preset cameras.
nextCamera();
}
// In fly mode, clicks while locked do nothing special.
} else if (e.target === canvas && !didDrag && !getIsTouch() && !cameraTourStore.getState().animation) {
controls?.lock();
}
};
canvas.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("click", handleClick);
return () => {
canvas.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("click", handleClick);
};
}, [camera, gl.domElement, nextCamera, mode]);
// Handle number keys 1-9 for camera selection (local-only UI action).
useEffect(() => {
const cameraControls = [
Controls.camera1,
Controls.camera2,
Controls.camera3,
Controls.camera4,
Controls.camera5,
Controls.camera6,
Controls.camera7,
Controls.camera8,
Controls.camera9,
];
return subscribe((state) => {
for (let i = 0; i < cameraControls.length; i++) {
if (state[cameraControls[i]] && i < cameraCount) {
setCameraIndex(i);
break;
}
}
setSpeedMultiplier((prev) => {
const newSpeed = Math.round(prev * 100) + speedDelta;
return Math.max(
MIN_SPEED_MULTIPLIER,
Math.min(MAX_SPEED_MULTIPLIER, newSpeed / 100),
);
});
}, [subscribe, setCameraIndex, cameraCount]);
});
// Handle mousewheel for speed adjustment (local setting, stays in KMH).
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const scrollSign = getInvertScroll() ? -1 : 1;
const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign;
// scale deltaY in a way that feels natural for both trackpads (often just
// a deltaY of 1 at a time!) and scroll wheels (can be 100s or more).
const scaledDeltaY = Math.ceil(Math.log2(Math.abs(e.deltaY) + 1));
const delta =
Math.max(
MIN_SPEED_ADJUSTMENT,
Math.min(MAX_SPEED_ADJUSTMENT, scaledDeltaY),
) * direction;
setSpeedMultiplier((prev) => {
const newSpeed = Math.round(prev * 100) + delta;
return Math.max(
MIN_SPEED_MULTIPLIER,
Math.min(MAX_SPEED_MULTIPLIER, newSpeed / 100),
);
});
};
const canvas = gl.domElement;
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => {
canvas.removeEventListener("wheel", handleWheel);
};
}, [gl.domElement, setSpeedMultiplier]);
// Escape or movement keys: cancel active camera tour.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (!cameraTourStore.getState().animation) return;
if (e.code === "Escape" || TOUR_CANCEL_KEYS.has(e.code)) {
cameraTourStore.getState().cancel();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, []);
// Handle number keys 1-9 for camera selection.
const selectCamera = (i: number) => {
if (i < cameraCount) setCameraIndex(i);
};
useInputAction("camera1", () => selectCamera(0));
useInputAction("camera2", () => selectCamera(1));
useInputAction("camera3", () => selectCamera(2));
useInputAction("camera4", () => selectCamera(3));
useInputAction("camera5", () => selectCamera(4));
useInputAction("camera6", () => selectCamera(5));
useInputAction("camera7", () => selectCamera(6));
useInputAction("camera8", () => selectCamera(7));
useInputAction("camera9", () => selectCamera(8));
// 'O' key: toggle observer mode (sets trigger 2).
useEffect(() => {
if (mode === "local") return;
const handleKey = (e: KeyboardEvent) => {
if (e.code !== "KeyO" || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
triggerObserve.current = true;
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [mode]);
useInputAction("toggleObserverMode", () => {
triggerObserve.current = true;
});
// Build and emit InputFrame each render frame.
useFrame((_state, delta) => {
// Suppress all input while a camera tour is active.
if (cameraTourStore.getState().animation) return;
const {
forward,
backward,
left,
right,
up,
down,
lookUp,
lookDown,
lookLeft,
lookRight,
} = getKeys();
const inputState = getInputState();
// ── Look deltas ──
let deltaYaw = 0;
let deltaPitch = 0;
// Pointer-locked mouse movement (raw deltas, user sensitivity).
const locked = inputState.lockedLook as DragState | undefined;
if (locked && (locked.deltaX !== 0 || locked.deltaY !== 0)) {
deltaYaw = locked.deltaX * mouseSensitivity;
deltaPitch = locked.deltaY * mouseSensitivity;
}
// Drag-to-look (unlocked, fixed sensitivity, orbit flip in follow mode).
const drag = inputState.dragLook as DragState | undefined;
if (drag?.dragging && (drag.deltaX !== 0 || drag.deltaY !== 0)) {
const orbitFlip = mode === "follow" ? -1 : 1;
const dragSign = (invertDrag ? 1 : -1) * orbitFlip;
deltaYaw += dragSign * drag.deltaX * DRAG_SENSITIVITY;
deltaPitch += dragSign * drag.deltaY * DRAG_SENSITIVITY;
}
// Arrow keys contribute to look deltas.
let deltaYaw = mouseDeltaYaw.current;
let deltaPitch = mouseDeltaPitch.current;
mouseDeltaYaw.current = 0;
mouseDeltaPitch.current = 0;
if (isPressed(inputState, "lookLeft")) deltaYaw -= ARROW_LOOK_SPEED * delta;
if (isPressed(inputState, "lookRight"))
deltaYaw += ARROW_LOOK_SPEED * delta;
if (isPressed(inputState, "lookUp")) deltaPitch -= ARROW_LOOK_SPEED * delta;
if (isPressed(inputState, "lookDown"))
deltaPitch += ARROW_LOOK_SPEED * delta;
if (lookLeft) deltaYaw -= ARROW_LOOK_SPEED * delta;
if (lookRight) deltaYaw += ARROW_LOOK_SPEED * delta;
if (lookUp) deltaPitch -= ARROW_LOOK_SPEED * delta;
if (lookDown) deltaPitch += ARROW_LOOK_SPEED * delta;
// Movement axes, pre-scaled by speedMultiplier (clamped to [-1, 1]).
// ── Movement axes ──
let x = 0;
let y = 0;
let z = 0;
if (left) x -= 1;
if (right) x += 1;
if (forward) y += 1;
if (backward) y -= 1;
if (up) z += 1;
if (down) z -= 1;
if (isPressed(inputState, "moveLeft")) x -= 1;
if (isPressed(inputState, "moveRight")) x += 1;
if (isPressed(inputState, "moveForward")) y += 1;
if (isPressed(inputState, "moveBackward")) y -= 1;
if (isPressed(inputState, "moveUp")) z += 1;
if (isPressed(inputState, "moveDown")) z -= 1;
const quantizedSpeedMultiplier = quantizeSpeed(speedMultiplier);
x = Math.max(-1, Math.min(1, x * quantizedSpeedMultiplier));
y = Math.max(-1, Math.min(1, y * quantizedSpeedMultiplier));
z = Math.max(-1, Math.min(1, z * quantizedSpeedMultiplier));
// Triggers.
// ── Triggers ──
const triggers = [false, false, false, false, false, false];
if (triggerFire.current) {
triggers[0] = true;
@ -385,6 +190,9 @@ export function MouseAndKeyboardHandler() {
triggerObserve.current = false;
}
// Always clear deltas so stale values don't linger in the store.
clearInputDeltas();
// Only emit if there's actual input.
const hasLook = deltaYaw !== 0 || deltaPitch !== 0;
const hasMove = x !== 0 || y !== 0 || z !== 0;

View file

@ -1,4 +1,6 @@
import { useStore } from "zustand";
import { useEngineSelector } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { DEFAULT_TEAM_NAMES } from "../stringUtils";
import { textureToUrl } from "../loaders";
import type { StreamEntity, TeamScore, WeaponsHudSlot } from "../stream/types";
@ -410,18 +412,24 @@ export function PlayerHUD() {
const hasControlPlayer = useEngineSelector(
(state) => !!state.playback.streamSnapshot?.controlPlayerGhostId,
);
const cameraMode = useStore(streamPlaybackStore, (s) => s.cameraMode);
// In free-fly mode the camera is disconnected from the player, so
// player-specific HUD elements (health, energy, weapons, etc.) are hidden.
const showPlayerElements =
hasControlPlayer && cameraMode !== "freeFly";
return (
<div className={styles.PlayerHUD}>
<ChatWindow />
{hasControlPlayer && (
{showPlayerElements && (
<div className={styles.Bars}>
<HealthBar />
<EnergyBar />
</div>
)}
<Compass />
{hasControlPlayer && (
{showPlayerElements && (
<>
<WeaponHUD />
<PackAndInventoryHUD />

View file

@ -10,7 +10,6 @@ import {
LoopOnce,
LoopRepeat,
Object3D,
Audio as ThreeAudio,
PositionalAudio,
Vector3,
} from "three";
@ -43,10 +42,7 @@ import {
} from "./AudioEmitter";
import type { ResolvedAudioProfile } from "./AudioEmitter";
import { audioToUrl } from "../loaders";
import { createLogger } from "../logger";
import { useSettings } from "./SettingsProvider";
const log = createLogger("PlayerModel");
import { useEngineStoreApi, useEngineSelector } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes";
@ -495,7 +491,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
} catch {
// File not in manifest.
}
}, [audioLoader, entity.dataBlockId]);
}, [audioLoader, engineStore, entity.dataBlockId]);
// Cleanup jet sound on unmount.
useEffect(() => {
@ -740,12 +736,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const jetSound = jetSoundRef.current;
const soundActuallyPlaying = jetSound?.isPlaying ?? false;
if (isJetting && !soundActuallyPlaying) {
if (
audioEnabled &&
audioListener &&
jetBufferRef.current &&
jetProfile
) {
if (audioEnabled && audioListener && jetBufferRef.current && jetProfile) {
let sound = jetSound;
if (!sound) {
sound = new PositionalAudio(audioListener);

View file

@ -2,6 +2,8 @@ import { useCallback, type ReactNode } from "react";
import type { StreamRecording } from "../stream/types";
import { useEngineSelector } from "../state/engineStore";
export const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
export function RecordingProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View file

@ -24,7 +24,7 @@ type StateSetter<T> = Dispatch<SetStateAction<T>>;
export type TouchMode = "dualStick" | "moveLookStick";
type SettingsContext = {
type SettingsContextType = {
fogEnabled: boolean;
setFogEnabled: StateSetter<boolean>;
clearFogEnabledOverride: () => void;
@ -44,16 +44,18 @@ type SettingsContext = {
setSidebarOpen: StateSetter<boolean>;
fpsLimit: number | null;
setFpsLimit: StateSetter<number | null>;
showInputOverlay: boolean;
setShowInputOverlay: StateSetter<boolean>;
};
type DebugContext = {
type DebugContextType = {
debugMode: boolean;
setDebugMode: StateSetter<boolean>;
renderOnDemand: boolean;
setRenderOnDemand: StateSetter<boolean>;
};
type ControlsContext = {
type ControlsContextType = {
speedMultiplier: number;
setSpeedMultiplier: StateSetter<number>;
mouseSensitivity: number;
@ -68,9 +70,9 @@ type ControlsContext = {
setInvertJoystick: StateSetter<boolean>;
};
const SettingsContext = createContext<SettingsContext | null>(null);
const DebugContext = createContext<DebugContext | null>(null);
const ControlsContext = createContext<ControlsContext | null>(null);
const SettingsContext = createContext<SettingsContextType | null>(null);
const DebugContext = createContext<DebugContextType | null>(null);
const ControlsContext = createContext<ControlsContextType | null>(null);
type PersistedSettings = {
fogEnabled?: boolean;
@ -89,6 +91,7 @@ type PersistedSettings = {
invertJoystick?: boolean;
sidebarOpen?: boolean;
fpsLimit?: number | null;
showInputOverlay?: boolean;
};
export function useSettings() {
@ -140,6 +143,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [invertJoystick, setInvertJoystick] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [fpsLimit, setFpsLimit] = useState<number | null>(null);
const [showInputOverlay, setShowInputOverlay] = useState(true);
const [renderOnDemand, setRenderOnDemand] = useState(false);
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
@ -155,7 +159,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
[clearFogEnabledOverride],
);
const settingsContext: SettingsContext = useMemo(
const settingsContext: SettingsContextType = useMemo(
() => ({
fogEnabled: fogEnabledOverride ?? fogEnabled,
setFogEnabled: setFogEnabledWithoutOverride,
@ -176,6 +180,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSidebarOpen,
fpsLimit,
setFpsLimit,
showInputOverlay,
setShowInputOverlay,
}),
[
fogEnabled,
@ -190,10 +196,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
audioVolume,
sidebarOpen,
fpsLimit,
showInputOverlay,
],
);
const debugContext: DebugContext = useMemo(
const debugContext: DebugContextType = useMemo(
() => ({
debugMode,
setDebugMode,
@ -203,7 +210,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
[debugMode, setDebugMode, renderOnDemand],
);
const controlsContext: ControlsContext = useMemo(
const controlsContext: ControlsContextType = useMemo(
() => ({
speedMultiplier,
setSpeedMultiplier,
@ -239,7 +246,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
let savedSettings: PersistedSettings = {};
try {
savedSettings = JSON.parse(localStorage.getItem("settings") ?? "{}") || {};
savedSettings =
JSON.parse(localStorage.getItem("settings") ?? "{}") || {};
} catch (err) {
// Ignore.
}
@ -295,9 +303,15 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
if (savedSettings.invertJoystick != null) {
setInvertJoystick(savedSettings.invertJoystick);
}
if (savedSettings.fpsLimit === null || Number.isInteger(savedSettings.fpsLimit)) {
if (
savedSettings.fpsLimit === null ||
Number.isInteger(savedSettings.fpsLimit)
) {
setFpsLimit(savedSettings.fpsLimit!);
}
if (savedSettings.showInputOverlay != null) {
setShowInputOverlay(savedSettings.showInputOverlay);
}
if (savedSettings.sidebarOpen != null) {
// Don't restore on touch devices!
if (!isTouch) {
@ -334,6 +348,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
invertJoystick,
sidebarOpen,
fpsLimit,
showInputOverlay,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
@ -364,6 +379,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
invertJoystick,
sidebarOpen,
fpsLimit,
showInputOverlay,
]);
return (

View file

@ -155,7 +155,7 @@ function SkyBoxTexture({
/>
</bufferGeometry>
<shaderMaterial
uniforms={uniformsRef.current}
uniforms={uniformsRef.current} // eslint-disable-line react-hooks/refs
vertexShader={`
varying vec2 vUv;
@ -400,7 +400,7 @@ function SolidColorSky({
/>
</bufferGeometry>
<shaderMaterial
uniforms={uniformsRef.current}
uniforms={uniformsRef.current} // eslint-disable-line react-hooks/refs
vertexShader={`
varying vec2 vUv;

View file

@ -418,21 +418,24 @@ export function StreamingController({
? renderPrev.camera
: null;
// When freeFlyCamera is active, skip stream camera positioning so
// ObserverControls drives the camera instead.
const freeFly = streamPlaybackStore.getState().freeFlyCamera;
// Camera mode override for demo playback. "freeFly" lets
// ObserverControls drive the camera; "orbitOverride" uses
// user-controlled yaw/pitch for orbit instead of stream data.
const cameraMode = streamPlaybackStore.getState().cameraMode;
// In live mode, InputConsumer owns camera position and rotation
// (moves are applied locally, matching how the real Tribes 2 client
// handles its control Camera). StreamingController still handles
// entity interpolation, FOV, and orbit target positioning.
const isLive = recording.source === "live";
if (currentCamera && !freeFly) {
if (currentCamera && cameraMode !== "freeFly") {
// In live mode, InputConsumer owns both camera position and rotation
// (client-side prediction with server reconciliation + interpolateTick,
// matching Tribes 2's Camera behavior). StreamingController only
// handles entity interpolation, FOV, and orbit target positioning.
if (!isLive) {
// In orbitOverride mode, skip stream position/rotation — the orbit
// block below will position the camera using user-controlled yaw/pitch.
if (!isLive && cameraMode !== "orbitOverride") {
if (previousCamera) {
const px = previousCamera.position[0];
const py = previousCamera.position[1];
@ -557,10 +560,16 @@ export function StreamingController({
const mode = currentCamera?.mode;
// In live mode, InputConsumer handles orbit positioning from local rotation
// so the orbit responds at frame rate. Skip here to avoid fighting.
if (
!freeFly &&
// In orbitOverride mode with a valid orbit target, use user-controlled
// yaw/pitch instead of stream data.
const orbitOverride =
cameraMode === "orbitOverride" &&
!isLive &&
mode === "third-person" &&
currentCamera?.orbitTargetId != null;
if (
cameraMode !== "freeFly" &&
!isLive &&
(mode === "third-person" || orbitOverride) &&
root &&
currentCamera?.orbitTargetId
) {
@ -577,7 +586,16 @@ export function StreamingController({
}
let hasDirection = false;
if (currentCamera.orbitDirection) {
if (orbitOverride) {
// User-controlled orbit: use yaw/pitch from store.
const spState = streamPlaybackStore.getState();
const sx = Math.sin(spState.orbitOverridePitch);
const cx = Math.cos(spState.orbitOverridePitch);
const sz = Math.sin(spState.orbitOverrideYaw);
const cz = Math.cos(spState.orbitOverrideYaw);
_orbitDir.set(-cz * cx, -sx, -sz * cx);
hasDirection = _orbitDir.lengthSq() > 1e-8;
} else if (currentCamera.orbitDirection) {
// Use explicit pullback direction (e.g. from full vehicle quaternion
// including roll) when available.
_orbitDir.set(
@ -618,7 +636,7 @@ export function StreamingController({
}
if (
!freeFly &&
cameraMode === "original" &&
mode === "first-person" &&
root &&
currentCamera?.controlEntityId

View file

@ -1,8 +1,8 @@
import { useEffect, useEffectEvent, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useFrame } from "@react-three/fiber";
import { useControls } from "./SettingsProvider";
import { useJoystick } from "./JoystickContext";
import { useOnInput } from "./InputContext";
import { useInputState, type TouchState } from "./InputControls";
const LOOK_SENSITIVITY = 0.004;
const STICK_LOOK_SENSITIVITY = 2.5;
@ -19,83 +19,27 @@ export type JoystickState = {
export function TouchHandler() {
const { speedMultiplier, touchMode, invertDrag, invertJoystick } =
useControls();
const gl = useThree((state) => state.gl);
const { moveState, lookState } = useJoystick();
const onInput = useOnInput();
// Touch look state
const lookTouchId = useRef<number | null>(null);
const lastTouchPos = useRef({ x: 0, y: 0 });
const getInvertDrag = useEffectEvent(() => invertDrag);
// Accumulated touch-drag deltas between frames.
const touchDeltaYaw = useRef(0);
const touchDeltaPitch = useRef(0);
// Touch-drag look handling (moveLookStick mode)
useEffect(() => {
if (touchMode !== "moveLookStick") return;
const canvas = gl.domElement;
const handleTouchStart = (e: TouchEvent) => {
if (lookTouchId.current !== null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
lookTouchId.current = touch.identifier;
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
break;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (lookTouchId.current === null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
if (touch.identifier === lookTouchId.current) {
const dx = touch.clientX - lastTouchPos.current.x;
const dy = touch.clientY - lastTouchPos.current.y;
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
const dragSign = getInvertDrag() ? 1 : -1;
touchDeltaYaw.current += dragSign * dx * LOOK_SENSITIVITY;
touchDeltaPitch.current += dragSign * dy * LOOK_SENSITIVITY;
break;
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === lookTouchId.current) {
lookTouchId.current = null;
break;
}
}
};
canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
canvas.addEventListener("touchmove", handleTouchMove, { passive: true });
canvas.addEventListener("touchend", handleTouchEnd, { passive: true });
canvas.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
canvas.removeEventListener("touchstart", handleTouchStart);
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
canvas.removeEventListener("touchcancel", handleTouchEnd);
lookTouchId.current = null;
};
}, [gl.domElement, touchMode]);
const [, getInputState] = useInputState();
useFrame((_state, delta) => {
const { force: moveForce, angle: moveAngle } = moveState.current;
const { force: lookForce, angle: lookAngle } = lookState.current;
let deltaYaw = touchDeltaYaw.current;
let deltaPitch = touchDeltaPitch.current;
touchDeltaYaw.current = 0;
touchDeltaPitch.current = 0;
// Read touch deltas from InputControls store.
const inputState = getInputState();
const touch = inputState.touchLook as TouchState | undefined;
const dragSign = invertDrag ? 1 : -1;
let deltaYaw = 0;
let deltaPitch = 0;
// Touch-drag look (moveLookStick mode only — dualStick uses joystick).
if (touchMode === "moveLookStick" && touch && touch.dragging) {
deltaYaw = dragSign * touch.deltaX * LOOK_SENSITIVITY;
deltaPitch = dragSign * touch.deltaY * LOOK_SENSITIVITY;
}
let x = 0;
let y = 0;

View file

@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import { useTouchDevice } from "./useTouchDevice";
import { useCameraTour } from "../state/cameraTourStore";
import { useSettings } from "./SettingsProvider";
const TouchJoystick = lazy(() =>
import("@/src/components/TouchJoystick").then((mod) => ({
@ -17,13 +18,12 @@ const KeyboardOverlay = lazy(() =>
export function VisualInput() {
const isTouch = useTouchDevice();
const isTourActive = useCameraTour((s) => s.animation !== null);
if (isTourActive) return null;
const { showInputOverlay } = useSettings();
return (
<Suspense>
{isTouch ? <TouchJoystick /> : null}
{isTouch === false ? (
{isTouch && !isTourActive ? <TouchJoystick /> : null}
{isTouch === false && showInputOverlay ? (
// isTouch can be `null` before we know for sure; make sure this doesn't
// render until it's definitively false
<KeyboardOverlay />

View file

@ -0,0 +1,76 @@
import type { InputMapEntry } from "./InputControls";
/** WASD movement + speed adjust. Active in free-fly modes only. */
export const FREE_FLY_INPUT = [
{ name: "moveForward", keys: ["KeyW"] },
{ name: "moveBackward", keys: ["KeyS"] },
{ name: "moveLeft", keys: ["KeyA"] },
{ name: "moveRight", keys: ["KeyD"] },
{ name: "moveUp", keys: ["KeyE"] },
{ name: "moveDown", keys: ["KeyQ"] },
{ name: "adjustSpeed", keys: [{ type: "scroll" }] },
] as const satisfies readonly InputMapEntry[];
/** Arrow keys, drag, touch, and pointer-locked look. Active in any mode
* with camera control (free-fly, orbit/follow, etc.) NOT during tours. */
export const MOVABLE_CAMERA_INPUT = [
{ name: "lookUp", keys: ["ArrowUp"] },
{ name: "lookDown", keys: ["ArrowDown"] },
{ name: "lookLeft", keys: ["ArrowLeft"] },
{ name: "lookRight", keys: ["ArrowRight"] },
{ name: "dragLook", keys: [{ type: "drag", button: 0 }] },
{ name: "lockedLook", keys: [{ type: "pointerLockMove" }] },
{ name: "touchLook", keys: [{ type: "touch" }] },
] as const satisfies readonly InputMapEntry[];
export const POINTER_LOCKABLE_INPUT = [
{
name: "canvasClick",
keys: [{ type: "click", button: 0, whenPointerLocked: false }],
},
] as const satisfies readonly InputMapEntry[];
export const MAP_MODE_INPUT = [
{ name: "camera1", keys: ["Digit1"] },
{ name: "camera2", keys: ["Digit2"] },
{ name: "camera3", keys: ["Digit3"] },
{ name: "camera4", keys: ["Digit4"] },
{ name: "camera5", keys: ["Digit5"] },
{ name: "camera6", keys: ["Digit6"] },
{ name: "camera7", keys: ["Digit7"] },
{ name: "camera8", keys: ["Digit8"] },
{ name: "camera9", keys: ["Digit9"] },
] as const satisfies readonly InputMapEntry[];
export const DEMO_MODE_INPUT = [
{ name: "playPause", keys: ["Space"] },
{ name: "decreasePlaybackSpeed", keys: ["Comma", "Shift-Comma"] },
{ name: "increasePlaybackSpeed", keys: ["Period", "Shift-Period"] },
] as const satisfies readonly InputMapEntry[];
export const LIVE_OBSERVER_INPUT = [
{ name: "toggleObserverMode", keys: ["Space"] },
] as const satisfies readonly InputMapEntry[];
export const LIVE_FOLLOW_INPUT = [
{
name: "nextPlayer",
keys: [{ type: "click", button: 0, whenPointerLocked: true }],
},
] as const satisfies readonly InputMapEntry[];
export const TOUR_MODE_INPUT = [
{ name: "nextStop", keys: [{ type: "click", button: 0 }] },
{ name: "exitTour", keys: ["Escape"] },
] as const satisfies readonly InputMapEntry[];
/** Union of all action names across all input maps. */
export type ActionName =
| (typeof FREE_FLY_INPUT)[number]["name"]
| (typeof MOVABLE_CAMERA_INPUT)[number]["name"]
| (typeof POINTER_LOCKABLE_INPUT)[number]["name"]
| (typeof MAP_MODE_INPUT)[number]["name"]
| (typeof DEMO_MODE_INPUT)[number]["name"]
| (typeof LIVE_OBSERVER_INPUT)[number]["name"]
| (typeof LIVE_FOLLOW_INPUT)[number]["name"]
| (typeof TOUR_MODE_INPUT)[number]["name"];

View file

@ -0,0 +1,15 @@
import { useSyncExternalStore } from "react";
function subscribe(callback: () => void): () => void {
document.addEventListener("pointerlockchange", callback);
return () => document.removeEventListener("pointerlockchange", callback);
}
function getSnapshot(): boolean {
return document.pointerLockElement !== null;
}
/** Whether pointer lock is currently active on any element. */
export function usePointerLocked(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -1,3 +1,9 @@
:root {
--monospace-font:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
monospace;
}
html {
box-sizing: border-box;
margin: 0;

View file

@ -3,6 +3,8 @@ import type { Group } from "three";
import type { StreamingPlayback } from "../stream/types";
import type { GameEntity } from "./gameEntityTypes";
export type DemoCameraMode = "original" | "freeFly" | "orbitOverride";
/**
* Store for mutable streaming playback state that needs to be shared between
* the playback controller (writer) and entity rendering components (readers).
@ -18,11 +20,12 @@ export interface StreamPlaybackState {
playback: StreamingPlayback | null;
/** The Three.js group node containing all entity children. */
root: Group | null;
/**
* When true, ObserverControls drives the camera instead of the stream.
* Toggled by 'O' key during live observation.
*/
freeFlyCamera: boolean;
/** Camera mode override for demo playback. */
cameraMode: DemoCameraMode;
/** User-controlled orbit yaw (radians), used when cameraMode is "orbitOverride". */
orbitOverrideYaw: number;
/** User-controlled orbit pitch (radians), used when cameraMode is "orbitOverride". */
orbitOverridePitch: number;
/** Live entity map, updated every frame. Components read from this in
* useFrame to get the latest render fields (threads, weapons, etc.)
* without triggering React re-renders. */
@ -33,7 +36,9 @@ export const streamPlaybackStore = createStore<StreamPlaybackState>()(() => ({
time: 0,
playback: null,
root: null,
freeFlyCamera: false,
cameraMode: "original",
orbitOverrideYaw: 0,
orbitOverridePitch: 0,
entities: new Map(),
}));
@ -42,7 +47,9 @@ export function resetStreamPlayback(): void {
streamPlaybackStore.setState({
time: 0,
playback: null,
freeFlyCamera: false,
cameraMode: "original",
orbitOverrideYaw: 0,
orbitOverridePitch: 0,
});
// root is managed by the React ref callback in EntityScene — don't clear it
}

View file

@ -1149,7 +1149,7 @@ export abstract class StreamEngine implements StreamingPlayback {
log.debug(
"Item %s (%s): atRest=false pos=%s vel=%s",
entity.id,
entity.shapeName ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
entity.shapeHint ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
data.position ? `${(data.position as Vec3).x.toFixed(1)},${(data.position as Vec3).y.toFixed(1)},${(data.position as Vec3).z.toFixed(1)}` : "none",
`${vel.x.toFixed(1)},${vel.y.toFixed(1)},${vel.z.toFixed(1)}`,
);
@ -1157,7 +1157,7 @@ export abstract class StreamEngine implements StreamingPlayback {
log.debug(
"Item %s (%s): atRest=true pos=%s",
entity.id,
entity.shapeName ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
entity.shapeHint ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
entity.position ? `${entity.position[0].toFixed(1)},${entity.position[1].toFixed(1)},${entity.position[2].toFixed(1)}` : "none",
);
entity.itemPhysics = undefined;