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 <noreply@anthropic.com>

* Fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
foxtacles 2026-03-07 21:23:57 -08:00 committed by GitHub
parent eb6d2b8728
commit e0a1ac781f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 228 additions and 8 deletions

View File

@ -805,6 +805,8 @@ void LegoInputManager::InitializeHaptics()
void LegoInputManager::UpdateLastInputMethod(SDL_Event* p_event)
{
Extension<MultiplayerExt>::Call(HandleSDLEvent, p_event);
switch (p_event->type) {
case SDL_EVENT_KEY_DOWN:
case SDL_EVENT_KEY_UP:

View File

@ -3,6 +3,7 @@
#include "extensions/extensions.h"
#include "mxtypes.h"
#include <SDL3/SDL_events.h>
#include <map>
#include <string>
@ -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

View File

@ -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 <SDL3/SDL_events.h>
#include <cstdint>
#include <map>
#include <string>
@ -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<std::string, AnimCache> 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

View File

@ -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()) {

View File

@ -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();

View File

@ -13,6 +13,7 @@
#include "misc/legostorage.h"
#include "mxmisc.h"
#include "mxvariable.h"
#include "mxvariabletable.h"
#include <SDL3/SDL_stdinc.h>
#include <cstdio>

View File

@ -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"
HandleCreate: "Not a variable but function name"
HandleBeforeSaveLoad: "Not a variable but function name"
HandleSaveLoaded: "Not a variable but function name"