left click when locked to cycle camera

This commit is contained in:
Brian Beck 2025-11-26 14:37:49 -08:00
parent 106d684d64
commit 42f4f9ae9d
7 changed files with 162 additions and 15 deletions

View file

@ -21,8 +21,9 @@ Click inside the map preview area to capture the mouse.
| <kbd>Space</kbd> | Up | | <kbd>Space</kbd> | Up |
| <kbd>Shift</kbd> | Down | | <kbd>Shift</kbd> | Down |
| <kbd>Esc</kbd> | Release mouse | | <kbd>Esc</kbd> | Release mouse |
| △ Scroll/mouse wheel up | Increase speed | | <small>Left click</small> | Next observer camera |
| ▽ Scroll/mouse wheel down | Decrease speed | | △ <small>Scroll/mouse wheel up</small> | Increase speed |
| ▽ <small>Scroll/mouse wheel down</small> | Decrease speed |
## Development ## Development

View file

@ -11,6 +11,7 @@ import { SettingsProvider } from "@/src/components/SettingsProvider";
import { ObserverCamera } from "@/src/components/ObserverCamera"; import { ObserverCamera } from "@/src/components/ObserverCamera";
import { AudioProvider } from "@/src/components/AudioContext"; import { AudioProvider } from "@/src/components/AudioContext";
import { DebugElements } from "@/src/components/DebugElements"; import { DebugElements } from "@/src/components/DebugElements";
import { CamerasProvider } from "@/src/components/CamerasProvider";
// three.js has its own loaders for textures and models, but we need to load other // 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. // stuff too, e.g. missions, terrains, and more. This client is used for those.
@ -37,12 +38,14 @@ function MapInspector() {
<main> <main>
<SettingsProvider> <SettingsProvider>
<Canvas shadows frameloop="always"> <Canvas shadows frameloop="always">
<AudioProvider> <CamerasProvider>
<ObserverControls /> <AudioProvider>
<Mission key={missionName} name={missionName} /> <Mission key={missionName} name={missionName} />
<ObserverCamera /> <ObserverCamera />
<DebugElements /> <DebugElements />
</AudioProvider> <ObserverControls />
</AudioProvider>
</CamerasProvider>
<EffectComposer> <EffectComposer>
<N8AO intensity={3} aoRadius={3} quality="performance" /> <N8AO intensity={3} aoRadius={3} quality="performance" />
</EffectComposer> </EffectComposer>

42
src/components/Camera.tsx Normal file
View file

@ -0,0 +1,42 @@
import { useEffect, useId, useMemo, useRef } from "react";
import { PerspectiveCamera } from "@react-three/drei";
import { useCameras } from "./CamerasProvider";
import { useSettings } from "./SettingsProvider";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
} from "../mission";
import { Quaternion, Vector3 } from "three";
export function Camera({ object }: { object: ConsoleObject }) {
const { fov } = useSettings();
const { registerCamera, unregisterCamera } = useCameras();
const id = useId();
const dataBlock = getProperty(object, "dataBlock").value;
const [x, y, z] = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
useEffect(() => {
if (dataBlock === "Observer") {
const camera = { id, position: new Vector3(x, y, z), rotation: q };
registerCamera(camera);
return () => {
unregisterCamera(camera);
};
}
}, [id, dataBlock, registerCamera, unregisterCamera, x, y, z, q]);
// Maps can define preset observer camera locations. You should be able to jump
// to an observer camera position and then fly around from that starting point
// But, we wouldn't want the user to take control of the actual camera's
// position, because then if you want to cycle back through them again, the
// "fixed" camera location has moved. There are two approaches for fixing this:
// make Camera render an actual PerspectiveCamera, switch it when cycling,
// but clone a new "flying" camera when the user moves. The other is to not have
// multiple cameras at all, but rather update the one camera with fixed position
// information when cycling. This uses the latter approach.
return null;
}

View file

@ -0,0 +1,94 @@
import { Quaternion, Vector3 } from "three";
import { useThree } from "@react-three/fiber";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
interface CameraEntry {
id: string;
position: Vector3;
rotation: Quaternion;
}
interface CamerasContextValue {
registerCamera: (camera: any) => void;
unregisterCamera: (camera: any) => void;
nextCamera: () => void;
}
const CamerasContext = createContext<CamerasContextValue | null>(null);
export function useCameras() {
const context = useContext(CamerasContext);
if (!context) {
throw new Error("useCameras must be used within CamerasProvider");
}
return context;
}
export function CamerasProvider({ children }: { children: ReactNode }) {
const { camera } = useThree();
const [cameraIndex, setCameraIndex] = useState(0);
const [cameraMap, setCameraMap] = useState<Record<string, CameraEntry>>({});
const registerCamera = useCallback((camera: CameraEntry) => {
setCameraMap((prevCameraMap) => ({
...prevCameraMap,
[camera.id]: camera,
}));
}, []);
const unregisterCamera = useCallback((camera: CameraEntry) => {
setCameraMap((prevCameraMap) => {
const { [camera.id]: removedCamera, ...remainingCameras } = prevCameraMap;
return remainingCameras;
});
}, []);
const nextCamera = useCallback(() => {
setCameraIndex((prev) => {
const cameraCount = Object.keys(cameraMap).length;
if (cameraCount === 0) {
return 0;
}
return (prev + 1) % cameraCount;
});
}, [cameraMap]);
useEffect(() => {
const cameraCount = Object.keys(cameraMap).length;
if (cameraIndex < cameraCount) {
const cameraId = Object.keys(cameraMap)[cameraIndex];
const cameraInfo = cameraMap[cameraId];
camera.position.copy(cameraInfo.position);
// Apply coordinate system correction for Torque3D to Three.js
const correction = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
-Math.PI / 2
);
camera.quaternion.copy(cameraInfo.rotation).multiply(correction);
}
}, [cameraIndex, cameraMap, camera]);
const context: CamerasContextValue = useMemo(
() => ({
registerCamera,
unregisterCamera,
nextCamera,
}),
[registerCamera, unregisterCamera, nextCamera]
);
return (
<CamerasContext.Provider value={context}>
{children}
</CamerasContext.Provider>
);
}

View file

@ -9,8 +9,9 @@ const excludeMissions = new Set([
"SkiFree_Randomizer", "SkiFree_Randomizer",
]); ]);
const SOURCE_GROUP_NAMES = { const sourceGroupNames = {
"Classic_maps_v1.vl2": "Classic", "Classic_maps_v1.vl2": "Classic",
"DynamixFinalPack.vl2": "Official",
"missions.vl2": "Official", "missions.vl2": "Official",
"S5maps.vl2": "S5", "S5maps.vl2": "S5",
"S8maps.vl2": "S8", "S8maps.vl2": "S8",
@ -27,7 +28,7 @@ const groupedMissions = getMissionList().reduce(
(groupMap, missionName) => { (groupMap, missionName) => {
const missionInfo = getMissionInfo(missionName); const missionInfo = getMissionInfo(missionName);
const source = getSource(missionInfo.resourcePath); const source = getSource(missionInfo.resourcePath);
const groupName = SOURCE_GROUP_NAMES[source] ?? null; const groupName = sourceGroupNames[source] ?? null;
const groupMissions = groupMap.get(groupName) ?? []; const groupMissions = groupMap.get(groupName) ?? [];
if (!excludeMissions.has(missionName)) { if (!excludeMissions.has(missionName)) {
groupMissions.push({ groupMissions.push({

View file

@ -4,6 +4,7 @@ import { useFrame, useThree } from "@react-three/fiber";
import { KeyboardControls, useKeyboardControls } from "@react-three/drei"; import { KeyboardControls, useKeyboardControls } from "@react-three/drei";
import { PointerLockControls } from "three-stdlib"; import { PointerLockControls } from "three-stdlib";
import { useControls } from "./SettingsProvider"; import { useControls } from "./SettingsProvider";
import { useCameras } from "./CamerasProvider";
enum Controls { enum Controls {
forward = "forward", forward = "forward",
@ -22,6 +23,7 @@ function CameraMovement() {
const { speedMultiplier, setSpeedMultiplier } = useControls(); const { speedMultiplier, setSpeedMultiplier } = useControls();
const [subscribe, getKeys] = useKeyboardControls<Controls>(); const [subscribe, getKeys] = useKeyboardControls<Controls>();
const { camera, gl } = useThree(); const { camera, gl } = useThree();
const { nextCamera } = useCameras();
const controlsRef = useRef<PointerLockControls | null>(null); const controlsRef = useRef<PointerLockControls | null>(null);
// Scratch vectors to avoid allocations each frame // Scratch vectors to avoid allocations each frame
@ -35,19 +37,21 @@ function CameraMovement() {
controlsRef.current = controls; controlsRef.current = controls;
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
// Only lock if clicking directly on the canvas (not on UI elements) if (controls.isLocked) {
if (e.target === gl.domElement) { nextCamera();
} else if (e.target === gl.domElement) {
// Only lock if clicking directly on the canvas (not on UI elements)
controls.lock(); controls.lock();
} }
}; };
gl.domElement.addEventListener("click", handleClick); document.addEventListener("click", handleClick);
return () => { return () => {
gl.domElement.removeEventListener("click", handleClick); document.removeEventListener("click", handleClick);
controls.dispose(); controls.dispose();
}; };
}, [camera, gl]); }, [camera, gl, nextCamera]);
// Handle mousewheel for speed adjustment // Handle mousewheel for speed adjustment
useEffect(() => { useEffect(() => {

View file

@ -11,9 +11,11 @@ import { Item } from "./Item";
import { Turret } from "./Turret"; import { Turret } from "./Turret";
import { AudioEmitter } from "./AudioEmitter"; import { AudioEmitter } from "./AudioEmitter";
import { WayPoint } from "./WayPoint"; import { WayPoint } from "./WayPoint";
import { Camera } from "./Camera";
const componentMap = { const componentMap = {
AudioEmitter, AudioEmitter,
Camera,
InteriorInstance, InteriorInstance,
Item, Item,
SimGroup, SimGroup,