3rd person camera (#3)

* Add feasibility plan for reusing multiplayer animation system for third-person camera

Evaluates reusing the multiplayer extension's RemotePlayer animation system
(BuildROIMap, AssignROIIndices, ApplyAnimationTransformation) for the local
player to enable a third-person camera mode. Conclusion: feasible with only
3 single-line extension hooks added to core game code.

https://claude.ai/code/session_01NC3zdQZ4nqEcYjyvStqcdD

* WIP: Third-person camera with animation reuse and movement fix

* Fix third-person camera bugs: vehicles, remote facing, emote distortion (#2)

- Fix spawn pose and building re-entry by applying idle frame 0 and
  reinitializing on world enable
- Handle vehicle transitions: ride animations for small vehicles,
  first-person fallback for large vehicles and helicopter
- Keep vehicle dashboards visible for exit controls
- Disable third-person camera for large vehicles, fix ROI cleanup
- Move HandleActorExit hook to end of Exit() for immediate reinit
- Fix remote player facing 180 degrees wrong by negating direction
  in BroadcastLocalState when third-person camera is active
- Fix Hat Tip emote distortion from compounding transform scale by
  saving clean parent transform at emote start and restoring after
  each frame's animation application

* DRY cleanup for third-person camera branch

- Extract shared DetectVehicleType() to protocol.h/cpp (was duplicated
  in ThirdPersonCamera and NetworkManager)
- Remove no-op HandlePostApplyTransform hook chain (called every frame
  for every LegoPathActor but did nothing)
- Add ThirdPersonCamera::ClearAnimCaches() helper (pattern repeated 5x)
- Add AnimUtils::EnsureROIMapVisibility() inline helper (loop repeated
  5x across ThirdPersonCamera and RemotePlayer)
- Remove redundant static_cast in multiplayer.cpp (UserActor() already
  returns LegoPathActor*)
- Delete THIRD_PERSON_CAMERA_ANIMATION_REUSE_PLAN.md development artifact
This commit is contained in:
foxtacles 2026-03-06 16:38:45 -08:00 committed by GitHub
parent 0997610bad
commit 37b328a595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1085 additions and 244 deletions

View File

@ -532,9 +532,12 @@ if (ISLE_EXTENSIONS)
extensions/src/siloader.cpp extensions/src/siloader.cpp
extensions/src/textureloader.cpp extensions/src/textureloader.cpp
extensions/src/multiplayer.cpp extensions/src/multiplayer.cpp
extensions/src/multiplayer/animutils.cpp
extensions/src/multiplayer/charactercloner.cpp extensions/src/multiplayer/charactercloner.cpp
extensions/src/multiplayer/networkmanager.cpp extensions/src/multiplayer/networkmanager.cpp
extensions/src/multiplayer/protocol.cpp
extensions/src/multiplayer/remoteplayer.cpp extensions/src/multiplayer/remoteplayer.cpp
extensions/src/multiplayer/thirdpersoncamera.cpp
extensions/src/multiplayer/worldstatesync.cpp extensions/src/multiplayer/worldstatesync.cpp
) )
if(EMSCRIPTEN) if(EMSCRIPTEN)

View File

@ -1,6 +1,7 @@
#include "islepathactor.h" #include "islepathactor.h"
#include "3dmanager/lego3dmanager.h" #include "3dmanager/lego3dmanager.h"
#include "extensions/multiplayer.h"
#include "isle_actions.h" #include "isle_actions.h"
#include "jukebox_actions.h" #include "jukebox_actions.h"
#include "legoanimationmanager.h" #include "legoanimationmanager.h"
@ -16,6 +17,8 @@
#include "scripts.h" #include "scripts.h"
#include "viewmanager/viewmanager.h" #include "viewmanager/viewmanager.h"
using namespace Extensions;
DECOMP_SIZE_ASSERT(IslePathActor, 0x160) DECOMP_SIZE_ASSERT(IslePathActor, 0x160)
DECOMP_SIZE_ASSERT(IslePathActor::SpawnLocation, 0x38) DECOMP_SIZE_ASSERT(IslePathActor::SpawnLocation, 0x38)
@ -95,6 +98,8 @@ void IslePathActor::Enter()
TurnAround(); TurnAround();
TransformPointOfView(); TransformPointOfView();
} }
Extension<MultiplayerExt>::Call(HandleActorEnter, this);
} }
// FUNCTION: LEGO1 0x1001a3f0 // FUNCTION: LEGO1 0x1001a3f0
@ -154,6 +159,8 @@ void IslePathActor::Exit()
TurnAround(); TurnAround();
TransformPointOfView(); TransformPointOfView();
ResetViewVelocity(); ResetViewVelocity();
Extension<MultiplayerExt>::Call(HandleActorExit, this);
} }
// GLOBAL: LEGO1 0x10102b28 // GLOBAL: LEGO1 0x10102b28

View File

@ -1,6 +1,7 @@
#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"
@ -20,6 +21,8 @@
#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)
@ -262,6 +265,11 @@ 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;
@ -321,6 +329,10 @@ 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

@ -6,6 +6,7 @@
#include <map> #include <map>
#include <optional> #include <optional>
#include <string> #include <string>
#include <type_traits>
namespace Extensions namespace Extensions
{ {
@ -17,14 +18,26 @@ LEGO1_EXPORT void Enable(const char* p_key, std::map<std::string, std::string> p
template <typename T> template <typename T>
struct Extension { struct Extension {
template <typename Function, typename... Args> template <typename Function, typename... Args>
static auto Call(Function&& function, Args&&... args) -> std::optional<std::invoke_result_t<Function, Args...>> static auto Call(Function&& function, Args&&... args)
{ {
using result_t = std::invoke_result_t<Function, Args...>;
if constexpr (std::is_void_v<result_t>) {
#ifdef EXTENSIONS #ifdef EXTENSIONS
if (T::enabled) { if (T::enabled) {
return std::invoke(std::forward<Function>(function), std::forward<Args>(args)...); std::invoke(std::forward<Function>(function), std::forward<Args>(args)...);
} }
#endif #endif
return std::nullopt; }
else {
#ifdef EXTENSIONS
if (T::enabled) {
return std::optional<result_t>(
std::invoke(std::forward<Function>(function), std::forward<Args>(args)...)
);
}
#endif
return std::optional<result_t>(std::nullopt);
}
} }
}; };
}; // namespace Extensions }; // namespace Extensions

View File

@ -6,7 +6,9 @@
#include <map> #include <map>
#include <string> #include <string>
class IslePathActor;
class LegoEntity; class LegoEntity;
class LegoPathActor;
class LegoWorld; class LegoWorld;
namespace Multiplayer namespace Multiplayer
@ -34,6 +36,10 @@ class MultiplayerExt {
static std::string relayUrl; static std::string relayUrl;
static std::string room; static std::string room;
static void HandleActorEnter(IslePathActor* p_actor);
static void HandleActorExit(IslePathActor* p_actor);
static MxBool ShouldInvertMovement(LegoPathActor* p_actor);
// Returns true if the multiplayer connection was rejected (e.g. room full). // Returns true if the multiplayer connection was rejected (e.g. room full).
static MxBool CheckRejected(); static MxBool CheckRejected();
@ -51,10 +57,16 @@ LEGO1_EXPORT bool IsMultiplayerRejected();
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable; constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify; constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify;
constexpr auto HandleActorEnter = &MultiplayerExt::HandleActorEnter;
constexpr auto HandleActorExit = &MultiplayerExt::HandleActorExit;
constexpr auto ShouldInvertMovement = &MultiplayerExt::ShouldInvertMovement;
constexpr auto CheckRejected = &MultiplayerExt::CheckRejected; constexpr auto CheckRejected = &MultiplayerExt::CheckRejected;
#else #else
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr; constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr;
constexpr decltype(&MultiplayerExt::HandleActorEnter) HandleActorEnter = nullptr;
constexpr decltype(&MultiplayerExt::HandleActorExit) HandleActorExit = nullptr;
constexpr decltype(&MultiplayerExt::ShouldInvertMovement) ShouldInvertMovement = nullptr;
constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr; constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr;
#endif #endif

View File

@ -0,0 +1,82 @@
#pragma once
#include "mxtypes.h"
#include "roi/legoroi.h"
#include <map>
#include <string>
class LegoAnim;
namespace Multiplayer
{
namespace AnimUtils
{
// Cached ROI map entry for an animation
struct AnimCache {
LegoAnim* anim;
LegoROI** roiMap;
MxU32 roiMapSize;
AnimCache() : anim(nullptr), roiMap(nullptr), roiMapSize(0) {}
~AnimCache()
{
if (roiMap) {
delete[] roiMap;
}
}
AnimCache(const AnimCache&) = delete;
AnimCache& operator=(const AnimCache&) = delete;
AnimCache(AnimCache&& p_other) noexcept
: anim(p_other.anim), roiMap(p_other.roiMap), roiMapSize(p_other.roiMapSize)
{
p_other.roiMap = nullptr;
p_other.roiMapSize = 0;
p_other.anim = nullptr;
}
AnimCache& operator=(AnimCache&& p_other) noexcept
{
if (this != &p_other) {
if (roiMap) {
delete[] roiMap;
}
anim = p_other.anim;
roiMap = p_other.roiMap;
roiMapSize = p_other.roiMapSize;
p_other.roiMap = nullptr;
p_other.roiMapSize = 0;
p_other.anim = nullptr;
}
return *this;
}
};
void BuildROIMap(
LegoAnim* p_anim,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
LegoROI**& p_roiMap,
MxU32& p_roiMapSize
);
AnimCache* GetOrBuildAnimCache(
std::map<std::string, AnimCache>& p_cacheMap,
LegoROI* p_roi,
const char* p_animName
);
inline void EnsureROIMapVisibility(LegoROI** p_roiMap, MxU32 p_roiMapSize)
{
for (MxU32 i = 1; i < p_roiMapSize; i++) {
if (p_roiMap[i] != nullptr) {
p_roiMap[i]->SetVisibility(TRUE);
}
}
}
} // namespace AnimUtils
} // namespace Multiplayer

View File

@ -4,6 +4,7 @@
#include "extensions/multiplayer/platformcallbacks.h" #include "extensions/multiplayer/platformcallbacks.h"
#include "extensions/multiplayer/protocol.h" #include "extensions/multiplayer/protocol.h"
#include "extensions/multiplayer/remoteplayer.h" #include "extensions/multiplayer/remoteplayer.h"
#include "extensions/multiplayer/thirdpersoncamera.h"
#include "extensions/multiplayer/worldstatesync.h" #include "extensions/multiplayer/worldstatesync.h"
#include "mxcore.h" #include "mxcore.h"
#include "mxtypes.h" #include "mxtypes.h"
@ -49,6 +50,8 @@ class NetworkManager : public MxCore {
void OnWorldEnabled(LegoWorld* p_world); void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world);
ThirdPersonCamera& GetThirdPersonCamera() { return m_thirdPersonCamera; }
// Called from multiplayer extension when a plant/building entity is clicked. // Called from multiplayer extension when a plant/building entity is clicked.
// Returns TRUE if the mutation should be suppressed locally (non-host). // Returns TRUE if the mutation should be suppressed locally (non-host).
MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType); MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType);
@ -72,7 +75,6 @@ class NetworkManager : public MxCore {
void RemoveAllRemotePlayers(); void RemoveAllRemotePlayers();
void NotifyPlayerCountChanged(); void NotifyPlayerCountChanged();
int8_t DetectLocalVehicleType();
// Serialize and send a fixed-size message via the transport // Serialize and send a fixed-size message via the transport
template <typename T> template <typename T>
@ -81,6 +83,7 @@ class NetworkManager : public MxCore {
NetworkTransport* m_transport; NetworkTransport* m_transport;
PlatformCallbacks* m_callbacks; PlatformCallbacks* m_callbacks;
WorldStateSync m_worldSync; WorldStateSync m_worldSync;
ThirdPersonCamera m_thirdPersonCamera;
std::map<uint32_t, std::unique_ptr<RemotePlayer>> m_remotePlayers; std::map<uint32_t, std::unique_ptr<RemotePlayer>> m_remotePlayers;
uint32_t m_localPeerId; uint32_t m_localPeerId;

View File

@ -5,6 +5,8 @@
#include <cstdint> #include <cstdint>
#include <type_traits> #include <type_traits>
class LegoPathActor;
namespace Multiplayer namespace Multiplayer
{ {
@ -127,31 +129,25 @@ struct EmoteMsg {
#pragma pack(pop) #pragma pack(pop)
// Walk animation table: index -> CNs name // Animation and vehicle tables (defined in protocol.cpp)
static const char* const g_walkAnimNames[] = { extern const char* const g_walkAnimNames[];
"CNs001xx", // 0: Normal (default) extern const int g_walkAnimCount;
"CNs002xx", // 1: Joyful
"CNs003xx", // 2: Gloomy
"CNs005xx", // 3: Leaning
"CNs006xx", // 4: Scared
"CNs007xx", // 5: Hyper
};
static const int g_walkAnimCount = sizeof(g_walkAnimNames) / sizeof(g_walkAnimNames[0]);
// Idle animation table: index -> CNs name extern const char* const g_idleAnimNames[];
static const char* const g_idleAnimNames[] = { extern const int g_idleAnimCount;
"CNs008xx", // 0: Sway (default)
"CNs009xx", // 1: Groove
"CNs010xx", // 2: Excited
};
static const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]);
// Emote table: index -> CNs name extern const char* const g_emoteAnimNames[];
static const char* const g_emoteAnimNames[] = { extern const int g_emoteAnimCount;
"CNs011xx", // 0: Wave
"CNs012xx", // 1: Hat Tip extern const char* const g_vehicleROINames[VEHICLE_COUNT];
}; extern const char* const g_rideAnimNames[VEHICLE_COUNT];
static const int g_emoteAnimCount = sizeof(g_emoteAnimNames) / sizeof(g_emoteAnimNames[0]); extern const char* const g_rideVehicleROINames[VEHICLE_COUNT];
// Returns true if the vehicle type has no ride animation (model swap instead)
bool IsLargeVehicle(int8_t p_vehicleType);
// Detect the vehicle type of a given actor, or VEHICLE_NONE if not a vehicle
int8_t DetectVehicleType(LegoPathActor* p_actor);
// Validate actorId is a playable character (1-5, not brickster) // Validate actorId is a playable character (1-5, not brickster)
inline bool IsValidActorId(uint8_t p_actorId) inline bool IsValidActorId(uint8_t p_actorId)

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include "extensions/multiplayer/animutils.h"
#include "extensions/multiplayer/protocol.h" #include "extensions/multiplayer/protocol.h"
#include "mxtypes.h" #include "mxtypes.h"
@ -37,53 +38,8 @@ class RemotePlayer {
void TriggerEmote(uint8_t p_emoteId); void TriggerEmote(uint8_t p_emoteId);
private: private:
// Cached ROI map entry for an animation using AnimCache = AnimUtils::AnimCache;
struct AnimCache {
LegoAnim* anim;
LegoROI** roiMap;
MxU32 roiMapSize;
AnimCache() : anim(nullptr), roiMap(nullptr), roiMapSize(0) {}
~AnimCache()
{
if (roiMap) {
delete[] roiMap;
}
}
AnimCache(const AnimCache&) = delete;
AnimCache& operator=(const AnimCache&) = delete;
AnimCache(AnimCache&& p_other) noexcept
: anim(p_other.anim), roiMap(p_other.roiMap), roiMapSize(p_other.roiMapSize)
{
p_other.roiMap = nullptr;
p_other.roiMapSize = 0;
p_other.anim = nullptr;
}
AnimCache& operator=(AnimCache&& p_other) noexcept
{
if (this != &p_other) {
if (roiMap) {
delete[] roiMap;
}
anim = p_other.anim;
roiMap = p_other.roiMap;
roiMapSize = p_other.roiMapSize;
p_other.roiMap = nullptr;
p_other.roiMapSize = 0;
p_other.anim = nullptr;
}
return *this;
}
};
void BuildROIMap(
LegoAnim* p_anim,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
LegoROI**& p_roiMap,
MxU32& p_roiMapSize
);
AnimCache* GetOrBuildAnimCache(const char* p_animName); AnimCache* GetOrBuildAnimCache(const char* p_animName);
void UpdateTransform(float p_deltaTime); void UpdateTransform(float p_deltaTime);
void UpdateAnimation(float p_deltaTime); void UpdateAnimation(float p_deltaTime);

View File

@ -0,0 +1,87 @@
#pragma once
#include "extensions/multiplayer/animutils.h"
#include "extensions/multiplayer/protocol.h"
#include "mxgeometry/mxmatrix.h"
#include "mxtypes.h"
#include <cstdint>
#include <map>
#include <string>
class IslePathActor;
class LegoPathActor;
class LegoROI;
class LegoWorld;
class LegoAnim;
namespace Multiplayer
{
class ThirdPersonCamera {
public:
ThirdPersonCamera();
void Enable();
void Disable();
bool IsEnabled() const { return m_enabled; }
bool IsActive() const { return m_active; }
// Core hooks
void OnActorEnter(IslePathActor* p_actor);
void OnActorExit(IslePathActor* p_actor);
// Called every frame from NetworkManager::Tickle()
void Tick(float p_deltaTime);
// Animation selection (forwarded from NetworkManager)
void SetWalkAnimId(uint8_t p_id);
void SetIdleAnimId(uint8_t p_id);
void TriggerEmote(uint8_t p_emoteId);
void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world);
private:
using AnimCache = AnimUtils::AnimCache;
AnimCache* GetOrBuildAnimCache(const char* p_animName);
void ClearAnimCaches();
void SetupCamera(LegoPathActor* p_actor);
void BuildRideAnimation(int8_t p_vehicleType);
void ClearRideAnimation();
void ApplyIdleFrame0();
void ReinitForCharacter();
bool m_enabled;
bool m_active;
LegoROI* m_playerROI; // Borrowed, not owned
// Walk/idle state (same pattern as RemotePlayer)
uint8_t m_walkAnimId;
uint8_t m_idleAnimId;
AnimCache* m_walkAnimCache;
AnimCache* m_idleAnimCache;
float m_animTime;
float m_idleTime;
float m_idleAnimTime;
bool m_wasMoving;
// Emote state
AnimCache* m_emoteAnimCache;
float m_emoteTime;
float m_emoteDuration;
bool m_emoteActive;
MxMatrix m_emoteParentTransform;
// Vehicle ride state
int8_t m_currentVehicleType;
LegoAnim* m_rideAnim;
LegoROI** m_rideRoiMap;
MxU32 m_rideRoiMapSize;
LegoROI* m_rideVehicleROI;
std::map<std::string, AnimCache> m_animCacheMap;
};
} // namespace Multiplayer

View File

@ -4,9 +4,11 @@
#include "extensions/multiplayer/networkmanager.h" #include "extensions/multiplayer/networkmanager.h"
#include "extensions/multiplayer/networktransport.h" #include "extensions/multiplayer/networktransport.h"
#include "extensions/multiplayer/protocol.h" #include "extensions/multiplayer/protocol.h"
#include "islepathactor.h"
#include "legoactor.h" #include "legoactor.h"
#include "legoentity.h" #include "legoentity.h"
#include "legogamestate.h" #include "legogamestate.h"
#include "legopathactor.h"
#include "misc.h" #include "misc.h"
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
@ -31,10 +33,6 @@ void MultiplayerExt::Initialize()
relayUrl = options["multiplayer:relay url"]; relayUrl = options["multiplayer:relay url"];
room = options["multiplayer:room"]; room = options["multiplayer:room"];
if (relayUrl.empty() || room.empty()) {
return;
}
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
s_transport = new Multiplayer::WebSocketTransport(relayUrl); s_transport = new Multiplayer::WebSocketTransport(relayUrl);
s_callbacks = new Multiplayer::EmscriptenCallbacks(); s_callbacks = new Multiplayer::EmscriptenCallbacks();
@ -42,7 +40,12 @@ void MultiplayerExt::Initialize()
s_networkManager = new Multiplayer::NetworkManager(); s_networkManager = new Multiplayer::NetworkManager();
s_networkManager->Initialize(s_transport, s_callbacks); s_networkManager->Initialize(s_transport, s_callbacks);
s_networkManager->Connect(room.c_str()); // Third-person camera enabled by default, toggled via WASM export
s_networkManager->GetThirdPersonCamera().Enable();
if (!relayUrl.empty() && !room.empty()) {
s_networkManager->Connect(room.c_str());
}
#endif #endif
} }
@ -107,6 +110,29 @@ MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity)
return s_networkManager->HandleEntityMutation(p_entity, changeType); return s_networkManager->HandleEntityMutation(p_entity, changeType);
} }
void MultiplayerExt::HandleActorEnter(IslePathActor* p_actor)
{
if (s_networkManager) {
s_networkManager->GetThirdPersonCamera().OnActorEnter(p_actor);
}
}
void MultiplayerExt::HandleActorExit(IslePathActor* p_actor)
{
if (s_networkManager) {
s_networkManager->GetThirdPersonCamera().OnActorExit(p_actor);
}
}
MxBool MultiplayerExt::ShouldInvertMovement(LegoPathActor* p_actor)
{
if (s_networkManager && UserActor() == p_actor) {
return s_networkManager->GetThirdPersonCamera().IsActive();
}
return FALSE;
}
MxBool MultiplayerExt::CheckRejected() MxBool MultiplayerExt::CheckRejected()
{ {
if (s_networkManager && s_networkManager->WasRejected()) { if (s_networkManager && s_networkManager->WasRejected()) {

View File

@ -0,0 +1,130 @@
#include "extensions/multiplayer/animutils.h"
#include "anim/legoanim.h"
#include "legoanimpresenter.h"
#include "legoworld.h"
#include "misc.h"
#include "misc/legotree.h"
#include "roi/legoroi.h"
#include <vector>
using namespace Multiplayer;
// Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime
// via SetROIIndex() since m_roiIndex starts at 0 for all animation nodes.
static void AssignROIIndices(
LegoTreeNode* p_node,
LegoROI* p_parentROI,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
MxU32& p_nextIndex,
std::vector<LegoROI*>& p_entries
)
{
LegoROI* roi = p_parentROI;
LegoAnimNodeData* data = (LegoAnimNodeData*) p_node->GetData();
const char* name = data ? data->GetName() : nullptr;
if (name != nullptr && *name != '-') {
LegoROI* matchedROI = nullptr;
if (*name == '*' || p_parentROI == nullptr) {
roi = p_rootROI;
matchedROI = p_rootROI;
}
else {
matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
if (matchedROI == nullptr && p_extraROI != nullptr) {
matchedROI = p_extraROI->FindChildROI(name, p_extraROI);
}
}
if (matchedROI != nullptr) {
data->SetROIIndex(p_nextIndex);
p_entries.push_back(matchedROI);
p_nextIndex++;
}
else {
data->SetROIIndex(0);
}
}
for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) {
AssignROIIndices(p_node->GetChild(i), roi, p_rootROI, p_extraROI, p_nextIndex, p_entries);
}
}
void AnimUtils::BuildROIMap(
LegoAnim* p_anim,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
LegoROI**& p_roiMap,
MxU32& p_roiMapSize
)
{
if (!p_anim || !p_rootROI) {
return;
}
LegoTreeNode* root = p_anim->GetRoot();
if (!root) {
return;
}
MxU32 nextIndex = 1;
std::vector<LegoROI*> entries;
AssignROIIndices(root, nullptr, p_rootROI, p_extraROI, nextIndex, entries);
if (entries.empty()) {
return;
}
// 1-indexed; index 0 reserved as NULL
p_roiMapSize = entries.size() + 1;
p_roiMap = new LegoROI*[p_roiMapSize];
p_roiMap[0] = nullptr;
for (MxU32 i = 0; i < entries.size(); i++) {
p_roiMap[i + 1] = entries[i];
}
}
AnimUtils::AnimCache* AnimUtils::GetOrBuildAnimCache(
std::map<std::string, AnimCache>& p_cacheMap,
LegoROI* p_roi,
const char* p_animName
)
{
if (!p_animName || !p_roi) {
return nullptr;
}
// Check if already cached
auto it = p_cacheMap.find(p_animName);
if (it != p_cacheMap.end()) {
return &it->second;
}
// Look up the animation presenter in the current world
LegoWorld* world = CurrentWorld();
if (!world) {
return nullptr;
}
MxCore* presenter = world->Find("LegoAnimPresenter", p_animName);
if (!presenter) {
return nullptr;
}
LegoAnim* anim = static_cast<LegoAnimPresenter*>(presenter)->GetAnimation();
if (!anim) {
return nullptr;
}
// Build and cache
AnimCache& cache = p_cacheMap[p_animName];
cache.anim = anim;
BuildROIMap(anim, p_roi, nullptr, cache.roiMap, cache.roiMapSize);
return &cache;
}

View File

@ -42,6 +42,8 @@ NetworkManager::~NetworkManager()
MxResult NetworkManager::Tickle() MxResult NetworkManager::Tickle()
{ {
m_thirdPersonCamera.Tick(0.016f);
if (!m_transport) { if (!m_transport) {
return SUCCESS; return SUCCESS;
} }
@ -134,6 +136,8 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
m_registered = true; m_registered = true;
} }
m_thirdPersonCamera.OnWorldEnabled(p_world);
if (p_world->GetWorldId() == LegoOmni::e_act1) { if (p_world->GetWorldId() == LegoOmni::e_act1) {
m_inIsleWorld = true; m_inIsleWorld = true;
m_worldSync.SetInIsleWorld(true); m_worldSync.SetInIsleWorld(true);
@ -158,6 +162,8 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world)
return; return;
} }
m_thirdPersonCamera.OnWorldDisabled(p_world);
if (p_world->GetWorldId() == LegoOmni::e_act1) { if (p_world->GetWorldId() == LegoOmni::e_act1) {
m_inIsleWorld = false; m_inIsleWorld = false;
m_worldSync.SetInIsleWorld(false); m_worldSync.SetInIsleWorld(false);
@ -213,9 +219,19 @@ void NetworkManager::BroadcastLocalState()
msg.header = {MSG_STATE, m_localPeerId, m_sequence++}; msg.header = {MSG_STATE, m_localPeerId, m_sequence++};
msg.actorId = actorId; msg.actorId = actorId;
msg.worldId = (int8_t) currentWorld->GetWorldId(); msg.worldId = (int8_t) currentWorld->GetWorldId();
msg.vehicleType = DetectLocalVehicleType(); 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));
// Third-person camera: ROI direction is opposite to actual movement direction
// (ShouldInvertMovement preserves TurnAround convention). Negate so remote
// players receive the true movement-facing direction.
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;
@ -407,6 +423,7 @@ void NetworkManager::SetWalkAnimation(uint8_t p_index)
{ {
if (p_index < g_walkAnimCount) { if (p_index < g_walkAnimCount) {
m_localWalkAnimId = p_index; m_localWalkAnimId = p_index;
m_thirdPersonCamera.SetWalkAnimId(p_index);
} }
} }
@ -414,6 +431,7 @@ void NetworkManager::SetIdleAnimation(uint8_t p_index)
{ {
if (p_index < g_idleAnimCount) { if (p_index < g_idleAnimCount) {
m_localIdleAnimId = p_index; m_localIdleAnimId = p_index;
m_thirdPersonCamera.SetIdleAnimId(p_index);
} }
} }
@ -423,6 +441,8 @@ void NetworkManager::SendEmote(uint8_t p_emoteId)
return; return;
} }
m_thirdPersonCamera.TriggerEmote(p_emoteId);
EmoteMsg msg{}; EmoteMsg msg{};
msg.header = {MSG_EMOTE, m_localPeerId, m_sequence++}; msg.header = {MSG_EMOTE, m_localPeerId, m_sequence++};
msg.emoteId = p_emoteId; msg.emoteId = p_emoteId;
@ -476,31 +496,3 @@ void NetworkManager::NotifyPlayerCountChanged()
m_callbacks->OnPlayerCountChanged(count); m_callbacks->OnPlayerCountChanged(count);
} }
int8_t NetworkManager::DetectLocalVehicleType()
{
static const struct {
const char* className;
int8_t vehicleType;
} vehicleMap[] = {
{"Helicopter", VEHICLE_HELICOPTER},
{"Jetski", VEHICLE_JETSKI},
{"DuneBuggy", VEHICLE_DUNEBUGGY},
{"Bike", VEHICLE_BIKE},
{"SkateBoard", VEHICLE_SKATEBOARD},
{"Motorcycle", VEHICLE_MOTOCYCLE},
{"TowTrack", VEHICLE_TOWTRACK},
{"Ambulance", VEHICLE_AMBULANCE},
};
LegoPathActor* actor = UserActor();
if (!actor) {
return VEHICLE_NONE;
}
for (const auto& entry : vehicleMap) {
if (actor->IsA(entry.className)) {
return entry.vehicleType;
}
}
return VEHICLE_NONE;
}

View File

@ -34,6 +34,20 @@ extern "C"
} }
} }
EMSCRIPTEN_KEEPALIVE void mp_toggle_third_person()
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
Multiplayer::ThirdPersonCamera& cam = mgr->GetThirdPersonCamera();
if (cam.IsEnabled()) {
cam.Disable();
}
else {
cam.Enable();
}
}
}
} // extern "C" } // extern "C"
#endif #endif

View File

@ -0,0 +1,79 @@
#include "extensions/multiplayer/protocol.h"
#include "legopathactor.h"
#include <cstddef>
namespace Multiplayer
{
const char* const g_walkAnimNames[] = {
"CNs001xx", // 0: Normal (default)
"CNs002xx", // 1: Joyful
"CNs003xx", // 2: Gloomy
"CNs005xx", // 3: Leaning
"CNs006xx", // 4: Scared
"CNs007xx", // 5: Hyper
};
const int g_walkAnimCount = sizeof(g_walkAnimNames) / sizeof(g_walkAnimNames[0]);
const char* const g_idleAnimNames[] = {
"CNs008xx", // 0: Sway (default)
"CNs009xx", // 1: Groove
"CNs010xx", // 2: Excited
};
const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]);
const char* const g_emoteAnimNames[] = {
"CNs011xx", // 0: Wave
"CNs012xx", // 1: Hat Tip
};
const int g_emoteAnimCount = sizeof(g_emoteAnimNames) / sizeof(g_emoteAnimNames[0]);
// Vehicle model names (LOD names). The helicopter is a compound ROI ("copter")
// with no standalone LOD; use its body part instead.
const char* const g_vehicleROINames[VEHICLE_COUNT] =
{"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"};
// Ride animation names for small vehicles (NULL = large vehicle, no ride anim)
const char* const g_rideAnimNames[VEHICLE_COUNT] =
{NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL};
// Vehicle variant ROI names used in ride animations
const char* const g_rideVehicleROINames[VEHICLE_COUNT] =
{NULL, NULL, NULL, "bikebd", "board", "motoni", NULL, NULL};
bool IsLargeVehicle(int8_t p_vehicleType)
{
return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL;
}
int8_t DetectVehicleType(LegoPathActor* p_actor)
{
static const struct {
const char* className;
int8_t vehicleType;
} vehicleMap[] = {
{"Helicopter", VEHICLE_HELICOPTER},
{"Jetski", VEHICLE_JETSKI},
{"DuneBuggy", VEHICLE_DUNEBUGGY},
{"Bike", VEHICLE_BIKE},
{"SkateBoard", VEHICLE_SKATEBOARD},
{"Motorcycle", VEHICLE_MOTOCYCLE},
{"TowTrack", VEHICLE_TOWTRACK},
{"Ambulance", VEHICLE_AMBULANCE},
};
if (!p_actor) {
return VEHICLE_NONE;
}
for (const auto& entry : vehicleMap) {
if (p_actor->IsA(entry.className)) {
return entry.vehicleType;
}
}
return VEHICLE_NONE;
}
} // namespace Multiplayer

View File

@ -18,24 +18,9 @@
#include <SDL3/SDL_timer.h> #include <SDL3/SDL_timer.h>
#include <cmath> #include <cmath>
#include <vec.h> #include <vec.h>
#include <vector>
using namespace Multiplayer; using namespace Multiplayer;
// LOD names for vehicle models. The helicopter is a compound ROI ("copter")
// with no standalone LOD; use its body part instead.
static const char* g_vehicleROINames[VEHICLE_COUNT] =
{"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"};
static const char* g_rideAnimNames[VEHICLE_COUNT] = {NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL};
static const char* g_rideVehicleROINames[VEHICLE_COUNT] = {NULL, NULL, NULL, "bikebd", "board", "motoni", NULL, NULL};
static bool IsLargeVehicle(int8_t p_vehicleType)
{
return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL;
}
RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId) RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId)
: m_peerId(p_peerId), m_actorId(p_actorId), m_roi(nullptr), m_spawned(false), m_visible(false), m_targetSpeed(0.0f), : m_peerId(p_peerId), m_actorId(p_actorId), m_roi(nullptr), m_spawned(false), m_visible(false), m_targetSpeed(0.0f),
m_targetVehicleType(VEHICLE_NONE), m_targetWorldId(-1), m_lastUpdateTime(SDL_GetTicks()), m_targetVehicleType(VEHICLE_NONE), m_targetWorldId(-1), m_lastUpdateTime(SDL_GetTicks()),
@ -213,38 +198,7 @@ void RemotePlayer::SetVisible(bool p_visible)
RemotePlayer::AnimCache* RemotePlayer::GetOrBuildAnimCache(const char* p_animName) RemotePlayer::AnimCache* RemotePlayer::GetOrBuildAnimCache(const char* p_animName)
{ {
if (!p_animName || !m_roi) { return AnimUtils::GetOrBuildAnimCache(m_animCacheMap, m_roi, p_animName);
return nullptr;
}
// Check if already cached
auto it = m_animCacheMap.find(p_animName);
if (it != m_animCacheMap.end()) {
return &it->second;
}
// Look up the animation presenter in the current world
LegoWorld* world = CurrentWorld();
if (!world) {
return nullptr;
}
MxCore* presenter = world->Find("LegoAnimPresenter", p_animName);
if (!presenter) {
return nullptr;
}
LegoAnim* anim = static_cast<LegoAnimPresenter*>(presenter)->GetAnimation();
if (!anim) {
return nullptr;
}
// Build and cache
AnimCache& cache = m_animCacheMap[p_animName];
cache.anim = anim;
BuildROIMap(anim, m_roi, nullptr, cache.roiMap, cache.roiMapSize);
return &cache;
} }
void RemotePlayer::TriggerEmote(uint8_t p_emoteId) void RemotePlayer::TriggerEmote(uint8_t p_emoteId)
@ -269,83 +223,6 @@ void RemotePlayer::TriggerEmote(uint8_t p_emoteId)
m_emoteActive = true; m_emoteActive = true;
} }
// Mirrors the game's UpdateStructMapAndROIIndex: assigns ROI indices at runtime
// via SetROIIndex() since m_roiIndex starts at 0 for all animation nodes.
static void AssignROIIndices(
LegoTreeNode* p_node,
LegoROI* p_parentROI,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
MxU32& p_nextIndex,
std::vector<LegoROI*>& p_entries
)
{
LegoROI* roi = p_parentROI;
LegoAnimNodeData* data = (LegoAnimNodeData*) p_node->GetData();
const char* name = data ? data->GetName() : nullptr;
if (name != nullptr && *name != '-') {
LegoROI* matchedROI = nullptr;
if (*name == '*' || p_parentROI == nullptr) {
roi = p_rootROI;
matchedROI = p_rootROI;
}
else {
matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
if (matchedROI == nullptr && p_extraROI != nullptr) {
matchedROI = p_extraROI->FindChildROI(name, p_extraROI);
}
}
if (matchedROI != nullptr) {
data->SetROIIndex(p_nextIndex);
p_entries.push_back(matchedROI);
p_nextIndex++;
}
else {
data->SetROIIndex(0);
}
}
for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) {
AssignROIIndices(p_node->GetChild(i), roi, p_rootROI, p_extraROI, p_nextIndex, p_entries);
}
}
void RemotePlayer::BuildROIMap(
LegoAnim* p_anim,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
LegoROI**& p_roiMap,
MxU32& p_roiMapSize
)
{
if (!p_anim || !p_rootROI) {
return;
}
LegoTreeNode* root = p_anim->GetRoot();
if (!root) {
return;
}
MxU32 nextIndex = 1;
std::vector<LegoROI*> entries;
AssignROIIndices(root, nullptr, p_rootROI, p_extraROI, nextIndex, entries);
if (entries.empty()) {
return;
}
// 1-indexed; index 0 reserved as NULL
p_roiMapSize = entries.size() + 1;
p_roiMap = new LegoROI*[p_roiMapSize];
p_roiMap[0] = nullptr;
for (MxU32 i = 0; i < entries.size(); i++) {
p_roiMap[i + 1] = entries[i];
}
}
void RemotePlayer::UpdateTransform(float p_deltaTime) void RemotePlayer::UpdateTransform(float p_deltaTime)
{ {
@ -394,18 +271,10 @@ void RemotePlayer::UpdateAnimation(float p_deltaTime)
// Ensure visibility of all mapped ROIs // Ensure visibility of all mapped ROIs
if (walkRoiMap) { if (walkRoiMap) {
for (MxU32 i = 1; i < walkRoiMapSize; i++) { AnimUtils::EnsureROIMapVisibility(walkRoiMap, walkRoiMapSize);
if (walkRoiMap[i] != nullptr) {
walkRoiMap[i]->SetVisibility(TRUE);
}
}
} }
if (m_idleAnimCache && m_idleAnimCache->roiMap) { if (m_idleAnimCache && m_idleAnimCache->roiMap) {
for (MxU32 i = 1; i < m_idleAnimCache->roiMapSize; i++) { AnimUtils::EnsureROIMapVisibility(m_idleAnimCache->roiMap, m_idleAnimCache->roiMapSize);
if (m_idleAnimCache->roiMap[i] != nullptr) {
m_idleAnimCache->roiMap[i]->SetVisibility(TRUE);
}
}
} }
bool inVehicle = (m_currentVehicleType != VEHICLE_NONE); bool inVehicle = (m_currentVehicleType != VEHICLE_NONE);
@ -569,7 +438,7 @@ void RemotePlayer::EnterVehicle(int8_t p_vehicleType)
m_rideVehicleROI->SetName(vehicleVariantName); m_rideVehicleROI->SetName(vehicleVariantName);
} }
BuildROIMap(m_rideAnim, m_roi, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize); AnimUtils::BuildROIMap(m_rideAnim, m_roi, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize);
} }
} }

View File

@ -0,0 +1,560 @@
#include "extensions/multiplayer/thirdpersoncamera.h"
#include "3dmanager/lego3dmanager.h"
#include "anim/legoanim.h"
#include "islepathactor.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 <cmath>
using namespace Multiplayer;
ThirdPersonCamera::ThirdPersonCamera()
: m_enabled(false), m_active(false), m_playerROI(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_currentVehicleType(VEHICLE_NONE), m_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0),
m_rideVehicleROI(nullptr)
{
}
void ThirdPersonCamera::Enable()
{
m_enabled = true;
}
void ThirdPersonCamera::Disable()
{
m_enabled = false;
m_active = false;
m_playerROI = nullptr;
ClearRideAnimation();
m_animCacheMap.clear();
ClearAnimCaches();
}
void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor)
{
if (!m_enabled) {
return;
}
LegoPathActor* userActor = UserActor();
if (static_cast<LegoPathActor*>(p_actor) != userActor) {
return;
}
LegoROI* newROI = userActor->GetROI();
if (!newROI) {
return;
}
// Detect if we're entering a vehicle
int8_t vehicleType = DetectVehicleType(userActor);
if (vehicleType != VEHICLE_NONE) {
// Large vehicles and helicopter: stay first-person with dashboard.
// Track the vehicle type so OnActorExit can trigger reinit on exit.
if (IsLargeVehicle(vehicleType) || vehicleType == VEHICLE_HELICOPTER) {
// Hide the walking character ROI that we made visible earlier.
// Enter() doesn't call Exit() on the previous actor, so our
// OnActorExit never fires for the walking character.
if (m_playerROI) {
m_playerROI->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Remove(*m_playerROI);
}
m_currentVehicleType = vehicleType;
m_active = false;
return;
}
// Small vehicle: need the character ROI for ride animations.
if (!m_playerROI) {
return;
}
m_currentVehicleType = vehicleType;
m_active = true;
SetupCamera(userActor);
BuildRideAnimation(vehicleType);
return;
}
// Non-vehicle (walking character) entry
m_playerROI = newROI;
m_currentVehicleType = VEHICLE_NONE;
m_active = true;
// Make the player model visible (Enter() hid it for first-person)
m_playerROI->SetVisibility(TRUE);
// SpawnPlayer() removes the ROI from the 3D manager before calling Enter().
// Re-add it so the character is actually rendered in third-person mode.
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;
}
// The hook fires at the end of Exit(), after UserActor() has been restored
// to the walking character. For vehicle exit, p_actor is the vehicle (not
// UserActor), so we check m_currentVehicleType instead of comparing actors.
if (m_currentVehicleType != VEHICLE_NONE) {
// Exiting a vehicle: reinitialize immediately for the walking character.
ClearRideAnimation();
ClearAnimCaches();
m_animCacheMap.clear();
ReinitForCharacter();
}
else if (m_active && static_cast<LegoPathActor*>(p_actor) == UserActor()) {
// Exiting on foot (e.g., world transition): full teardown.
// Hide the player ROI and remove it from the 3D manager (we added it
// in OnActorEnter so the character would render in third-person).
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::Tick(float p_deltaTime)
{
if (!m_active) {
return;
}
if (!m_playerROI) {
return;
}
// Small vehicle with ride animation (like RemotePlayer)
if (m_currentVehicleType != VEHICLE_NONE) {
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 (character ROI may be at old position)
MxMatrix transform(actor->GetROI()->GetLocal2World());
// Position character ROI at the vehicle so bones render at the right place
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;
}
// 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 emotes
if (isMoving && 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 the saved clean parent transform to prevent scale
// accumulation (see TriggerEmote for details).
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 the player ROI's transform — the animation's root
// node (ACTOR_01) wrote a scaled value into 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_id)
{
if (p_id >= g_walkAnimCount) {
return;
}
if (p_id != m_walkAnimId) {
m_walkAnimId = p_id;
if (m_active) {
m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]);
}
}
}
void ThirdPersonCamera::SetIdleAnimId(uint8_t p_id)
{
if (p_id >= g_idleAnimCount) {
return;
}
if (p_id != m_idleAnimId) {
m_idleAnimId = p_id;
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;
}
m_emoteAnimCache = cache;
m_emoteTime = 0.0f;
m_emoteDuration = (float) cache->anim->GetDuration();
m_emoteActive = true;
// Save the clean parent transform before the emote starts.
// The emote animation's root node (ACTOR_01) maps to the player ROI,
// so ApplyAnimationTransformation writes a scaled transform into
// m_playerROI->m_local2world each frame. When the character is
// stationary the engine's CalculateTransform does not run, so the ROI
// is never reset — causing the scale to compound across frames.
// Using the saved clean transform as parent prevents this feedback.
m_emoteParentTransform = m_playerROI->GetLocal2World();
}
void ThirdPersonCamera::OnWorldEnabled(LegoWorld* p_world)
{
if (!m_enabled || !p_world) {
return;
}
// Clear stale caches (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_playerROI = nullptr;
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;
}
// After Enter()'s TurnAround, the ROI direction is negated.
// The mesh faces -z (local) = +path_forward (correct visual facing).
// +z in ROI-local is the negated direction, i.e. behind the visual model.
// Movement inversion is handled by ShouldInvertMovement in CalculateTransform.
Mx3DPointFloat at(0.0f, 2.5f, 3.0f);
Mx3DPointFloat dir(0.0f, -0.3f, -1.0f);
Mx3DPointFloat up(0.0f, 1.0f, 0.0f);
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 from base vehicle name, rename for anim tree matching
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::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::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 (!m_playerROI) {
m_active = false;
return;
}
m_active = true;
SetupCamera(userActor);
BuildRideAnimation(vehicleType);
return;
}
// Reinitializing for walking character
m_playerROI = roi;
m_playerROI->SetVisibility(TRUE);
// Ensure the ROI is in the 3D manager so it gets rendered
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);
}