isle-portable/extensions/src/multiplayer/thirdpersoncamera.cpp
foxtacles e0a1ac781f
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>
2026-03-08 06:23:57 +01:00

944 lines
25 KiB
C++

#include "extensions/multiplayer/thirdpersoncamera.h"
#include "3dmanager/lego3dmanager.h"
#include "anim/legoanim.h"
#include "extensions/multiplayer/charactercloner.h"
#include "extensions/multiplayer/charactercustomizer.h"
#include "islepathactor.h"
#include "legogamestate.h"
#include "legoanimpresenter.h"
#include "legocameracontroller.h"
#include "legocharactermanager.h"
#include "legovideomanager.h"
#include "legoworld.h"
#include "misc.h"
#include "misc/legotree.h"
#include "mxgeometry/mxgeometry3d.h"
#include "mxgeometry/mxmatrix.h"
#include "realtime/realtime.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
#include <cmath>
using namespace Multiplayer;
// Flip the ROI's z-axis direction in place (same operation as IslePathActor::TurnAround).
static void FlipROIDirection(LegoROI* p_roi)
{
MxMatrix transform(p_roi->GetLocal2World());
Vector3 right(transform[0]);
Vector3 up(transform[1]);
Vector3 direction(transform[2]);
direction *= -1.0f;
right.EqualsCross(up, direction);
p_roi->SetLocal2World(transform);
p_roi->WrappedUpdateWorldData();
}
ThirdPersonCamera::ThirdPersonCamera()
: m_enabled(false), m_active(false), m_roiUnflipped(false), m_playerROI(nullptr),
m_displayActorIndex(DISPLAY_ACTOR_NONE), m_displayROI(nullptr), m_walkAnimId(0), m_idleAnimId(0),
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_orbitYaw(DEFAULT_ORBIT_YAW),
m_orbitPitch(DEFAULT_ORBIT_PITCH), m_orbitDistance(DEFAULT_ORBIT_DISTANCE), m_touch{}
{
SDL_memset(m_displayUniqueName, 0, sizeof(m_displayUniqueName));
}
void ThirdPersonCamera::Enable()
{
m_enabled = true;
ReinitForCharacter();
}
void ThirdPersonCamera::Disable()
{
m_enabled = false;
if (m_active && m_playerROI) {
LegoPathActor* userActor = UserActor();
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).
// For walking characters the target is m_playerROI; for vehicles it
// is the vehicle actor's ROI (UserActor() returns the vehicle).
// When a display actor override is active, flip the native ROI (not the
// display clone) since TransformPointOfView uses it for the 1st-person camera.
LegoROI* turnAroundROI = (m_currentVehicleType == VEHICLE_NONE && !HasDisplayOverride())
? m_playerROI
: (userActor ? userActor->GetROI() : nullptr);
if (turnAroundROI) {
FlipROIDirection(turnAroundROI);
m_roiUnflipped = true;
}
m_playerROI->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Remove(*m_playerROI);
// Restore vanilla 1st-person camera (eye-height offset, same as ResetWorldTransform).
if (userActor && world && world->GetCameraController()) {
world->GetCameraController()->SetWorldTransform(
Mx3DPointFloat(0.0F, 1.25F, 0.0F),
Mx3DPointFloat(0.0F, 0.0F, 1.0F),
Mx3DPointFloat(0.0F, 1.0F, 0.0F)
);
userActor->TransformPointOfView();
}
}
m_active = false;
DestroyDisplayClone();
ClearRideAnimation();
m_animCacheMap.clear();
ClearAnimCaches();
ResetOrbitState();
}
void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor)
{
LegoPathActor* userActor = UserActor();
if (static_cast<LegoPathActor*>(p_actor) != userActor) {
return;
}
// Always track vehicle type so OnActorExit can handle exits
// even if Enable() was called after entering the vehicle.
m_currentVehicleType = DetectVehicleType(userActor);
// Enter() calls TurnAround(), so any previous undo is superseded.
m_roiUnflipped = false;
if (!m_enabled) {
return;
}
LegoROI* newROI = userActor->GetROI();
if (!newROI) {
return;
}
if (m_currentVehicleType != VEHICLE_NONE) {
// Large vehicles and helicopter: stay first-person.
if (IsLargeVehicle(m_currentVehicleType) || m_currentVehicleType == VEHICLE_HELICOPTER) {
// Hide walking character ROI (Enter doesn't call Exit on it).
if (m_playerROI) {
m_playerROI->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Remove(*m_playerROI);
}
m_active = false;
return;
}
// Small vehicle: need the character ROI for ride animations.
if (!m_playerROI) {
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;
SetupCamera(userActor);
BuildRideAnimation(m_currentVehicleType);
return;
}
// Non-vehicle (walking character) entry — Enter() already called TurnAround.
if (IsValidDisplayActorIndex(m_displayActorIndex)) {
newROI->SetVisibility(FALSE);
if (!EnsureDisplayROI()) {
return;
}
}
else {
m_playerROI = newROI;
}
m_roiUnflipped = false;
m_active = true;
m_playerROI->SetVisibility(TRUE);
// Re-add ROI so it renders in third-person (SpawnPlayer removes it).
VideoManager()->Get3DManager()->Remove(*m_playerROI);
VideoManager()->Get3DManager()->Add(*m_playerROI);
// Build animation caches
m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]);
m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]);
// Reset animation state
m_animTime = 0.0f;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
m_wasMoving = false;
m_emoteActive = false;
ApplyIdleFrame0();
SetupCamera(userActor);
}
void ThirdPersonCamera::OnActorExit(IslePathActor* p_actor)
{
if (!m_enabled) {
return;
}
// For vehicle exit, p_actor is the vehicle, not UserActor —
// check m_currentVehicleType instead.
if (m_currentVehicleType != 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.
ClearRideAnimation();
ClearAnimCaches();
m_animCacheMap.clear();
ReinitForCharacter();
}
else if (m_active && static_cast<LegoPathActor*>(p_actor) == UserActor()) {
// Exiting on foot: full teardown.
if (m_playerROI) {
m_playerROI->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Remove(*m_playerROI);
}
ClearRideAnimation();
ClearAnimCaches();
m_currentVehicleType = VEHICLE_NONE;
m_playerROI = nullptr;
m_active = false;
}
}
void ThirdPersonCamera::OnCamAnimEnd(LegoPathActor* p_actor)
{
if (!m_active) {
return;
}
// FUN_1004b6d0's PlaceActor set the ROI with standard direction
// (z = visual forward). The 3rd person camera needs backward-z.
// Flip the ROI direction, then re-setup the camera.
// When a display actor override is active, flip the native ROI (not the
// display clone) since Tick() syncs the clone's transform from it.
LegoROI* roi = (m_currentVehicleType == VEHICLE_NONE && !HasDisplayOverride()) ? m_playerROI : p_actor->GetROI();
if (roi) {
FlipROIDirection(roi);
}
SetupCamera(p_actor);
}
void ThirdPersonCamera::Tick(float p_deltaTime)
{
if (!m_active) {
return;
}
if (!m_playerROI) {
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();
if (m_rideAnim && m_rideRoiMap) {
LegoPathActor* actor = UserActor();
if (!actor || !actor->GetROI()) {
return;
}
// Force visibility of ride ROI map entries
AnimUtils::EnsureROIMapVisibility(m_rideRoiMap, m_rideRoiMapSize);
// Only advance animation time when actually moving
float speed = actor->GetWorldSpeed();
if (fabsf(speed) > 0.01f) {
m_animTime += p_deltaTime * 2000.0f;
}
// Use vehicle actor's transform as base.
MxMatrix transform(actor->GetROI()->GetLocal2World());
// Position character ROI at the vehicle for bone rendering.
m_playerROI->WrappedSetLocal2WorldWithWorldDataUpdate(transform);
m_playerROI->SetVisibility(TRUE);
float duration = (float) m_rideAnim->GetDuration();
if (duration > 0.0f) {
float timeInCycle = m_animTime - duration * floorf(m_animTime / duration);
LegoTreeNode* root = m_rideAnim->GetRoot();
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
LegoROI::ApplyAnimationTransformation(
root->GetChild(i),
transform,
(LegoTime) timeInCycle,
m_rideRoiMap
);
}
}
}
return;
}
LegoPathActor* userActor = UserActor();
if (!userActor) {
return;
}
// Sync display clone position from native ROI
if (m_displayROI && m_displayROI == m_playerROI) {
LegoROI* nativeROI = userActor->GetROI();
if (nativeROI) {
MxMatrix mat(nativeROI->GetLocal2World());
m_displayROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
VideoManager()->Get3DManager()->Moved(*m_displayROI);
}
}
// Determine the active walk animation and its ROI map
LegoAnim* walkAnim = nullptr;
LegoROI** walkRoiMap = nullptr;
MxU32 walkRoiMapSize = 0;
if (m_walkAnimCache && m_walkAnimCache->anim && m_walkAnimCache->roiMap) {
walkAnim = m_walkAnimCache->anim;
walkRoiMap = m_walkAnimCache->roiMap;
walkRoiMapSize = m_walkAnimCache->roiMapSize;
}
// Ensure visibility of all mapped ROIs
if (walkRoiMap) {
AnimUtils::EnsureROIMapVisibility(walkRoiMap, walkRoiMapSize);
}
if (m_idleAnimCache && m_idleAnimCache->roiMap) {
AnimUtils::EnsureROIMapVisibility(m_idleAnimCache->roiMap, m_idleAnimCache->roiMapSize);
}
float speed = userActor->GetWorldSpeed();
bool isMoving = fabsf(speed) > 0.01f;
// Movement interrupts click animations and emotes
if (isMoving) {
StopClickAnimation();
if (m_emoteActive) {
m_emoteActive = false;
m_emoteAnimCache = nullptr;
}
}
if (isMoving) {
if (!walkAnim || !walkRoiMap) {
return;
}
m_animTime += p_deltaTime * 2000.0f;
float duration = (float) walkAnim->GetDuration();
if (duration > 0.0f) {
float timeInCycle = m_animTime - duration * floorf(m_animTime / duration);
MxMatrix transform(m_playerROI->GetLocal2World());
LegoTreeNode* root = walkAnim->GetRoot();
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) timeInCycle, walkRoiMap);
}
}
m_wasMoving = true;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
}
else if (m_emoteActive && m_emoteAnimCache && m_emoteAnimCache->anim && m_emoteAnimCache->roiMap) {
m_emoteTime += p_deltaTime * 1000.0f;
if (m_emoteTime >= m_emoteDuration) {
m_emoteActive = false;
m_emoteAnimCache = nullptr;
m_wasMoving = false;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
}
else {
// Use saved clean transform to prevent scale accumulation.
MxMatrix transform(m_emoteParentTransform);
LegoTreeNode* root = m_emoteAnimCache->anim->GetRoot();
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
LegoROI::ApplyAnimationTransformation(
root->GetChild(i),
transform,
(LegoTime) m_emoteTime,
m_emoteAnimCache->roiMap
);
}
// Restore player ROI transform (animation root overwrote it).
m_playerROI->WrappedSetLocal2WorldWithWorldDataUpdate(m_emoteParentTransform);
}
}
else if (m_idleAnimCache && m_idleAnimCache->anim && m_idleAnimCache->roiMap) {
if (m_wasMoving) {
m_wasMoving = false;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
}
m_idleTime += p_deltaTime;
if (m_idleTime >= 2.5f) {
m_idleAnimTime += p_deltaTime * 1000.0f;
}
float duration = (float) m_idleAnimCache->anim->GetDuration();
if (duration > 0.0f) {
float timeInCycle = m_idleAnimTime - duration * floorf(m_idleAnimTime / duration);
MxMatrix transform(m_playerROI->GetLocal2World());
LegoTreeNode* root = m_idleAnimCache->anim->GetRoot();
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
LegoROI::ApplyAnimationTransformation(
root->GetChild(i),
transform,
(LegoTime) timeInCycle,
m_idleAnimCache->roiMap
);
}
}
}
}
void ThirdPersonCamera::SetWalkAnimId(uint8_t p_walkAnimId)
{
if (p_walkAnimId >= g_walkAnimCount) {
return;
}
if (p_walkAnimId != m_walkAnimId) {
m_walkAnimId = p_walkAnimId;
if (m_active) {
m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]);
}
}
}
void ThirdPersonCamera::SetIdleAnimId(uint8_t p_idleAnimId)
{
if (p_idleAnimId >= g_idleAnimCount) {
return;
}
if (p_idleAnimId != m_idleAnimId) {
m_idleAnimId = p_idleAnimId;
if (m_active) {
m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]);
}
}
}
void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId)
{
if (p_emoteId >= g_emoteAnimCount || !m_active) {
return;
}
LegoPathActor* userActor = UserActor();
if (!userActor || fabsf(userActor->GetWorldSpeed()) > 0.01f) {
return;
}
AnimCache* cache = GetOrBuildAnimCache(g_emoteAnimNames[p_emoteId]);
if (!cache || !cache->anim) {
return;
}
StopClickAnimation();
m_emoteAnimCache = cache;
m_emoteTime = 0.0f;
m_emoteDuration = (float) cache->anim->GetDuration();
m_emoteActive = true;
// Save clean transform to prevent scale accumulation during emote
// (the animation root writes scaled values into the ROI each frame).
m_emoteParentTransform = m_playerROI->GetLocal2World();
}
void ThirdPersonCamera::ApplyCustomizeChange(uint8_t p_changeType, uint8_t p_partIndex)
{
uint8_t actorInfoIndex = CharacterCustomizer::ResolveActorInfoIndex(
m_displayActorIndex,
GameState() ? GameState()->GetActorId() : 0
);
CharacterCustomizer::ApplyChange(m_displayROI, actorInfoIndex, m_customizeState, p_changeType, p_partIndex);
}
void ThirdPersonCamera::StopClickAnimation()
{
if (m_clickAnimObjectId != 0) {
CharacterCustomizer::StopClickAnimation(m_clickAnimObjectId);
m_clickAnimObjectId = 0;
}
}
void ThirdPersonCamera::OnWorldEnabled(LegoWorld* p_world)
{
if (!m_enabled || !p_world) {
return;
}
// Animation presenters may have been recreated.
m_animCacheMap.clear();
ClearAnimCaches();
ReinitForCharacter();
}
void ThirdPersonCamera::OnWorldDisabled(LegoWorld* p_world)
{
if (!p_world) {
return;
}
m_active = false;
m_roiUnflipped = false;
m_playerROI = nullptr;
DestroyDisplayClone();
ClearRideAnimation();
m_animCacheMap.clear();
ClearAnimCaches();
}
ThirdPersonCamera::AnimCache* ThirdPersonCamera::GetOrBuildAnimCache(const char* p_animName)
{
return AnimUtils::GetOrBuildAnimCache(m_animCacheMap, m_playerROI, p_animName);
}
void ThirdPersonCamera::ClearAnimCaches()
{
m_walkAnimCache = nullptr;
m_idleAnimCache = nullptr;
m_emoteAnimCache = nullptr;
m_emoteActive = false;
}
void ThirdPersonCamera::SetupCamera(LegoPathActor* p_actor)
{
LegoWorld* world = CurrentWorld();
if (!world || !world->GetCameraController()) {
return;
}
Mx3DPointFloat at, dir, up;
ComputeOrbitVectors(at, dir, up);
world->GetCameraController()->SetWorldTransform(at, dir, up);
p_actor->TransformPointOfView();
}
void ThirdPersonCamera::BuildRideAnimation(int8_t p_vehicleType)
{
if (p_vehicleType < 0 || p_vehicleType >= VEHICLE_COUNT) {
return;
}
const char* rideAnimName = g_rideAnimNames[p_vehicleType];
const char* vehicleVariantName = g_rideVehicleROINames[p_vehicleType];
if (!rideAnimName || !vehicleVariantName) {
return;
}
LegoWorld* world = CurrentWorld();
if (!world) {
return;
}
MxCore* presenter = world->Find("LegoAnimPresenter", rideAnimName);
if (!presenter) {
return;
}
m_rideAnim = static_cast<LegoAnimPresenter*>(presenter)->GetAnimation();
if (!m_rideAnim) {
return;
}
// Create variant ROI, rename to match animation tree.
const char* baseName = g_vehicleROINames[p_vehicleType];
m_rideVehicleROI = CharacterManager()->CreateAutoROI("tp_vehicle", baseName, FALSE);
if (m_rideVehicleROI) {
m_rideVehicleROI->SetName(vehicleVariantName);
}
AnimUtils::BuildROIMap(m_rideAnim, m_playerROI, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize);
m_animTime = 0.0f;
}
void ThirdPersonCamera::SetDisplayActorIndex(uint8_t p_displayActorIndex)
{
if (m_displayActorIndex != p_displayActorIndex) {
m_customizeState.InitFromActorInfo(p_displayActorIndex);
}
m_displayActorIndex = p_displayActorIndex;
}
bool ThirdPersonCamera::EnsureDisplayROI()
{
if (!IsValidDisplayActorIndex(m_displayActorIndex)) {
return false;
}
if (!m_displayROI) {
CreateDisplayClone();
}
if (!m_displayROI) {
return false;
}
m_playerROI = m_displayROI;
return true;
}
void ThirdPersonCamera::CreateDisplayClone()
{
if (!IsValidDisplayActorIndex(m_displayActorIndex)) {
return;
}
LegoCharacterManager* charMgr = CharacterManager();
const char* actorName = charMgr->GetActorName(m_displayActorIndex);
if (!actorName) {
return;
}
SDL_snprintf(m_displayUniqueName, sizeof(m_displayUniqueName), "tp_display");
m_displayROI = CharacterCloner::Clone(charMgr, m_displayUniqueName, actorName);
if (m_displayROI) {
// Reapply existing customize state to the new clone (preserves state across world transitions).
// The state is only reset to defaults when the display actor index changes (SetDisplayActorIndex).
CharacterCustomizer::ApplyFullState(m_displayROI, m_displayActorIndex, m_customizeState);
}
}
void ThirdPersonCamera::DestroyDisplayClone()
{
StopClickAnimation();
if (m_displayROI) {
if (m_playerROI == m_displayROI) {
m_playerROI = nullptr;
}
VideoManager()->Get3DManager()->Remove(*m_displayROI);
CharacterManager()->ReleaseActor(m_displayUniqueName);
m_displayROI = nullptr;
}
}
void ThirdPersonCamera::ClearRideAnimation()
{
if (m_rideRoiMap) {
delete[] m_rideRoiMap;
m_rideRoiMap = nullptr;
m_rideRoiMapSize = 0;
}
if (m_rideVehicleROI) {
VideoManager()->Get3DManager()->Remove(*m_rideVehicleROI);
CharacterManager()->ReleaseAutoROI(m_rideVehicleROI);
m_rideVehicleROI = nullptr;
}
m_rideAnim = nullptr;
m_currentVehicleType = VEHICLE_NONE;
}
void ThirdPersonCamera::ApplyIdleFrame0()
{
if (!m_playerROI || !m_idleAnimCache || !m_idleAnimCache->anim || !m_idleAnimCache->roiMap) {
return;
}
MxMatrix transform(m_playerROI->GetLocal2World());
LegoTreeNode* root = m_idleAnimCache->anim->GetRoot();
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
LegoROI::ApplyAnimationTransformation(root->GetChild(i), transform, (LegoTime) 0.0f, m_idleAnimCache->roiMap);
}
}
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();
if (!userActor) {
m_active = false;
return;
}
LegoROI* roi = userActor->GetROI();
if (!roi) {
m_active = false;
return;
}
int8_t vehicleType = DetectVehicleType(userActor);
// Large vehicles and helicopter: stay first-person
if (vehicleType == VEHICLE_HELICOPTER || (vehicleType != VEHICLE_NONE && IsLargeVehicle(vehicleType))) {
m_active = false;
return;
}
m_currentVehicleType = vehicleType;
if (vehicleType != VEHICLE_NONE) {
if (IsValidDisplayActorIndex(m_displayActorIndex) && !EnsureDisplayROI()) {
m_active = false;
return;
}
if (!m_playerROI) {
m_active = false;
return;
}
// Undo TurnAround on the vehicle ROI so the backward-z convention
// 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()->Add(*m_playerROI);
m_active = true;
SetupCamera(userActor);
BuildRideAnimation(vehicleType);
return;
}
// Reinitializing for walking character
if (IsValidDisplayActorIndex(m_displayActorIndex)) {
roi->SetVisibility(FALSE);
if (!EnsureDisplayROI()) {
m_active = false;
return;
}
}
else {
m_playerROI = roi;
}
// Re-apply TurnAround if we undid it in Disable().
// Only set the local matrix here; the subsequent Add() will propagate world data.
// When a display actor override is active, flip the native ROI (not the
// display clone) since Tick() syncs the clone's transform from it.
if (m_roiUnflipped) {
FlipROIDirection(HasDisplayOverride() ? roi : m_playerROI);
m_roiUnflipped = false;
}
m_playerROI->SetVisibility(TRUE);
// Ensure the ROI is in the 3D manager.
VideoManager()->Get3DManager()->Remove(*m_playerROI);
VideoManager()->Get3DManager()->Add(*m_playerROI);
m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]);
m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]);
m_animTime = 0.0f;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
m_wasMoving = false;
m_emoteActive = false;
m_active = true;
ApplyIdleFrame0();
SetupCamera(userActor);
}