From 42f4f9ae9d56c56c520a52c3aadaacdfb3324c8a Mon Sep 17 00:00:00 2001 From: Brian Beck Date: Wed, 26 Nov 2025 14:37:49 -0800 Subject: [PATCH] left click when locked to cycle camera --- README.md | 5 +- app/page.tsx | 15 +++-- src/components/Camera.tsx | 42 +++++++++++++ src/components/CamerasProvider.tsx | 94 ++++++++++++++++++++++++++++ src/components/InspectorControls.tsx | 5 +- src/components/ObserverControls.tsx | 14 +++-- src/components/renderObject.tsx | 2 + 7 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 src/components/Camera.tsx create mode 100644 src/components/CamerasProvider.tsx diff --git a/README.md b/README.md index f30b33f3..39de6f56 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ Click inside the map preview area to capture the mouse. | Space | Up | | Shift | Down | | Esc | Release mouse | -| △ Scroll/mouse wheel up | Increase speed | -| ▽ Scroll/mouse wheel down | Decrease speed | +| Left click | Next observer camera | +| △ Scroll/mouse wheel up | Increase speed | +| ▽ Scroll/mouse wheel down | Decrease speed | ## Development diff --git a/app/page.tsx b/app/page.tsx index 345c9d70..cabf8e0d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,6 +11,7 @@ import { SettingsProvider } from "@/src/components/SettingsProvider"; import { ObserverCamera } from "@/src/components/ObserverCamera"; import { AudioProvider } from "@/src/components/AudioContext"; 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 // stuff too, e.g. missions, terrains, and more. This client is used for those. @@ -37,12 +38,14 @@ function MapInspector() {
- - - - - - + + + + + + + + diff --git a/src/components/Camera.tsx b/src/components/Camera.tsx new file mode 100644 index 00000000..99a4e4c2 --- /dev/null +++ b/src/components/Camera.tsx @@ -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; +} diff --git a/src/components/CamerasProvider.tsx b/src/components/CamerasProvider.tsx new file mode 100644 index 00000000..bf6113da --- /dev/null +++ b/src/components/CamerasProvider.tsx @@ -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(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>({}); + + 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 ( + + {children} + + ); +} diff --git a/src/components/InspectorControls.tsx b/src/components/InspectorControls.tsx index ee077c7b..5e5a15a2 100644 --- a/src/components/InspectorControls.tsx +++ b/src/components/InspectorControls.tsx @@ -9,8 +9,9 @@ const excludeMissions = new Set([ "SkiFree_Randomizer", ]); -const SOURCE_GROUP_NAMES = { +const sourceGroupNames = { "Classic_maps_v1.vl2": "Classic", + "DynamixFinalPack.vl2": "Official", "missions.vl2": "Official", "S5maps.vl2": "S5", "S8maps.vl2": "S8", @@ -27,7 +28,7 @@ const groupedMissions = getMissionList().reduce( (groupMap, missionName) => { const missionInfo = getMissionInfo(missionName); const source = getSource(missionInfo.resourcePath); - const groupName = SOURCE_GROUP_NAMES[source] ?? null; + const groupName = sourceGroupNames[source] ?? null; const groupMissions = groupMap.get(groupName) ?? []; if (!excludeMissions.has(missionName)) { groupMissions.push({ diff --git a/src/components/ObserverControls.tsx b/src/components/ObserverControls.tsx index f8f0e358..8a74de83 100644 --- a/src/components/ObserverControls.tsx +++ b/src/components/ObserverControls.tsx @@ -4,6 +4,7 @@ import { useFrame, useThree } from "@react-three/fiber"; import { KeyboardControls, useKeyboardControls } from "@react-three/drei"; import { PointerLockControls } from "three-stdlib"; import { useControls } from "./SettingsProvider"; +import { useCameras } from "./CamerasProvider"; enum Controls { forward = "forward", @@ -22,6 +23,7 @@ function CameraMovement() { const { speedMultiplier, setSpeedMultiplier } = useControls(); const [subscribe, getKeys] = useKeyboardControls(); const { camera, gl } = useThree(); + const { nextCamera } = useCameras(); const controlsRef = useRef(null); // Scratch vectors to avoid allocations each frame @@ -35,19 +37,21 @@ function CameraMovement() { controlsRef.current = controls; const handleClick = (e: MouseEvent) => { - // Only lock if clicking directly on the canvas (not on UI elements) - if (e.target === gl.domElement) { + if (controls.isLocked) { + nextCamera(); + } else if (e.target === gl.domElement) { + // Only lock if clicking directly on the canvas (not on UI elements) controls.lock(); } }; - gl.domElement.addEventListener("click", handleClick); + document.addEventListener("click", handleClick); return () => { - gl.domElement.removeEventListener("click", handleClick); + document.removeEventListener("click", handleClick); controls.dispose(); }; - }, [camera, gl]); + }, [camera, gl, nextCamera]); // Handle mousewheel for speed adjustment useEffect(() => { diff --git a/src/components/renderObject.tsx b/src/components/renderObject.tsx index b6071512..dbb0f43a 100644 --- a/src/components/renderObject.tsx +++ b/src/components/renderObject.tsx @@ -11,9 +11,11 @@ import { Item } from "./Item"; import { Turret } from "./Turret"; import { AudioEmitter } from "./AudioEmitter"; import { WayPoint } from "./WayPoint"; +import { Camera } from "./Camera"; const componentMap = { AudioEmitter, + Camera, InteriorInstance, Item, SimGroup,