mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-08 13:14:32 +00:00
input and tour improvements, bug fixes
This commit is contained in:
parent
fe90146e1e
commit
90ec7cbae2
110 changed files with 2802 additions and 1286 deletions
53
src/components/ActiveInputBindings.tsx
Normal file
53
src/components/ActiveInputBindings.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
466
src/components/InputBindings.tsx
Normal file
466
src/components/InputBindings.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
368
src/components/InputControls.tsx
Normal file
368
src/components/InputControls.tsx
Normal 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]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>↑</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}>↓</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}>
|
||||
↑
|
||||
</div>
|
||||
<div className={styles.Spacer} />
|
||||
</div>
|
||||
<div className={styles.Row}>
|
||||
<div className={styles.Key} data-pressed={lookLeft}>
|
||||
←
|
||||
</div>
|
||||
<div className={styles.Key} data-pressed={lookDown}>
|
||||
↓
|
||||
</div>
|
||||
<div className={styles.Key} data-pressed={lookRight}>
|
||||
→
|
||||
</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 – {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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,8 +104,10 @@
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ToggleSidebarButton:active {
|
||||
.ToggleSidebarButton:active,
|
||||
.ToggleSidebarButton[data-active="true"] {
|
||||
opacity: 1;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.ToggleSidebarButton[data-orientation="top"] {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
76
src/components/inputMap.ts
Normal file
76
src/components/inputMap.ts
Normal 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"];
|
||||
15
src/components/usePointerLocked.ts
Normal file
15
src/components/usePointerLocked.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue