Camera relative movement (#15)

* Decouple 3rd person camera from player movement

Arrow keys now move the player relative to the camera direction instead
of using tank controls. The camera stays static unless explicitly
controlled via right-click drag, mouse wheel, or touch gestures.

Adds a single hook in CalculateNewPosDir that lets the extension take
over movement computation. The extension manages its own velocity
smoothing, computes camera-relative directions from an absolute yaw,
and writes nav controller velocities via friend class access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Clean up ThirdPersonCamera: remove dead m_orbitYaw, extract helpers

Remove m_orbitYaw (always DEFAULT_ORBIT_YAW) and pass yaw directly to
ComputeOrbitVectors, eliminating save/restore blocks at all call sites.
Extract GetLocalYaw() and InitAbsoluteYaw() helpers to deduplicate
repeated atan2 patterns. Simplify SetupCamera by passing DEFAULT_ORBIT_YAW
directly. Move TURN_RATE constant to file scope.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Hide cursor while orbiting 3rd person camera with right mouse button

Move SDL event handling from LegoInputManager::UpdateLastInputMethod to
an exported function called at the end of SDL_AppEvent, so cursor state
changes aren't overridden by isleapp's SDL_ShowCursor(). Use
SDL_SetWindowRelativeMouseMode to hide the cursor and freeze its position
while the right mouse button is held in 3rd person camera mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
foxtacles 2026-03-10 20:26:23 -07:00 committed by GitHub
parent 9145a23ffe
commit be65af4550
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 287 additions and 19 deletions

View File

@ -877,6 +877,10 @@ SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
}
}
#ifdef EXTENSIONS
Extensions::HandleMultiplayerSDLEvent(event);
#endif
return SDL_APP_CONTINUE;
}

View File

@ -7,6 +7,10 @@
struct LegoLocation;
class Vector3;
namespace Multiplayer
{
class ThirdPersonCamera;
}
//////////////////////////////////////////////////////////////////////////////
//
@ -122,6 +126,8 @@ class LegoNavController : public MxCore {
// LegoNavController::`scalar deleting destructor'
protected:
friend class Multiplayer::ThirdPersonCamera;
float CalculateNewVel(float p_targetVel, float p_currentVel, float p_accel, float p_time);
float CalculateNewTargetVel(int p_pos, int p_center, float p_max);
float CalculateNewAccel(int p_pos, int p_center, float p_max, int p_min);

View File

@ -2,6 +2,7 @@
#include "3dmanager/lego3dmanager.h"
#include "act3.h"
#include "extensions/multiplayer.h"
#include "infocenter.h"
#include "legoanimationmanager.h"
#include "legocameracontroller.h"
@ -29,6 +30,8 @@
#include <SDL3/SDL_stdinc.h>
#include <vec.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(LegoNavController, 0x70)
// MSVC 4.20 didn't define a macro for this key
@ -348,6 +351,11 @@ MxBool LegoNavController::CalculateNewPosDir(
ProcessJoystickInput(rotatedY);
}
if (Extension<MultiplayerExt>::Call(HandleNavOverride, this, p_curPos, p_curDir, p_newPos, p_newDir, deltaTime)
.value_or(FALSE)) {
return TRUE;
}
if (m_useRotationalVel) {
m_rotationalVel = CalculateNewVel(m_targetRotationalVel, m_rotationalVel, m_rotationalAccel * 40.0f, deltaTime);
}

View File

@ -813,8 +813,6 @@ 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

@ -10,9 +10,11 @@
class IslePathActor;
class LegoEntity;
class LegoEventNotificationParam;
class LegoNavController;
class LegoPathActor;
class LegoROI;
class LegoWorld;
class Vector3;
namespace Multiplayer
{
@ -73,6 +75,17 @@ class MultiplayerExt {
// Returns TRUE if the caller should return early.
static MxBool HandleTouchInput();
// Overrides nav controller movement for camera-relative 3rd person controls.
// Returns TRUE if the hook handled movement (caller should return early).
static MxBool HandleNavOverride(
LegoNavController* p_nav,
const Vector3& p_curPos,
const Vector3& p_curDir,
Vector3& p_newPos,
Vector3& p_newDir,
float p_deltaTime
);
static void SetNetworkManager(Multiplayer::NetworkManager* p_networkManager);
static Multiplayer::NetworkManager* GetNetworkManager();
@ -84,6 +97,7 @@ class MultiplayerExt {
#ifdef EXTENSIONS
LEGO1_EXPORT bool IsMultiplayerRejected();
LEGO1_EXPORT void HandleMultiplayerSDLEvent(SDL_Event* p_event);
constexpr auto HandleCreate = &MultiplayerExt::HandleCreate;
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
@ -100,6 +114,7 @@ constexpr auto CheckRejected = &MultiplayerExt::CheckRejected;
constexpr auto HandleSDLEvent = &MultiplayerExt::HandleSDLEvent;
constexpr auto IsThirdPersonCameraActive = &MultiplayerExt::IsThirdPersonCameraActive;
constexpr auto HandleTouchInput = &MultiplayerExt::HandleTouchInput;
constexpr auto HandleNavOverride = &MultiplayerExt::HandleNavOverride;
#else
constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr;
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
@ -116,6 +131,7 @@ constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr;
constexpr decltype(&MultiplayerExt::HandleSDLEvent) HandleSDLEvent = nullptr;
constexpr decltype(&MultiplayerExt::IsThirdPersonCameraActive) IsThirdPersonCameraActive = nullptr;
constexpr decltype(&MultiplayerExt::HandleTouchInput) HandleTouchInput = nullptr;
constexpr decltype(&MultiplayerExt::HandleNavOverride) HandleNavOverride = nullptr;
#endif
}; // namespace Extensions

View File

@ -9,9 +9,11 @@
#include <cstdint>
class IslePathActor;
class LegoNavController;
class LegoPathActor;
class LegoROI;
class LegoWorld;
class Vector3;
namespace Multiplayer
{
@ -54,18 +56,31 @@ class ThirdPersonCamera {
void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world);
// Camera-relative movement override (called from nav controller hook)
MxBool HandleCameraRelativeMovement(
LegoNavController* p_nav,
const Vector3& p_curPos,
const Vector3& p_curDir,
Vector3& p_newPos,
Vector3& p_newDir,
float p_deltaTime
);
// Free camera input handling
void HandleSDLEvent(SDL_Event* p_event);
bool IsTouchGestureActive() const { return m_touchGestureActive; }
private:
// Orbit camera helpers
void ComputeOrbitVectors(Mx3DPointFloat& p_at, Mx3DPointFloat& p_dir, Mx3DPointFloat& p_up) const;
void ComputeOrbitVectors(float p_yaw, Mx3DPointFloat& p_at, Mx3DPointFloat& p_dir, Mx3DPointFloat& p_up) const;
void ApplyOrbitCamera();
void ResetOrbitState();
void ClampPitch();
void ClampDistance();
float GetLocalYaw(LegoROI* p_roi) const;
void InitAbsoluteYaw(LegoROI* p_roi);
void SetupCamera(LegoPathActor* p_actor);
void ReinitForCharacter();
@ -93,9 +108,10 @@ class ThirdPersonCamera {
bool m_showNameBubble;
// Orbit camera state
float m_orbitYaw;
float m_orbitPitch;
float m_orbitDistance;
float m_absoluteYaw; // Camera yaw in world space (decoupled from player facing)
float m_smoothedSpeed; // Extension-managed velocity for smooth acceleration/deceleration
// Touch gesture tracking
bool m_touchGestureActive = false;

View File

@ -12,6 +12,7 @@
#include "legoeventnotificationparam.h"
#include "legogamestate.h"
#include "legoinputmanager.h"
#include "legonavcontroller.h"
#include "legopathactor.h"
#include "misc.h"
#include "roi/legoroi.h"
@ -323,6 +324,27 @@ MxBool MultiplayerExt::HandleTouchInput()
return FALSE;
}
MxBool MultiplayerExt::HandleNavOverride(
LegoNavController* p_nav,
const Vector3& p_curPos,
const Vector3& p_curDir,
Vector3& p_newPos,
Vector3& p_newDir,
float p_deltaTime
)
{
if (!s_networkManager) {
return FALSE;
}
Multiplayer::ThirdPersonCamera& cam = s_networkManager->GetThirdPersonCamera();
if (!cam.IsActive()) {
return FALSE;
}
return cam.HandleCameraRelativeMovement(p_nav, p_curPos, p_curDir, p_newPos, p_newDir, p_deltaTime);
}
MxBool MultiplayerExt::CheckRejected()
{
if (s_networkManager && s_networkManager->WasRejected()) {
@ -346,3 +368,8 @@ bool Extensions::IsMultiplayerRejected()
{
return Extension<MultiplayerExt>::Call(CheckRejected).value_or(FALSE);
}
void Extensions::HandleMultiplayerSDLEvent(SDL_Event* p_event)
{
Extension<MultiplayerExt>::Call(HandleSDLEvent, p_event);
}

View File

@ -7,6 +7,7 @@
#include "islepathactor.h"
#include "legocameracontroller.h"
#include "legocharactermanager.h"
#include "legoinputmanager.h"
#include "legonavcontroller.h"
#include "legopathactor.h"
#include "legovideomanager.h"
@ -23,6 +24,8 @@
using namespace Multiplayer;
static constexpr float TURN_RATE = 10.0f;
// Flip a matrix from forward-z to backward-z (or vice versa) in place.
// Same operation as IslePathActor::TurnAround: negate z, recompute right.
static void FlipMatrixDirection(MxMatrix& p_mat)
@ -38,8 +41,8 @@ ThirdPersonCamera::ThirdPersonCamera()
: m_enabled(false), m_active(false), m_pendingWorldTransition(false), m_playerROI(nullptr),
m_displayActorIndex(DISPLAY_ACTOR_NONE), m_displayROI(nullptr),
m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true}), m_showNameBubble(true),
m_orbitYaw(DEFAULT_ORBIT_YAW), m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE),
m_touch{}
m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE),
m_absoluteYaw(DEFAULT_ORBIT_YAW), m_smoothedSpeed(0.0f), m_touch{}
{
SDL_memset(m_displayUniqueName, 0, sizeof(m_displayUniqueName));
}
@ -212,9 +215,14 @@ void ThirdPersonCamera::Tick(float p_deltaTime)
// After a world transition, PlaceActor has now run and set the ROI to
// the correct position. Clear the flag so subsequent OnActorEnter calls
// work normally. ApplyOrbitCamera below handles the camera setup.
// work normally. Initialize absolute yaw from the player's actual
// direction so the camera starts behind the character.
if (m_pendingWorldTransition) {
m_pendingWorldTransition = false;
LegoPathActor* actor = UserActor();
if (actor && actor->GetROI()) {
InitAbsoluteYaw(actor->GetROI());
}
}
// While a cam anim locks the player (actor state c_disabled), calling
@ -393,6 +401,22 @@ void ThirdPersonCamera::OnWorldDisabled(LegoWorld* p_world)
m_animator.ClearAll();
}
float ThirdPersonCamera::GetLocalYaw(LegoROI* p_roi) const
{
if (p_roi) {
const float* dir = p_roi->GetWorldDirection();
float playerWorldYaw = SDL_atan2f(-dir[0], dir[2]);
return m_absoluteYaw - playerWorldYaw;
}
return m_absoluteYaw;
}
void ThirdPersonCamera::InitAbsoluteYaw(LegoROI* p_roi)
{
const float* dir = p_roi->GetWorldDirection();
m_absoluteYaw = SDL_atan2f(-dir[0], dir[2]) + DEFAULT_ORBIT_YAW;
}
void ThirdPersonCamera::SetupCamera(LegoPathActor* p_actor)
{
LegoWorld* world = CurrentWorld();
@ -400,9 +424,18 @@ void ThirdPersonCamera::SetupCamera(LegoPathActor* p_actor)
return;
}
Mx3DPointFloat at, dir, up;
ComputeOrbitVectors(at, dir, up);
world->GetCameraController()->SetWorldTransform(at, dir, up);
LegoROI* roi = p_actor->GetROI();
if (roi) {
InitAbsoluteYaw(roi);
}
m_smoothedSpeed = 0.0f;
// InitAbsoluteYaw sets m_absoluteYaw = playerYaw + DEFAULT_ORBIT_YAW,
// so localYaw = m_absoluteYaw - playerYaw = DEFAULT_ORBIT_YAW.
Mx3DPointFloat at, camDir, up;
ComputeOrbitVectors(DEFAULT_ORBIT_YAW, at, camDir, up);
world->GetCameraController()->SetWorldTransform(at, camDir, up);
p_actor->TransformPointOfView();
}
@ -489,15 +522,20 @@ void ThirdPersonCamera::SetNameBubbleVisible(bool p_visible)
m_animator.SetNameBubbleVisible(p_visible);
}
void ThirdPersonCamera::ComputeOrbitVectors(Mx3DPointFloat& p_at, Mx3DPointFloat& p_dir, Mx3DPointFloat& p_up) const
void ThirdPersonCamera::ComputeOrbitVectors(
float p_yaw,
Mx3DPointFloat& p_at,
Mx3DPointFloat& p_dir,
Mx3DPointFloat& p_up
) const
{
// Convert spherical coordinates to camera offset in entity-local space.
// The ROI uses forward-z (Z+ = visual forward). The camera orbits
// behind the character, so at yaw=0 it sits at local -Z.
float cosP = SDL_cosf(m_orbitPitch);
float sinP = SDL_sinf(m_orbitPitch);
float sinY = SDL_sinf(m_orbitYaw);
float cosY = SDL_cosf(m_orbitYaw);
float sinY = SDL_sinf(p_yaw);
float cosY = SDL_cosf(p_yaw);
p_at = Mx3DPointFloat(
m_orbitDistance * sinY * cosP,
@ -519,17 +557,23 @@ void ThirdPersonCamera::ApplyOrbitCamera()
return;
}
Mx3DPointFloat at, dir, up;
ComputeOrbitVectors(at, dir, up);
world->GetCameraController()->SetWorldTransform(at, dir, up);
// Derive entity-local yaw from absolute yaw and player's world facing.
// This prevents the camera from rotating when the player turns.
float localYaw = GetLocalYaw(actor->GetROI());
Mx3DPointFloat at, camDir, up;
ComputeOrbitVectors(localYaw, at, camDir, up);
world->GetCameraController()->SetWorldTransform(at, camDir, up);
actor->TransformPointOfView();
}
void ThirdPersonCamera::ResetOrbitState()
{
m_orbitYaw = DEFAULT_ORBIT_YAW;
m_orbitPitch = DEFAULT_ORBIT_PITCH;
m_orbitDistance = DEFAULT_ORBIT_DISTANCE;
m_absoluteYaw = DEFAULT_ORBIT_YAW;
m_smoothedSpeed = 0.0f;
m_touch = {};
}
@ -553,6 +597,146 @@ void ThirdPersonCamera::ClampDistance()
}
}
MxBool ThirdPersonCamera::HandleCameraRelativeMovement(
LegoNavController* p_nav,
const Vector3& p_curPos,
const Vector3& p_curDir,
Vector3& p_newPos,
Vector3& p_newDir,
float p_deltaTime
)
{
// Read keyboard state
LegoInputManager* inputManager = InputManager();
MxU32 keyFlags = 0;
if (!inputManager || inputManager->GetNavigationKeyStates(keyFlags) == FAILURE) {
keyFlags = 0;
}
// Compute camera world-forward and right from absolute yaw
float camForwardX = -SDL_sinf(m_absoluteYaw);
float camForwardZ = SDL_cosf(m_absoluteYaw);
float camRightX = SDL_cosf(m_absoluteYaw);
float camRightZ = SDL_sinf(m_absoluteYaw);
// Map key flags to combined movement direction
float moveDirX = 0.0f;
float moveDirZ = 0.0f;
if (keyFlags & LegoInputManager::c_up) {
moveDirX += camForwardX;
moveDirZ += camForwardZ;
}
if (keyFlags & LegoInputManager::c_down) {
moveDirX -= camForwardX;
moveDirZ -= camForwardZ;
}
if (keyFlags & LegoInputManager::c_left) {
moveDirX -= camRightX;
moveDirZ -= camRightZ;
}
if (keyFlags & LegoInputManager::c_right) {
moveDirX += camRightX;
moveDirZ += camRightZ;
}
// Normalize movement direction
float moveDirLen = SDL_sqrtf(moveDirX * moveDirX + moveDirZ * moveDirZ);
bool hasInput = moveDirLen > 0.001f;
if (hasInput) {
moveDirX /= moveDirLen;
moveDirZ /= moveDirLen;
}
// Smooth speed using acceleration/deceleration (mirroring nav controller's model)
float maxSpeed = p_nav->m_maxLinearVel;
if (hasInput) {
float accel = p_nav->m_maxLinearAccel;
m_smoothedSpeed += accel * p_deltaTime;
if (m_smoothedSpeed > maxSpeed) {
m_smoothedSpeed = maxSpeed;
}
}
else {
float decel = p_nav->m_maxLinearDeccel;
m_smoothedSpeed -= decel * p_deltaTime;
if (m_smoothedSpeed < 0.0f) {
m_smoothedSpeed = 0.0f;
}
}
if (m_smoothedSpeed < p_nav->m_zeroThreshold && !hasInput) {
m_smoothedSpeed = 0.0f;
// No movement, keep current position and direction
p_newPos = p_curPos;
p_newDir = p_curDir;
}
else {
// Compute new position. Include p_curDir[1] (slope from boundary
// orientation) so the actor follows terrain height changes.
float speed = m_smoothedSpeed * p_deltaTime;
if (hasInput) {
p_newPos[0] = p_curPos[0] + moveDirX * speed;
p_newPos[1] = p_curPos[1] + p_curDir[1] * speed;
p_newPos[2] = p_curPos[2] + moveDirZ * speed;
// Smooth turn: interpolate facing toward movement direction
float targetYaw = SDL_atan2f(-moveDirX, moveDirZ);
float currentYaw = SDL_atan2f(-p_curDir[0], p_curDir[2]);
float angleDiff = targetYaw - currentYaw;
// Wrap to [-PI, PI]
while (angleDiff > SDL_PI_F) {
angleDiff -= 2.0f * SDL_PI_F;
}
while (angleDiff < -SDL_PI_F) {
angleDiff += 2.0f * SDL_PI_F;
}
float maxTurn = TURN_RATE * p_deltaTime;
if (SDL_fabsf(angleDiff) > maxTurn) {
angleDiff = angleDiff > 0 ? maxTurn : -maxTurn;
}
float newYaw = currentYaw + angleDiff;
p_newDir[0] = -SDL_sinf(newYaw);
p_newDir[1] = p_curDir[1];
p_newDir[2] = SDL_cosf(newYaw);
}
else {
// Decelerating: continue in current direction
p_newPos[0] = p_curPos[0] + p_curDir[0] * speed;
p_newPos[1] = p_curPos[1] + p_curDir[1] * speed;
p_newPos[2] = p_curPos[2] + p_curDir[2] * speed;
p_newDir = p_curDir;
}
}
// Set nav controller velocities via friend access so GetWorldSpeed()
// reports correctly for animations/network
p_nav->m_linearVel = m_smoothedSpeed;
// Suppress camera roll in Animate()
p_nav->m_rotationalVel = 0.0f;
// Pre-set camera controller's local transform for the NEW player direction.
// TransformPointOfView() runs after this hook returns but before Tick()'s
// ApplyOrbitCamera(). Without this, the stale local transform (computed for
// the old facing) composes with the new actor transform, causing a one-frame
// camera flash in the wrong direction.
LegoWorld* world = CurrentWorld();
if (world && world->GetCameraController()) {
float newPlayerYaw = SDL_atan2f(-p_newDir[0], p_newDir[2]);
float localYaw = m_absoluteYaw - newPlayerYaw;
Mx3DPointFloat at, camDir, camUp;
ComputeOrbitVectors(localYaw, at, camDir, camUp);
world->GetCameraController()->SetWorldTransform(at, camDir, camUp);
}
return TRUE;
}
void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event)
{
switch (p_event->type) {
@ -563,12 +747,21 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event)
case SDL_EVENT_MOUSE_MOTION:
if (p_event->motion.state & SDL_BUTTON_RMASK) {
m_orbitYaw -= p_event->motion.xrel * 0.005f;
m_absoluteYaw -= p_event->motion.xrel * 0.005f;
m_orbitPitch += p_event->motion.yrel * 0.005f;
ClampPitch();
}
break;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
case SDL_EVENT_MOUSE_BUTTON_UP: {
SDL_Window* window = SDL_GetWindowFromID(p_event->button.windowID);
if (window) {
SDL_SetWindowRelativeMouseMode(window, SDL_GetMouseState(NULL, NULL) & SDL_BUTTON_RMASK);
}
break;
}
case SDL_EVENT_FINGER_DOWN: {
if (m_touch.count < 2) {
int idx = m_touch.count;
@ -621,7 +814,7 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event)
// 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_absoluteYaw += moveX * 2.0f;
m_orbitPitch += moveY * 2.0f;
ClampPitch();
}