Fix camera flip bugs, refactor camera (#11)

* Fix 180-degree camera flip when exiting sub-worlds without cam anim

When returning from sub-worlds (jukebox, hospital exterior, etc.) with
3rd person camera active, the camera/player flipped 180 degrees. This
happened because SpawnPlayer calls Enter() → TurnAround() before
PlaceActor(), and PlaceActor overwrites the ROI direction with the
path's standard convention (z = forward). For spawn points with cam
anims, OnCamAnimEnd corrected this, but spawn points with m_location=0
(like jukebox exterior) had no correction.

Replace m_roiUnflipped with m_needsDirectionFlip flag that tracks world
transitions. OnWorldEnabled sets the flag, and the first Tick after
PlaceActor completes flips the ROI to backward-z and re-setups the
camera. ReinitForCharacter now always flips the ROI direction, handling
both Disable→Enable toggles and enabling 3rd person after a 1st-person
spawn.

https://claude.ai/code/session_01NQ9vy9Qr3aH6LNsRNLEEtY

* Fix camera flip regressions for vehicle exit and world transitions

The unconditional ROI flip in ReinitForCharacter caused a 180-degree
flip when exiting vehicles (Enter's TurnAround follows and double-flips).
Restore conditional flip using m_roiUnflipped, but now also set it in
OnWorldEnabled (even when disabled) so cold-enabling 3rd person after a
world transition correctly flips from PlaceActor's forward-z.

Key changes:
- Remove m_roiUnflipped clearing from OnActorEnter: Enter() is always
  followed by PlaceActor which overwrites the ROI, so clearing the flag
  prematurely caused the cold-enable case to miss the needed flip.
- Add orbit camera override in OnActorEnter during world transitions
  (m_needsDirectionFlip && m_active) to suppress the 1st-person camera
  flash from Enter's TransformPointOfView.
- Clear m_roiUnflipped in OnCamAnimEnd alongside m_needsDirectionFlip,
  since the cam anim's PlaceActor + flip handles the correction.

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

* Defer orbit camera setup during world transition loading freeze

Remove the premature SetupCamera call from OnActorEnter's world
transition path. The stale orbit camera view (computed before
PlaceActor runs) would freeze on screen during the ~500ms world
load, appearing as a wrong-direction flash. The Tick correction
after PlaceActor now handles the initial orbit camera setup at
the correct position.

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

* Skip orbit camera while cam anim is running to prevent actor glitch

When a cam anim plays with 3rd-person camera active, ApplyOrbitCamera
was fighting the cam anim each frame. If the cam anim was interrupted
(space bar), its end handler read the ViewROI position — which was set
by our orbit camera (elevated, behind player) — and placed the actor
at that position, causing it to glitch into the air.

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

* Add documentation for ROI direction conventions and camera corrections

Documents the forward-z vs backward-z conventions, all code paths that
require direction correction, and the flags that coordinate them.

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

* Refactor 3rd-person camera from backward-z to forward-z convention

Switch the orbit camera to use forward-z (matching PlaceActor's native
output), eliminating all FlipROIDirection/TurnAround corrections. The
display clone flips to backward-z when syncing from the native ROI so
character meshes face correctly. Use actor state (c_disabled) instead of
m_animRunning to guard against cam anim conflicts, allowing the orbit
camera to resume as soon as the player is released.

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

* Reset orbit camera position on world re-entry

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

* Remove ShouldInvertMovement and update documentation

* Remove header
This commit is contained in:
foxtacles 2026-03-09 17:15:22 -07:00 committed by GitHub
parent 37f33a91df
commit fe5ef4f9a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 177 additions and 130 deletions

View File

@ -1,7 +1,6 @@
#include "legopathactor.h" #include "legopathactor.h"
#include "define.h" #include "define.h"
#include "extensions/multiplayer.h"
#include "geom/legoorientededge.h" #include "geom/legoorientededge.h"
#include "legocachesoundmanager.h" #include "legocachesoundmanager.h"
#include "legocameracontroller.h" #include "legocameracontroller.h"
@ -21,8 +20,6 @@
#include <mxdebug.h> #include <mxdebug.h>
#include <vec.h> #include <vec.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(LegoPathActor, 0x154) DECOMP_SIZE_ASSERT(LegoPathActor, 0x154)
DECOMP_SIZE_ASSERT(LegoPathEdgeContainer, 0x3c) DECOMP_SIZE_ASSERT(LegoPathEdgeContainer, 0x3c)
@ -265,11 +262,6 @@ MxS32 LegoPathActor::CalculateTransform(float p_time, Matrix4& p_transform)
m_worldSpeed = nav->GetLinearVel(); m_worldSpeed = nav->GetLinearVel();
MxBool invertDir = Extension<MultiplayerExt>::Call(ShouldInvertMovement, this).value_or(FALSE);
if (invertDir) {
dir *= -1.0f;
}
if (nav->CalculateNewPosDir(pos, dir, newPos, newDir, m_boundary->GetUp())) { if (nav->CalculateNewPosDir(pos, dir, newPos, newDir, m_boundary->GetUp())) {
Mx3DPointFloat newPosCopy; Mx3DPointFloat newPosCopy;
newPosCopy = newPos; newPosCopy = newPos;
@ -329,10 +321,6 @@ MxS32 LegoPathActor::CalculateTransform(float p_time, Matrix4& p_transform)
} }
} }
if (invertDir) {
newDir *= -1.0f;
}
p_transform.SetIdentity(); p_transform.SetIdentity();
Vector3 right(p_transform[0]); Vector3 right(p_transform[0]);

View File

@ -0,0 +1,97 @@
# ROI Direction Conventions & Third-Person Camera
## Background: The Two Z-Axis Conventions
The game engine represents an actor's facing direction via the z-axis of its ROI
(Real-time Object Instance) local-to-world transform. Two opposite conventions
exist throughout the codebase:
| Convention | ROI z-axis points... | Used by |
|----------------|--------------------------|--------------------------------------------------------|
| **forward-z** | Toward visual forward | `PlaceActor` (with `m_cameraFlag=TRUE`), cam anim end |
| **backward-z** | Away from visual forward | After `Enter()`'s `TurnAround()`, vehicle ROIs |
Toggling between conventions is done by `IslePathActor::TurnAround` (or the
local `FlipMatrixDirection` helper): negate the z-axis and recompute the right
vector.
## Design Choice: Forward-Z
The third-person orbit camera uses **forward-z**, matching the convention that
`PlaceActor` naturally produces. This eliminates the need to flip the ROI
direction after every `PlaceActor` call.
`ComputeOrbitVectors` treats local Z+ as the character's visual forward and
places the camera at local Z (behind the character), looking toward +Z.
## Engine Behavior
The engine's actor lifecycle:
```
Enter()
→ ResetWorldTransform(TRUE) sets m_cameraFlag = TRUE
→ TurnAround() flips to backward-z
→ TransformPointOfView() sets 1st-person camera
PlaceActor() resets to forward-z ← what we use
```
`PlaceActor` always produces forward-z (when `m_cameraFlag=TRUE`), which is
exactly what the orbit camera expects. No direction correction is needed after
`PlaceActor` runs.
## World Transition Timing
The one remaining complexity is timing during world transitions. The event order
is:
1. `OnWorldEnabled` fires (from `LegoWorld::Enable`, BEFORE `SpawnPlayer`)
2. `ReinitForCharacter` sets up the display ROI and marks `m_active = true`
3. `Enter()` fires `OnActorEnter` — ROI is at **stale position** from previous session
4. `PlaceActor` sets ROI to correct spawn position
5. First `Tick``ApplyOrbitCamera` sets the camera at the correct position
Between steps 3 and 5, the ROI position is stale. If we set up the orbit camera
in step 3, the stale view would freeze on screen during the ~500ms world load.
The `m_pendingWorldTransition` flag handles this: set in `OnWorldEnabled`,
it causes `OnActorEnter` and `ReinitForCharacter` to skip camera setup.
Cleared in the first `Tick` after `PlaceActor`, where `ApplyOrbitCamera`
naturally handles the camera. The orbit state (yaw, pitch, distance) is also
reset to defaults in `OnWorldEnabled`.
## Display Clone Direction
The native actor ROI is invisible in 3rd-person mode. A display clone renders
the character model instead. Character meshes face z, so the clone needs
backward-z to look correct. When syncing the clone's transform from the native
ROI (which is in forward-z), `Tick` negates the z-axis and recomputes the right
vector — the same operation as `TurnAround`.
This also affects the right vector (X-axis): forward-z and backward-z produce
opposite right vectors. The orbit yaw input is negated to compensate, keeping
drag-right = camera-moves-right.
## Cam Anim Interaction
While a cam anim locks the player (`GetActorState() == c_disabled`), two
things protect the orbit camera:
1. **Tick guard**: `ApplyOrbitCamera` is skipped so it doesn't fight the cam
anim's `TransformPointOfView`. Without this, the cam anim end handler would
read our elevated orbit camera position and place the actor in the air.
2. **`OnCamAnimEnd`**: When the cam anim releases the player (first space bar
interruption or natural end), this callback calls `SetupCamera` to restore
the orbit camera.
After the first interruption, the actor state resets to `c_initial` and the
orbit camera resumes immediately — even if `m_animRunning` is still true for
background animations playing in the world.
## Network Direction
The network protocol sends forward-z direction (visual forward). Remote players
negate the received direction to backward-z for their ROI, since character meshes
face z.

View File

@ -50,7 +50,6 @@ class MultiplayerExt {
static void HandleActorEnter(IslePathActor* p_actor); static void HandleActorEnter(IslePathActor* p_actor);
static void HandleActorExit(IslePathActor* p_actor); static void HandleActorExit(IslePathActor* p_actor);
static void HandleCamAnimEnd(LegoPathActor* p_actor); static void HandleCamAnimEnd(LegoPathActor* p_actor);
static MxBool ShouldInvertMovement(LegoPathActor* p_actor);
// Returns TRUE if the name belongs to a multiplayer clone (entity-less ROI). // Returns TRUE if the name belongs to a multiplayer clone (entity-less ROI).
static MxBool IsClonedCharacter(const char* p_name); static MxBool IsClonedCharacter(const char* p_name);
@ -91,7 +90,6 @@ constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick;
constexpr auto HandleActorEnter = &MultiplayerExt::HandleActorEnter; constexpr auto HandleActorEnter = &MultiplayerExt::HandleActorEnter;
constexpr auto HandleActorExit = &MultiplayerExt::HandleActorExit; constexpr auto HandleActorExit = &MultiplayerExt::HandleActorExit;
constexpr auto HandleCamAnimEnd = &MultiplayerExt::HandleCamAnimEnd; constexpr auto HandleCamAnimEnd = &MultiplayerExt::HandleCamAnimEnd;
constexpr auto ShouldInvertMovement = &MultiplayerExt::ShouldInvertMovement;
constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter; constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter;
constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad; constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad;
constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded; constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded;
@ -107,7 +105,6 @@ constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr;
constexpr decltype(&MultiplayerExt::HandleActorEnter) HandleActorEnter = nullptr; constexpr decltype(&MultiplayerExt::HandleActorEnter) HandleActorEnter = nullptr;
constexpr decltype(&MultiplayerExt::HandleActorExit) HandleActorExit = nullptr; constexpr decltype(&MultiplayerExt::HandleActorExit) HandleActorExit = nullptr;
constexpr decltype(&MultiplayerExt::HandleCamAnimEnd) HandleCamAnimEnd = nullptr; constexpr decltype(&MultiplayerExt::HandleCamAnimEnd) HandleCamAnimEnd = nullptr;
constexpr decltype(&MultiplayerExt::ShouldInvertMovement) ShouldInvertMovement = nullptr;
constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr; constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr;
constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr; constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr;
constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr; constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr;

View File

@ -78,8 +78,8 @@ class ThirdPersonCamera {
bool m_enabled; bool m_enabled;
bool m_active; bool m_active;
bool m_roiUnflipped; // True when Disable() flipped the ROI direction; ReinitForCharacter re-applies bool m_pendingWorldTransition; // True between OnWorldEnabled and first Tick; defers camera setup
LegoROI* m_playerROI; // Borrowed, not owned LegoROI* m_playerROI; // Borrowed, not owned
// Display actor override // Display actor override
uint8_t m_displayActorIndex; uint8_t m_displayActorIndex;

View File

@ -283,15 +283,6 @@ void MultiplayerExt::HandleCamAnimEnd(LegoPathActor* p_actor)
} }
} }
MxBool MultiplayerExt::ShouldInvertMovement(LegoPathActor* p_actor)
{
if (s_networkManager && UserActor() == p_actor) {
return s_networkManager->GetThirdPersonCamera().IsActive();
}
return FALSE;
}
MxBool MultiplayerExt::IsClonedCharacter(const char* p_name) MxBool MultiplayerExt::IsClonedCharacter(const char* p_name)
{ {
if (!s_networkManager) { if (!s_networkManager) {

View File

@ -318,17 +318,6 @@ void NetworkManager::BroadcastLocalState()
msg.vehicleType = DetectVehicleType(userActor); msg.vehicleType = DetectVehicleType(userActor);
SDL_memcpy(msg.position, pos, sizeof(msg.position)); SDL_memcpy(msg.position, pos, sizeof(msg.position));
SDL_memcpy(msg.direction, dir, sizeof(msg.direction)); SDL_memcpy(msg.direction, dir, sizeof(msg.direction));
// When 3rd-person camera is active, ShouldInvertMovement causes movement
// inversion, and CalculateTransform re-inverts to keep ROI z backward.
// Negate to send the visual-forward direction that remote players expect.
// RemotePlayer::UpdateTransform negates again to restore backward-z.
if (m_thirdPersonCamera.IsActive()) {
msg.direction[0] = -msg.direction[0];
msg.direction[1] = -msg.direction[1];
msg.direction[2] = -msg.direction[2];
}
SDL_memcpy(msg.up, up, sizeof(msg.up)); SDL_memcpy(msg.up, up, sizeof(msg.up));
msg.speed = speed; msg.speed = speed;
msg.walkAnimId = m_localWalkAnimId; msg.walkAnimId = m_localWalkAnimId;

View File

@ -258,10 +258,8 @@ void RemotePlayer::UpdateTransform(float p_deltaTime)
LERP3(m_currentDirection, m_currentDirection, m_targetDirection, 0.2f); LERP3(m_currentDirection, m_currentDirection, m_targetDirection, 0.2f);
LERP3(m_currentUp, m_currentUp, m_targetUp, 0.2f); LERP3(m_currentUp, m_currentUp, m_targetUp, 0.2f);
// Negate the received direction to restore the backward-z ROI convention. // The network sends forward-z (visual forward). Character meshes face -z,
// BroadcastLocalState sends visual-forward; negating here gives ROI z = // so negate to get backward-z for the ROI (mesh faces the correct way).
// backward, so mesh faces -z = forward (matching the sender's visual).
// See also: BroadcastLocalState in networkmanager.cpp.
Mx3DPointFloat pos(m_currentPosition[0], m_currentPosition[1], m_currentPosition[2]); Mx3DPointFloat pos(m_currentPosition[0], m_currentPosition[1], m_currentPosition[2]);
Mx3DPointFloat dir(-m_currentDirection[0], -m_currentDirection[1], -m_currentDirection[2]); Mx3DPointFloat dir(-m_currentDirection[0], -m_currentDirection[1], -m_currentDirection[2]);
Mx3DPointFloat up(m_currentUp[0], m_currentUp[1], m_currentUp[2]); Mx3DPointFloat up(m_currentUp[0], m_currentUp[1], m_currentUp[2]);

View File

@ -7,6 +7,7 @@
#include "islepathactor.h" #include "islepathactor.h"
#include "legocameracontroller.h" #include "legocameracontroller.h"
#include "legocharactermanager.h" #include "legocharactermanager.h"
#include "legopathactor.h"
#include "legovideomanager.h" #include "legovideomanager.h"
#include "legoworld.h" #include "legoworld.h"
#include "misc.h" #include "misc.h"
@ -14,27 +15,26 @@
#include "mxgeometry/mxgeometry3d.h" #include "mxgeometry/mxgeometry3d.h"
#include "mxgeometry/mxmatrix.h" #include "mxgeometry/mxmatrix.h"
#include "realtime/realtime.h" #include "realtime/realtime.h"
#include "realtime/vector.h"
#include "roi/legoroi.h" #include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h> #include <SDL3/SDL_stdinc.h>
using namespace Multiplayer; using namespace Multiplayer;
// Flip the ROI's z-axis direction in place (same operation as IslePathActor::TurnAround). // Flip a matrix from forward-z to backward-z (or vice versa) in place.
static void FlipROIDirection(LegoROI* p_roi) // Same operation as IslePathActor::TurnAround: negate z, recompute right.
static void FlipMatrixDirection(MxMatrix& p_mat)
{ {
MxMatrix transform(p_roi->GetLocal2World()); Vector3 right(p_mat[0]);
Vector3 right(transform[0]); Vector3 up(p_mat[1]);
Vector3 up(transform[1]); Vector3 direction(p_mat[2]);
Vector3 direction(transform[2]);
direction *= -1.0f; direction *= -1.0f;
right.EqualsCross(up, direction); right.EqualsCross(up, direction);
p_roi->SetLocal2World(transform);
p_roi->WrappedUpdateWorldData();
} }
ThirdPersonCamera::ThirdPersonCamera() ThirdPersonCamera::ThirdPersonCamera()
: m_enabled(false), m_active(false), m_roiUnflipped(false), m_playerROI(nullptr), : m_enabled(false), m_active(false), m_pendingWorldTransition(false), m_playerROI(nullptr),
m_displayActorIndex(DISPLAY_ACTOR_NONE), m_displayROI(nullptr), m_displayActorIndex(DISPLAY_ACTOR_NONE), m_displayROI(nullptr),
m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true}), m_showNameBubble(true), m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true}), m_showNameBubble(true),
m_orbitYaw(DEFAULT_ORBIT_YAW), m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE), m_orbitYaw(DEFAULT_ORBIT_YAW), m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE),
@ -57,19 +57,6 @@ void ThirdPersonCamera::Disable()
LegoPathActor* userActor = UserActor(); LegoPathActor* userActor = UserActor();
LegoWorld* world = CurrentWorld(); LegoWorld* world = CurrentWorld();
// Undo TurnAround so the ROI z-axis points in the visual forward
// direction. This keeps the 1st-person camera facing the same way
// as the 3rd-person camera, and ensures the network direction stays
// consistent (no 180-degree flip for others).
// Flip the native ROI (not the display clone) since TransformPointOfView
// uses it for the 1st-person camera.
LegoROI* turnAroundROI = userActor ? userActor->GetROI() : nullptr;
if (turnAroundROI) {
FlipROIDirection(turnAroundROI);
m_roiUnflipped = true;
}
m_playerROI->SetVisibility(FALSE); m_playerROI->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Remove(*m_playerROI); VideoManager()->Get3DManager()->Remove(*m_playerROI);
@ -85,6 +72,7 @@ void ThirdPersonCamera::Disable()
} }
m_active = false; m_active = false;
m_pendingWorldTransition = false;
DestroyNameBubble(); DestroyNameBubble();
DestroyDisplayClone(); DestroyDisplayClone();
m_animator.ClearRideAnimation(); m_animator.ClearRideAnimation();
@ -104,13 +92,18 @@ void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor)
// even if Enable() was called after entering the vehicle. // even if Enable() was called after entering the vehicle.
m_animator.SetCurrentVehicleType(DetectVehicleType(userActor)); m_animator.SetCurrentVehicleType(DetectVehicleType(userActor));
// Enter() calls TurnAround(), so any previous undo is superseded.
m_roiUnflipped = false;
if (!m_enabled) { if (!m_enabled) {
return; return;
} }
// During a world transition, the ROI position is stale (PlaceActor hasn't
// run yet). Skip camera setup — the stale orbit view would freeze on
// screen during the ~500ms world load. ApplyOrbitCamera in the first
// Tick after PlaceActor handles camera setup naturally.
if (m_pendingWorldTransition && m_active) {
return;
}
LegoROI* newROI = userActor->GetROI(); LegoROI* newROI = userActor->GetROI();
if (!newROI) { if (!newROI) {
return; return;
@ -135,12 +128,6 @@ void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor)
return; return;
} }
// Undo Enter()'s TurnAround. Vehicles are placed with ROI z opposite
// to their visual forward (mesh faces -z = forward). Enter()'s
// TurnAround flips ROI z to match the visual forward, which breaks
// the backward-z convention the 3rd-person camera relies on.
p_actor->TurnAround();
m_active = true; m_active = true;
SetupCamera(userActor); SetupCamera(userActor);
m_animator.BuildRideAnimation(m_animator.GetCurrentVehicleType(), m_playerROI, 0); m_animator.BuildRideAnimation(m_animator.GetCurrentVehicleType(), m_playerROI, 0);
@ -148,12 +135,11 @@ void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor)
return; return;
} }
// Non-vehicle (walking character) entry — Enter() already called TurnAround. // Walking character entry.
newROI->SetVisibility(FALSE); newROI->SetVisibility(FALSE);
if (!EnsureDisplayROI()) { if (!EnsureDisplayROI()) {
return; return;
} }
m_roiUnflipped = false;
m_active = true; m_active = true;
m_playerROI->SetVisibility(TRUE); m_playerROI->SetVisibility(TRUE);
@ -181,16 +167,6 @@ void ThirdPersonCamera::OnActorExit(IslePathActor* p_actor)
// For vehicle exit, p_actor is the vehicle, not UserActor — // For vehicle exit, p_actor is the vehicle, not UserActor —
// check m_currentVehicleType instead. // check m_currentVehicleType instead.
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) { if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) {
// When 3rd-person camera is active, movement inversion causes the
// vehicle to physically drive opposite to vanilla. CalculateTransform
// re-inverts to keep the ROI z backward. Exit()'s TurnAround restores
// the vanilla convention, but that's wrong for the visual driving
// direction. Flip once more so the parked vehicle faces the way it
// was visually driven.
if (m_active) {
p_actor->TurnAround();
}
// Exiting a vehicle: reinitialize for the walking character. // Exiting a vehicle: reinitialize for the walking character.
m_animator.ClearRideAnimation(); m_animator.ClearRideAnimation();
m_animator.ClearAll(); m_animator.ClearAll();
@ -212,20 +188,14 @@ void ThirdPersonCamera::OnActorExit(IslePathActor* p_actor)
void ThirdPersonCamera::OnCamAnimEnd(LegoPathActor* p_actor) void ThirdPersonCamera::OnCamAnimEnd(LegoPathActor* p_actor)
{ {
m_pendingWorldTransition = false;
if (!m_active) { if (!m_active) {
return; return;
} }
// FUN_1004b6d0's PlaceActor set the ROI with standard direction // Cam anim end placed the actor via PlaceActor (forward-z).
// (z = visual forward). The 3rd person camera needs backward-z. // Restore the orbit camera.
// Flip the ROI direction, then re-setup the camera.
// Flip the native ROI (not the display clone) since Tick() syncs the
// clone's transform from it.
LegoROI* roi = p_actor->GetROI();
if (roi) {
FlipROIDirection(roi);
}
SetupCamera(p_actor); SetupCamera(p_actor);
} }
@ -239,8 +209,23 @@ void ThirdPersonCamera::Tick(float p_deltaTime)
return; return;
} }
// Update orbit camera position each frame so it tracks the player // After a world transition, PlaceActor has now run and set the ROI to
ApplyOrbitCamera(); // the correct position. Clear the flag so subsequent OnActorEnter calls
// work normally. ApplyOrbitCamera below handles the camera setup.
if (m_pendingWorldTransition) {
m_pendingWorldTransition = false;
}
// While a cam anim locks the player (actor state c_disabled), calling
// ApplyOrbitCamera would fight the cam anim each frame and, critically,
// if the cam anim is interrupted (space bar), its end handler reads
// ViewROI position to place the actor. Our orbit camera position
// (elevated, behind player) would cause the actor to be placed in the
// air. Once the player is released (first interruption resets actor
// state to c_initial), the orbit camera resumes immediately.
if (!UserActor() || UserActor()->GetActorState() != LegoPathActor::c_disabled) {
ApplyOrbitCamera();
}
m_animator.UpdateNameBubble(m_playerROI); m_animator.UpdateNameBubble(m_playerROI);
@ -262,8 +247,10 @@ void ThirdPersonCamera::Tick(float p_deltaTime)
m_animator.SetAnimTime(m_animator.GetAnimTime() + p_deltaTime * 2000.0f); m_animator.SetAnimTime(m_animator.GetAnimTime() + p_deltaTime * 2000.0f);
} }
// Use vehicle actor's transform as base. // Use vehicle actor's transform as base, flipped to backward-z
// so the character mesh (which faces -z) renders correctly.
MxMatrix transform(actor->GetROI()->GetLocal2World()); MxMatrix transform(actor->GetROI()->GetLocal2World());
FlipMatrixDirection(transform);
// Position character ROI at the vehicle for bone rendering. // Position character ROI at the vehicle for bone rendering.
m_playerROI->WrappedSetLocal2WorldWithWorldDataUpdate(transform); m_playerROI->WrappedSetLocal2WorldWithWorldDataUpdate(transform);
@ -298,6 +285,8 @@ void ThirdPersonCamera::Tick(float p_deltaTime)
LegoROI* nativeROI = userActor->GetROI(); LegoROI* nativeROI = userActor->GetROI();
if (nativeROI) { if (nativeROI) {
MxMatrix mat(nativeROI->GetLocal2World()); MxMatrix mat(nativeROI->GetLocal2World());
// Native ROI uses forward-z; flip to backward-z for the mesh.
FlipMatrixDirection(mat);
m_displayROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat); m_displayROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
VideoManager()->Get3DManager()->Moved(*m_displayROI); VideoManager()->Get3DManager()->Moved(*m_displayROI);
} }
@ -347,13 +336,26 @@ void ThirdPersonCamera::StopClickAnimation()
void ThirdPersonCamera::OnWorldEnabled(LegoWorld* p_world) void ThirdPersonCamera::OnWorldEnabled(LegoWorld* p_world)
{ {
if (!m_enabled || !p_world) { if (!p_world) {
return;
}
if (!m_enabled) {
return; return;
} }
// Animation presenters may have been recreated. // Animation presenters may have been recreated.
m_animator.ClearAll(); m_animator.ClearAll();
// Reset orbit to default position behind the character.
ResetOrbitState();
// ReinitForCharacter runs BEFORE SpawnPlayer/PlaceActor, so the ROI
// position is stale. Set the flag so OnActorEnter and ReinitForCharacter
// defer camera setup. The first Tick after PlaceActor clears it, and
// ApplyOrbitCamera handles the camera naturally.
m_pendingWorldTransition = true;
ReinitForCharacter(); ReinitForCharacter();
} }
@ -362,9 +364,8 @@ void ThirdPersonCamera::OnWorldDisabled(LegoWorld* p_world)
if (!p_world) { if (!p_world) {
return; return;
} }
m_active = false; m_active = false;
m_roiUnflipped = false; m_pendingWorldTransition = false;
m_playerROI = nullptr; m_playerROI = nullptr;
DestroyNameBubble(); DestroyNameBubble();
DestroyDisplayClone(); DestroyDisplayClone();
@ -471,7 +472,8 @@ void ThirdPersonCamera::SetNameBubbleVisible(bool p_visible)
void ThirdPersonCamera::ComputeOrbitVectors(Mx3DPointFloat& p_at, Mx3DPointFloat& p_dir, Mx3DPointFloat& p_up) const void ThirdPersonCamera::ComputeOrbitVectors(Mx3DPointFloat& p_at, Mx3DPointFloat& p_dir, Mx3DPointFloat& p_up) const
{ {
// Convert spherical coordinates to camera offset in entity-local space. // Convert spherical coordinates to camera offset in entity-local space.
// Entity local Z+ is "behind" (after TurnAround), which is where yaw=0 points. // 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 cosP = SDL_cosf(m_orbitPitch);
float sinP = SDL_sinf(m_orbitPitch); float sinP = SDL_sinf(m_orbitPitch);
float sinY = SDL_sinf(m_orbitYaw); float sinY = SDL_sinf(m_orbitYaw);
@ -480,13 +482,11 @@ void ThirdPersonCamera::ComputeOrbitVectors(Mx3DPointFloat& p_at, Mx3DPointFloat
p_at = Mx3DPointFloat( p_at = Mx3DPointFloat(
m_orbitDistance * sinY * cosP, m_orbitDistance * sinY * cosP,
ORBIT_TARGET_HEIGHT + m_orbitDistance * sinP, ORBIT_TARGET_HEIGHT + m_orbitDistance * sinP,
m_orbitDistance * cosY * cosP -m_orbitDistance * cosY * cosP
); );
// Direction points from camera toward the pivot. Since the camera sits on // Direction points from camera toward the pivot (the character).
// a sphere of radius m_orbitDistance, the unit direction is just the p_dir = Mx3DPointFloat(-sinY * cosP, -sinP, cosY * cosP);
// negated spherical unit vector.
p_dir = Mx3DPointFloat(-sinY * cosP, -sinP, -cosY * cosP);
p_up = Mx3DPointFloat(0.0f, 1.0f, 0.0f); p_up = Mx3DPointFloat(0.0f, 1.0f, 0.0f);
} }
@ -543,7 +543,7 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event)
case SDL_EVENT_MOUSE_MOTION: case SDL_EVENT_MOUSE_MOTION:
if (p_event->motion.state & SDL_BUTTON_RMASK) { if (p_event->motion.state & SDL_BUTTON_RMASK) {
m_orbitYaw += p_event->motion.xrel * 0.005f; m_orbitYaw -= p_event->motion.xrel * 0.005f;
m_orbitPitch += p_event->motion.yrel * 0.005f; m_orbitPitch += p_event->motion.yrel * 0.005f;
ClampPitch(); ClampPitch();
} }
@ -601,7 +601,7 @@ void ThirdPersonCamera::HandleSDLEvent(SDL_Event* p_event)
// Two-finger drag for orbit // Two-finger drag for orbit
float moveX = m_touch.x[idx] - oldX; float moveX = m_touch.x[idx] - oldX;
float moveY = m_touch.y[idx] - oldY; float moveY = m_touch.y[idx] - oldY;
m_orbitYaw -= moveX * 2.0f; m_orbitYaw += moveX * 2.0f;
m_orbitPitch += moveY * 2.0f; m_orbitPitch += moveY * 2.0f;
ClampPitch(); ClampPitch();
} }
@ -655,6 +655,7 @@ void ThirdPersonCamera::ReinitForCharacter()
// Large vehicles and helicopter: stay first-person // Large vehicles and helicopter: stay first-person
if (vehicleType == VEHICLE_HELICOPTER || (vehicleType != VEHICLE_NONE && IsLargeVehicle(vehicleType))) { if (vehicleType == VEHICLE_HELICOPTER || (vehicleType != VEHICLE_NONE && IsLargeVehicle(vehicleType))) {
m_active = false; m_active = false;
m_pendingWorldTransition = false;
return; return;
} }
@ -671,18 +672,7 @@ void ThirdPersonCamera::ReinitForCharacter()
return; return;
} }
// Undo TurnAround on the vehicle ROI so the backward-z convention m_pendingWorldTransition = false;
// is restored. This handles both entering from 1st-person (Enter's
// TurnAround still in effect) and the Disable→Enable cycle (Disable
// re-applied TurnAround). In both cases ROI z currently matches
// the visual forward and needs to be flipped back.
{
LegoROI* vehicleROI = userActor->GetROI();
if (vehicleROI) {
FlipROIDirection(vehicleROI);
}
m_roiUnflipped = false;
}
VideoManager()->Get3DManager()->Remove(*m_playerROI); VideoManager()->Get3DManager()->Remove(*m_playerROI);
VideoManager()->Get3DManager()->Add(*m_playerROI); VideoManager()->Get3DManager()->Add(*m_playerROI);
@ -700,15 +690,6 @@ void ThirdPersonCamera::ReinitForCharacter()
return; return;
} }
// Re-apply TurnAround if we undid it in Disable().
// Only set the local matrix here; the subsequent Add() will propagate world data.
// Flip the native ROI (not the display clone) since Tick() syncs the
// clone's transform from it.
if (m_roiUnflipped) {
FlipROIDirection(roi);
m_roiUnflipped = false;
}
m_playerROI->SetVisibility(TRUE); m_playerROI->SetVisibility(TRUE);
// Ensure the ROI is in the 3D manager. // Ensure the ROI is in the 3D manager.
@ -720,6 +701,12 @@ void ThirdPersonCamera::ReinitForCharacter()
m_active = true; m_active = true;
m_animator.ApplyIdleFrame0(m_playerROI); m_animator.ApplyIdleFrame0(m_playerROI);
SetupCamera(userActor);
// During a world transition, PlaceActor hasn't run yet — the ROI is at
// a stale position. Defer camera setup; ApplyOrbitCamera in the first
// Tick after PlaceActor handles it.
if (!m_pendingWorldTransition) {
SetupCamera(userActor);
}
CreateNameBubble(); CreateNameBubble();
} }