From e0a1ac781f217b699cbb823f9fd3e4906810d0f3 Mon Sep 17 00:00:00 2001 From: foxtacles Date: Sat, 7 Mar 2026 21:23:57 -0800 Subject: [PATCH] Add free camera controls (#10) * Add free camera orbit controls to multiplayer third-person camera Replace the fixed camera offset with dynamic orbit parameters (yaw, pitch, distance) computed from spherical coordinates. Mouse wheel controls zoom, right-click drag controls orbit, and two-finger touch gestures support pinch-zoom and orbit on touchscreens. The controls are always active when third-person mode is enabled. A single SDL event forwarding hook is added to the main event loop; all other changes are contained within the multiplayer extension. https://claude.ai/code/session_013FyPCrJSaHxiJwdfGBVnYP * Fix linker error and refactor orbit camera controls Move HandleSDLEvent extension call from ISLE (no EXTENSIONS define) into LegoInputManager::UpdateLastInputMethod in LEGO1 to fix unresolved symbol errors. DRY up orbit camera code with ClampPitch/ClampDistance/ ResetOrbitState/ApplyOrbitCamera helpers. Simplify direction vector computation. Fix mouse orbit yaw direction. Co-Authored-By: Claude Opus 4.6 * Fixes --------- Co-authored-by: Claude --- .../legoomni/src/input/legoinputmanager.cpp | 2 + extensions/include/extensions/multiplayer.h | 6 + .../multiplayer/thirdpersoncamera.h | 34 ++++ extensions/src/multiplayer.cpp | 7 + .../src/multiplayer/thirdpersoncamera.cpp | 182 +++++++++++++++++- extensions/src/multiplayer/worldstatesync.cpp | 1 + tools/ncc/skip.yml | 4 +- 7 files changed, 228 insertions(+), 8 deletions(-) diff --git a/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp b/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp index 39fd543e..ba49c6b4 100644 --- a/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp +++ b/LEGO1/lego/legoomni/src/input/legoinputmanager.cpp @@ -805,6 +805,8 @@ void LegoInputManager::InitializeHaptics() void LegoInputManager::UpdateLastInputMethod(SDL_Event* p_event) { + Extension::Call(HandleSDLEvent, p_event); + switch (p_event->type) { case SDL_EVENT_KEY_DOWN: case SDL_EVENT_KEY_UP: diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h index c860b0e2..860b9d68 100644 --- a/extensions/include/extensions/multiplayer.h +++ b/extensions/include/extensions/multiplayer.h @@ -3,6 +3,7 @@ #include "extensions/extensions.h" #include "mxtypes.h" +#include #include #include @@ -63,6 +64,9 @@ class MultiplayerExt { // Returns true if the multiplayer connection was rejected (e.g. room full). static MxBool CheckRejected(); + // Forwards SDL events to the third-person camera for orbit controls. + static void HandleSDLEvent(SDL_Event* p_event); + static void SetNetworkManager(Multiplayer::NetworkManager* p_networkManager); static Multiplayer::NetworkManager* GetNetworkManager(); @@ -88,6 +92,7 @@ constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter; constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad; constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded; constexpr auto CheckRejected = &MultiplayerExt::CheckRejected; +constexpr auto HandleSDLEvent = &MultiplayerExt::HandleSDLEvent; #else constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr; constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; @@ -102,6 +107,7 @@ constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullp constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr; constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr; constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr; +constexpr decltype(&MultiplayerExt::HandleSDLEvent) HandleSDLEvent = nullptr; #endif }; // namespace Extensions diff --git a/extensions/include/extensions/multiplayer/thirdpersoncamera.h b/extensions/include/extensions/multiplayer/thirdpersoncamera.h index e68a2359..f03bbc29 100644 --- a/extensions/include/extensions/multiplayer/thirdpersoncamera.h +++ b/extensions/include/extensions/multiplayer/thirdpersoncamera.h @@ -3,9 +3,11 @@ #include "extensions/multiplayer/animutils.h" #include "extensions/multiplayer/customizestate.h" #include "extensions/multiplayer/protocol.h" +#include "mxgeometry/mxgeometry3d.h" #include "mxgeometry/mxmatrix.h" #include "mxtypes.h" +#include #include #include #include @@ -53,7 +55,17 @@ class ThirdPersonCamera { void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); + // Free camera input handling + void HandleSDLEvent(SDL_Event* p_event); + private: + // Orbit camera helpers + void ComputeOrbitVectors(Mx3DPointFloat& p_at, Mx3DPointFloat& p_dir, Mx3DPointFloat& p_up) const; + void ApplyOrbitCamera(); + void ResetOrbitState(); + void ClampPitch(); + void ClampDistance(); + using AnimCache = AnimUtils::AnimCache; AnimCache* GetOrBuildAnimCache(const char* p_animName); @@ -108,6 +120,28 @@ class ThirdPersonCamera { LegoROI* m_rideVehicleROI; std::map m_animCacheMap; + + // Orbit camera state + float m_orbitYaw; + float m_orbitPitch; + float m_orbitDistance; + + // Touch gesture tracking + struct TouchState { + SDL_FingerID id[2]; + float x[2], y[2]; + int count; + float initialPinchDist; + } m_touch; + + static constexpr float DEFAULT_ORBIT_YAW = 0.0f; + static constexpr float DEFAULT_ORBIT_PITCH = 0.3f; + static constexpr float DEFAULT_ORBIT_DISTANCE = 3.5f; + static constexpr float ORBIT_TARGET_HEIGHT = 1.5f; + static constexpr float MIN_PITCH = 0.05f; + static constexpr float MAX_PITCH = 1.4f; + static constexpr float MIN_DISTANCE = 1.5f; + static constexpr float MAX_DISTANCE = 15.0f; }; } // namespace Multiplayer diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp index 89e31b4c..7d0022c0 100644 --- a/extensions/src/multiplayer.cpp +++ b/extensions/src/multiplayer.cpp @@ -304,6 +304,13 @@ MxBool MultiplayerExt::IsClonedCharacter(const char* p_name) return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE; } +void MultiplayerExt::HandleSDLEvent(SDL_Event* p_event) +{ + if (s_networkManager && s_networkManager->GetThirdPersonCamera().IsActive()) { + s_networkManager->GetThirdPersonCamera().HandleSDLEvent(p_event); + } +} + MxBool MultiplayerExt::CheckRejected() { if (s_networkManager && s_networkManager->WasRejected()) { diff --git a/extensions/src/multiplayer/thirdpersoncamera.cpp b/extensions/src/multiplayer/thirdpersoncamera.cpp index 463c9f1e..16cb8ae1 100644 --- a/extensions/src/multiplayer/thirdpersoncamera.cpp +++ b/extensions/src/multiplayer/thirdpersoncamera.cpp @@ -42,7 +42,8 @@ ThirdPersonCamera::ThirdPersonCamera() m_walkAnimCache(nullptr), m_idleAnimCache(nullptr), m_animTime(0.0f), m_idleTime(0.0f), m_idleAnimTime(0.0f), m_wasMoving(false), m_emoteAnimCache(nullptr), m_emoteTime(0.0f), m_emoteDuration(0.0f), m_emoteActive(false), m_clickAnimObjectId(0), m_currentVehicleType(VEHICLE_NONE), m_rideAnim(nullptr), m_rideRoiMap(nullptr), - m_rideRoiMapSize(0), m_rideVehicleROI(nullptr) + m_rideRoiMapSize(0), m_rideVehicleROI(nullptr), m_orbitYaw(DEFAULT_ORBIT_YAW), + m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE), m_touch{} { SDL_memset(m_displayUniqueName, 0, sizeof(m_displayUniqueName)); } @@ -97,6 +98,8 @@ void ThirdPersonCamera::Disable() ClearRideAnimation(); m_animCacheMap.clear(); ClearAnimCaches(); + + ResetOrbitState(); } void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor) @@ -254,6 +257,9 @@ void ThirdPersonCamera::Tick(float p_deltaTime) return; } + // Update orbit camera position each frame so it tracks the player + ApplyOrbitCamera(); + // Small vehicle with ride animation (like RemotePlayer) if (m_currentVehicleType != VEHICLE_NONE) { StopClickAnimation(); @@ -544,12 +550,8 @@ void ThirdPersonCamera::SetupCamera(LegoPathActor* p_actor) return; } - // Camera behind the character; +z in ROI-local is behind the model - // after TurnAround. Movement inversion in CalculateTransform corrects controls. - Mx3DPointFloat at(0.0f, 2.5f, 3.0f); - Mx3DPointFloat dir(0.0f, -0.3f, -1.0f); - Mx3DPointFloat up(0.0f, 1.0f, 0.0f); - + Mx3DPointFloat at, dir, up; + ComputeOrbitVectors(at, dir, up); world->GetCameraController()->SetWorldTransform(at, dir, up); p_actor->TransformPointOfView(); } @@ -677,6 +679,172 @@ void ThirdPersonCamera::ApplyIdleFrame0() } } +void ThirdPersonCamera::ComputeOrbitVectors( + Mx3DPointFloat& p_at, + Mx3DPointFloat& p_dir, + Mx3DPointFloat& p_up +) const +{ + // Convert spherical coordinates to camera offset in entity-local space. + // Entity local Z+ is "behind" (after TurnAround), which is where yaw=0 points. + float cosP = cosf(m_orbitPitch); + float sinP = sinf(m_orbitPitch); + float sinY = sinf(m_orbitYaw); + float cosY = cosf(m_orbitYaw); + + p_at = Mx3DPointFloat( + m_orbitDistance * sinY * cosP, + ORBIT_TARGET_HEIGHT + m_orbitDistance * sinP, + m_orbitDistance * cosY * cosP + ); + + // Direction points from camera toward the pivot. Since the camera sits on + // a sphere of radius m_orbitDistance, the unit direction is just the + // negated spherical unit vector. + p_dir = Mx3DPointFloat(-sinY * cosP, -sinP, -cosY * cosP); + + p_up = Mx3DPointFloat(0.0f, 1.0f, 0.0f); +} + +void ThirdPersonCamera::ApplyOrbitCamera() +{ + LegoPathActor* actor = UserActor(); + LegoWorld* world = CurrentWorld(); + if (!actor || !world || !world->GetCameraController()) { + return; + } + + Mx3DPointFloat at, dir, up; + ComputeOrbitVectors(at, dir, up); + world->GetCameraController()->SetWorldTransform(at, dir, up); + actor->TransformPointOfView(); +} + +void ThirdPersonCamera::ResetOrbitState() +{ + m_orbitYaw = DEFAULT_ORBIT_YAW; + m_orbitPitch = DEFAULT_ORBIT_PITCH; + m_orbitDistance = DEFAULT_ORBIT_DISTANCE; + m_touch = {}; +} + +void ThirdPersonCamera::ClampPitch() +{ + if (m_orbitPitch < MIN_PITCH) { + m_orbitPitch = MIN_PITCH; + } + if (m_orbitPitch > MAX_PITCH) { + m_orbitPitch = MAX_PITCH; + } +} + +void ThirdPersonCamera::ClampDistance() +{ + if (m_orbitDistance < MIN_DISTANCE) { + m_orbitDistance = MIN_DISTANCE; + } + if (m_orbitDistance > MAX_DISTANCE) { + m_orbitDistance = MAX_DISTANCE; + } +} + +void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event) +{ + switch (p_event->type) { + case SDL_EVENT_MOUSE_WHEEL: + m_orbitDistance -= p_event->wheel.y * 0.5f; + ClampDistance(); + break; + + case SDL_EVENT_MOUSE_MOTION: + if (p_event->motion.state & SDL_BUTTON_RMASK) { + m_orbitYaw += p_event->motion.xrel * 0.005f; + m_orbitPitch += p_event->motion.yrel * 0.005f; + ClampPitch(); + } + break; + + case SDL_EVENT_FINGER_DOWN: { + if (m_touch.count < 2) { + int idx = m_touch.count; + m_touch.id[idx] = p_event->tfinger.fingerID; + m_touch.x[idx] = p_event->tfinger.x; + m_touch.y[idx] = p_event->tfinger.y; + m_touch.count++; + + if (m_touch.count == 2) { + float dx = m_touch.x[1] - m_touch.x[0]; + float dy = m_touch.y[1] - m_touch.y[0]; + m_touch.initialPinchDist = sqrtf(dx * dx + dy * dy); + } + } + break; + } + + case SDL_EVENT_FINGER_MOTION: { + if (m_touch.count == 2) { + // Find which finger moved + int idx = -1; + for (int i = 0; i < 2; i++) { + if (m_touch.id[i] == p_event->tfinger.fingerID) { + idx = i; + break; + } + } + if (idx < 0) { + break; + } + + float oldX = m_touch.x[idx]; + float oldY = m_touch.y[idx]; + m_touch.x[idx] = p_event->tfinger.x; + m_touch.y[idx] = p_event->tfinger.y; + + // Pinch zoom + float dx = m_touch.x[1] - m_touch.x[0]; + float dy = m_touch.y[1] - m_touch.y[0]; + float newDist = sqrtf(dx * dx + dy * dy); + + if (m_touch.initialPinchDist > 0.001f) { + float pinchDelta = m_touch.initialPinchDist - newDist; + m_orbitDistance += pinchDelta * 15.0f; + ClampDistance(); + m_touch.initialPinchDist = newDist; + } + + // Two-finger drag for orbit + float moveX = m_touch.x[idx] - oldX; + float moveY = m_touch.y[idx] - oldY; + m_orbitYaw -= moveX * 2.0f; + m_orbitPitch += moveY * 2.0f; + ClampPitch(); + } + break; + } + + case SDL_EVENT_FINGER_UP: + case SDL_EVENT_FINGER_CANCELED: { + for (int i = 0; i < m_touch.count; i++) { + if (m_touch.id[i] == p_event->tfinger.fingerID) { + // Shift remaining finger down + if (i == 0 && m_touch.count == 2) { + m_touch.id[0] = m_touch.id[1]; + m_touch.x[0] = m_touch.x[1]; + m_touch.y[0] = m_touch.y[1]; + } + m_touch.count--; + m_touch.initialPinchDist = 0.0f; + break; + } + } + break; + } + + default: + break; + } +} + void ThirdPersonCamera::ReinitForCharacter() { LegoPathActor* userActor = UserActor(); diff --git a/extensions/src/multiplayer/worldstatesync.cpp b/extensions/src/multiplayer/worldstatesync.cpp index d9696e25..c1c0df0a 100644 --- a/extensions/src/multiplayer/worldstatesync.cpp +++ b/extensions/src/multiplayer/worldstatesync.cpp @@ -13,6 +13,7 @@ #include "misc/legostorage.h" #include "mxmisc.h" #include "mxvariable.h" +#include "mxvariabletable.h" #include #include diff --git a/tools/ncc/skip.yml b/tools/ncc/skip.yml index 271a4271..3e46aa5a 100644 --- a/tools/ncc/skip.yml +++ b/tools/ncc/skip.yml @@ -79,4 +79,6 @@ SDL_MouseID_v: "SDL-based name" SDL_JoystickID_v: "SDL-based name" SDL_TouchID_v: "SDL-based name" Load: "Not a variable but function name" -HandleCreate: "Not a variable but function name" \ No newline at end of file +HandleCreate: "Not a variable but function name" +HandleBeforeSaveLoad: "Not a variable but function name" +HandleSaveLoaded: "Not a variable but function name" \ No newline at end of file