Implement display actors (#6)

* Implement display actor override for multiplayer extension

Add displayActorIndex to the multiplayer protocol, allowing players to
choose any of the 66 character models from g_actorInfoInit via the
multiplayer:actor INI setting. The visual display is decoupled from the
gameplay actor ID while maintaining backward compatibility.

- Protocol: Add displayActorIndex field to PlayerStateMsg and validation helpers
- RemotePlayer: Use display actor name for cloning instead of actorId
- NetworkManager: Broadcast/handle displayActorIndex, respawn on display change
- ThirdPersonCamera: Create/manage display clone ROI for local player override
- INI: Read multiplayer:actor setting and resolve to g_actorInfoInit index

* Use array syntax for INI option access in display actor setup

Consistent with how relayUrl and room are read from options.

* Fix display actor ROI handling in 3rd person camera

- Fix direction flip targeting display clone instead of native ROI in
  Disable(), ReinitForCharacter(), and OnCamAnimEnd(). The native ROI is
  the source of truth for TransformPointOfView and Tick() sync.
- Fix use-after-free: DestroyDisplayClone() now nulls m_playerROI when
  it points to the destroyed clone, preventing dangling pointer access
  in BuildRideAnimation after a 3rd→1st→3rd person toggle on a vehicle.
- Recreate display clone in ReinitForCharacter() vehicle branch.
- Extract EnsureDisplayROI() helper to deduplicate clone setup pattern.
- Move IsValidDisplayActorIndex() to charactercloner.h, replacing magic
  number 66 with sizeOfArray(g_actorInfoInit).

* Remove display actors plan document
This commit is contained in:
foxtacles 2026-03-07 09:02:53 -08:00 committed by GitHub
parent dcf3b66173
commit ed4e248be4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 192 additions and 371 deletions

View File

@ -1,345 +0,0 @@
# Display Actor Override for Multiplayer Extension
## Context
Currently, the multiplayer extension clones a character ROI based on the player's in-game actor ID (1-5: Pepper/Mama/Papa/Nick/Laura). This means all players appear as one of the 5 playable characters. We want to allow players to choose any of the 66 character models from `g_actorInfoInit` (e.g., "rhoda", "infoman") via an INI setting, decoupling the visual display from the gameplay actor ID.
The actor ID continues to be communicated for future use, but the visual display is driven by a separate `displayActorIndex` — an index into `g_actorInfoInit[66]`. This works because all 66 characters share the same skeleton (`g_actorLODs`), so animations are compatible.
## Files to Modify
| File | Change |
|------|--------|
| `extensions/include/extensions/multiplayer/protocol.h` | Add `displayActorIndex` to `PlayerStateMsg`, add constants |
| `extensions/include/extensions/multiplayer/remoteplayer.h` | Add display actor fields, setter for actorId |
| `extensions/src/multiplayer/remoteplayer.cpp` | Use display actor name for cloning |
| `extensions/include/extensions/multiplayer/networkmanager.h` | Add `m_localDisplayActorIndex`, `SetDisplayActorIndex()` |
| `extensions/src/multiplayer/networkmanager.cpp` | Broadcast/handle display actor index, update respawn logic |
| `extensions/include/extensions/multiplayer/thirdpersoncamera.h` | Add display clone fields and methods |
| `extensions/src/multiplayer/thirdpersoncamera.cpp` | Create/manage display clone ROI, sync transform |
| `extensions/src/multiplayer.cpp` | Read INI setting, resolve to g_actorInfoInit index |
## Implementation Steps
### Step 1: Protocol — Add `displayActorIndex` to `PlayerStateMsg`
**File:** `extensions/include/extensions/multiplayer/protocol.h`
Add at the end of `PlayerStateMsg` (after `idleAnimId`):
```cpp
uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65)
```
Add constants/helper near `IsValidActorId`:
```cpp
static const uint8_t DISPLAY_ACTOR_NONE = 0xFF;
inline bool IsValidDisplayActorIndex(uint8_t p_index)
{
return p_index < 66;
}
```
The relay server (`extensions/src/multiplayer/server/`) does NOT parse MSG_STATE content — it just forwards raw bytes. No relay changes needed.
### Step 2: RemotePlayer — Use display actor for cloning
**File:** `extensions/include/extensions/multiplayer/remoteplayer.h`
- Change constructor: `RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)`
- Add member: `uint8_t m_displayActorIndex`
- Add getter: `uint8_t GetDisplayActorIndex() const { return m_displayActorIndex; }`
- Add setter: `void SetActorId(uint8_t p_actorId) { m_actorId = p_actorId; }`
- Add private helper declaration: `const char* GetDisplayActorName() const`
**File:** `extensions/src/multiplayer/remoteplayer.cpp`
Constructor: store `m_displayActorIndex`, use display actor name for unique name:
```cpp
const char* displayName = GetDisplayActorName();
SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", displayName, p_peerId);
```
Add helper using existing APIs (`CharacterManager()->GetActorName()` at `legocharactermanager.h:72`, `LegoActor::GetActorName()` at `legoactor.cpp:125`):
```cpp
const char* RemotePlayer::GetDisplayActorName() const
{
if (IsValidDisplayActorIndex(m_displayActorIndex)) {
return CharacterManager()->GetActorName(m_displayActorIndex);
}
return LegoActor::GetActorName(m_actorId);
}
```
In `Spawn()`, replace `LegoActor::GetActorName(m_actorId)` with `GetDisplayActorName()`:
```cpp
const char* actorName = GetDisplayActorName();
```
### Step 3: NetworkManager — Broadcast and handle display actor index
**File:** `extensions/include/extensions/multiplayer/networkmanager.h`
- Add public method: `void SetDisplayActorIndex(uint8_t p_index)`
- Add private member: `uint8_t m_localDisplayActorIndex`
- Update `CreateAndSpawnPlayer` signature: add `uint8_t p_displayActorIndex` parameter
**File:** `extensions/src/multiplayer/networkmanager.cpp`
Initialize `m_localDisplayActorIndex(DISPLAY_ACTOR_NONE)` in constructor.
`SetDisplayActorIndex`: store value and forward to third-person camera:
```cpp
void NetworkManager::SetDisplayActorIndex(uint8_t p_index)
{
m_localDisplayActorIndex = p_index;
m_thirdPersonCamera.SetDisplayActorIndex(p_index);
}
```
`BroadcastLocalState`: always send a valid display index (resolve NONE to actorId-1):
```cpp
uint8_t displayIndex = m_localDisplayActorIndex;
if (displayIndex == DISPLAY_ACTOR_NONE) {
displayIndex = actorId - 1; // actorId already validated above
}
msg.displayActorIndex = displayIndex;
```
`HandleState` — replace the actorId-change respawn logic (lines 387-392) with display-actor-change logic:
```cpp
// Respawn only if display actor changed (not on actorId change)
if (it->second->GetDisplayActorIndex() != p_msg.displayActorIndex) {
it->second->Despawn();
m_remotePlayers.erase(it);
CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex);
it = m_remotePlayers.find(peerId);
}
else if (IsValidActorId(p_msg.actorId)) {
it->second->SetActorId(p_msg.actorId); // Update for future use, no visual change
}
```
Also update the fallback player creation (line 381) to pass displayActorIndex:
```cpp
CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex);
```
`CreateAndSpawnPlayer`: pass displayActorIndex to RemotePlayer constructor:
```cpp
RemotePlayer* NetworkManager::CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)
{
auto player = std::make_unique<RemotePlayer>(p_peerId, p_actorId, p_displayActorIndex);
// ... rest unchanged
}
```
### Step 4: ThirdPersonCamera — Display clone for local player
**File:** `extensions/include/extensions/multiplayer/thirdpersoncamera.h`
Add public:
```cpp
void SetDisplayActorIndex(uint8_t p_index);
```
Add private:
```cpp
uint8_t m_displayActorIndex;
LegoROI* m_displayROI; // Owned clone; nullptr = use native ROI
char m_displayUniqueName[32];
void CreateDisplayClone();
void DestroyDisplayClone();
bool HasDisplayOverride() const { return m_displayROI != nullptr; }
```
**File:** `extensions/src/multiplayer/thirdpersoncamera.cpp`
Add includes: `#include "extensions/multiplayer/charactercloner.h"`, `#include "legocharactermanager.h"`
Constructor: initialize `m_displayActorIndex(DISPLAY_ACTOR_NONE)`, `m_displayROI(nullptr)`, zero `m_displayUniqueName`.
`SetDisplayActorIndex`: just store the index:
```cpp
void ThirdPersonCamera::SetDisplayActorIndex(uint8_t p_index)
{
m_displayActorIndex = p_index;
}
```
`CreateDisplayClone`: create clone via CharacterCloner (reuse existing `CharacterCloner::Clone` at `charactercloner.h:14`):
```cpp
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);
}
```
`DestroyDisplayClone`: clean up owned clone:
```cpp
void ThirdPersonCamera::DestroyDisplayClone()
{
if (m_displayROI) {
VideoManager()->Get3DManager()->Remove(*m_displayROI);
CharacterManager()->ReleaseActor(m_displayUniqueName);
m_displayROI = nullptr;
}
}
```
`OnActorEnter` (walking character path, ~line 94): when display override active, use clone instead of native ROI:
```cpp
if (IsValidDisplayActorIndex(m_displayActorIndex)) {
newROI->SetVisibility(FALSE); // hide native ROI
if (!m_displayROI) {
CreateDisplayClone();
}
if (!m_displayROI) {
return; // clone failed
}
m_playerROI = m_displayROI;
} else {
m_playerROI = newROI;
}
m_currentVehicleType = VEHICLE_NONE;
m_active = true;
m_playerROI->SetVisibility(TRUE);
VideoManager()->Get3DManager()->Remove(*m_playerROI);
VideoManager()->Get3DManager()->Add(*m_playerROI);
// ... build anim caches, setup camera (unchanged)
```
`OnActorExit` (walking exit, ~line 139): hide clone but don't destroy it (persists across actor changes):
```cpp
if (m_active && static_cast<LegoPathActor*>(p_actor) == UserActor()) {
if (m_playerROI) {
m_playerROI->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Remove(*m_playerROI);
}
// ... existing cleanup (ClearRideAnimation, ClearAnimCaches, etc.)
m_playerROI = nullptr;
m_active = false;
}
```
`ReinitForCharacter` (~line 540, walking character init): use clone if override active:
```cpp
if (IsValidDisplayActorIndex(m_displayActorIndex)) {
if (!m_displayROI) {
CreateDisplayClone();
}
if (!m_displayROI) {
m_active = false;
return;
}
roi->SetVisibility(FALSE); // hide native
m_playerROI = m_displayROI;
} else {
m_playerROI = roi;
}
// ... rest of init (SetVisibility, Add to 3D, build caches, etc.)
```
`OnWorldDisabled`: destroy the display clone (animation presenters are invalidated):
```cpp
// Add before existing cleanup:
DestroyDisplayClone();
```
`Disable`: destroy display clone on full teardown:
```cpp
// Add to Disable():
DestroyDisplayClone();
```
`Tick` (walking mode, ~line 207): sync display clone position from actual player ROI before animation:
```cpp
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);
}
}
// ... rest of animation code uses m_playerROI (unchanged)
```
### Step 5: INI Setting — Read and resolve actor name
**File:** `extensions/src/multiplayer.cpp`
Add include: `#include "legoactors.h"` (provides `extern LegoActorInfo g_actorInfoInit[66]`)
Add static resolver before `Initialize()`:
```cpp
static uint8_t ResolveDisplayActorIndex(const char* p_name)
{
for (int i = 0; i < static_cast<int>(sizeOfArray(g_actorInfoInit)); i++) {
if (!SDL_strcasecmp(g_actorInfoInit[i].m_name, p_name)) {
return static_cast<uint8_t>(i);
}
}
return Multiplayer::DISPLAY_ACTOR_NONE;
}
```
In `Initialize()`, after creating NetworkManager, read and apply setting:
```cpp
auto actorIt = options.find("multiplayer:actor");
if (actorIt != options.end() && !actorIt->second.empty()) {
uint8_t displayIndex = ResolveDisplayActorIndex(actorIt->second.c_str());
if (displayIndex != Multiplayer::DISPLAY_ACTOR_NONE) {
s_networkManager->SetDisplayActorIndex(displayIndex);
}
}
```
## Key Reused Functions
| Function | File | Purpose |
|----------|------|---------|
| `CharacterCloner::Clone(charMgr, uniqueName, characterType)` | `extensions/src/multiplayer/charactercloner.cpp` | Clone any actor by name |
| `CharacterManager()->GetActorName(index)` | `LEGO1/.../legocharactermanager.cpp:231` | Index → actor name |
| `CharacterManager()->GetNumActors()` | `LEGO1/.../legocharactermanager.cpp:243` | Returns 66 |
| `LegoActor::GetActorName(actorId)` | `LEGO1/.../legoactor.cpp:125` | ActorId → name (fallback) |
| `g_actorInfoInit[66]` | `LEGO1/.../legoactors.h:75` (extern) | Name lookup for INI resolution |
| `CharacterManager()->ReleaseActor(name)` | `LEGO1/.../legocharactermanager.h:83` | Clean up cloned ROI |
## Edge Cases
- **Invalid INI value**: `ResolveDisplayActorIndex` returns `DISPLAY_ACTOR_NONE`, player uses default actorId-based display
- **Actor change mid-game (with INI override)**: actorId changes but `displayActorIndex` stays the same (fixed by INI); remote players update stored actorId without respawning; local display clone persists unchanged
- **No INI override (backward-compatible behavior)**: `m_localDisplayActorIndex` stays `DISPLAY_ACTOR_NONE`; `BroadcastLocalState` resolves to `actorId - 1` dynamically each frame. When the player changes in-game character (e.g., Pepper→Nick), actorId goes 1→4, so `displayActorIndex` goes 0→3. Remote side detects the changed `displayActorIndex` and respawns with the new character model — preserving current behavior exactly. ThirdPersonCamera uses native ROI directly (no clone), so it also updates naturally with the new character
- **Vehicle entry**: Display clone is hidden for large vehicles (existing visibility logic in `SetVisible`/`OnActorEnter`). For small vehicles, ride animation uses `m_playerROI` (the clone) — works because all actors share the same skeleton
- **World transitions**: Display clone is destroyed on `OnWorldDisabled` (animation caches become stale). Recreated on next `ReinitForCharacter`/`OnActorEnter` via lazy `CreateDisplayClone`
## Verification
1. **Build**: Compile the project with `EXTENSIONS` enabled
2. **No override**: Run without `multiplayer:actor` in INI — verify existing behavior unchanged:
- Remote players show correct characters
- Third-person camera works with native ROI
- When a player changes in-game character (Pepper→Nick), the remote player ROI changes accordingly (displayActorIndex changes from 0→3, triggering respawn)
3. **With override**: Set `multiplayer:actor=rhoda` (or any g_actorInfoInit name) — verify:
- Local third-person camera shows the chosen actor model
- Remote players see the chosen actor model
- Changing in-game character (Pepper→Nick) doesn't change the visual display
- Vehicle entry/exit works correctly with display clone
- World transitions don't crash (clone recreated properly)
4. **Two players with different overrides**: Verify both display correctly to each other

View File

@ -1,11 +1,19 @@
#pragma once
#include "legoactors.h"
#include "misc.h"
class LegoCharacterManager;
class LegoROI;
namespace Multiplayer
{
inline bool IsValidDisplayActorIndex(uint8_t p_index)
{
return p_index < sizeOfArray(g_actorInfoInit);
}
class CharacterCloner {
public:
// Creates an independent multi-part character ROI clone.

View File

@ -47,6 +47,7 @@ class NetworkManager : public MxCore {
void SetWalkAnimation(uint8_t p_index);
void SetIdleAnimation(uint8_t p_index);
void SendEmote(uint8_t p_emoteId);
void SetDisplayActorIndex(uint8_t p_index);
// Thread-safe request methods for cross-thread callers (e.g. WASM exports
// running on the browser main thread). Deferred to the game thread in Tickle().
@ -71,7 +72,7 @@ class NetworkManager : public MxCore {
void ProcessIncomingPackets();
void UpdateRemotePlayers(float p_deltaTime);
RemotePlayer* CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId);
RemotePlayer* CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex);
void HandleJoin(const PlayerJoinMsg& p_msg);
void HandleLeave(const PlayerLeaveMsg& p_msg);
@ -102,6 +103,7 @@ class NetworkManager : public MxCore {
uint8_t m_lastValidActorId;
uint8_t m_localWalkAnimId;
uint8_t m_localIdleAnimId;
uint8_t m_localDisplayActorIndex;
bool m_inIsleWorld;
bool m_registered;

View File

@ -81,6 +81,7 @@ struct PlayerStateMsg {
float speed;
uint8_t walkAnimId; // Index into walk animation table (0 = default)
uint8_t idleAnimId; // Index into idle animation table (0 = default)
uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65)
};
// Server -> all: announces which peer is the host
@ -155,6 +156,8 @@ inline bool IsValidActorId(uint8_t p_actorId)
return p_actorId >= 1 && p_actorId <= 5;
}
static const uint8_t DISPLAY_ACTOR_NONE = 0xFF;
// Parse the message type from a buffer. Returns MSG type or 0 on error.
inline uint8_t ParseMessageType(const uint8_t* p_data, size_t p_length)
{

View File

@ -18,7 +18,7 @@ namespace Multiplayer
class RemotePlayer {
public:
RemotePlayer(uint32_t p_peerId, uint8_t p_actorId);
RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex);
~RemotePlayer();
void Spawn(LegoWorld* p_isleWorld);
@ -29,6 +29,8 @@ class RemotePlayer {
uint32_t GetPeerId() const { return m_peerId; }
uint8_t GetActorId() const { return m_actorId; }
uint8_t GetDisplayActorIndex() const { return m_displayActorIndex; }
void SetActorId(uint8_t p_actorId) { m_actorId = p_actorId; }
bool IsSpawned() const { return m_spawned; }
bool IsVisible() const { return m_visible; }
int8_t GetWorldId() const { return m_targetWorldId; }
@ -41,6 +43,7 @@ class RemotePlayer {
using AnimCache = AnimUtils::AnimCache;
AnimCache* GetOrBuildAnimCache(const char* p_animName);
const char* GetDisplayActorName() const;
void UpdateTransform(float p_deltaTime);
void UpdateAnimation(float p_deltaTime);
void UpdateVehicleState();
@ -49,6 +52,7 @@ class RemotePlayer {
uint32_t m_peerId;
uint8_t m_actorId;
uint8_t m_displayActorIndex;
char m_uniqueName[32];
LegoROI* m_roi;

View File

@ -39,6 +39,7 @@ class ThirdPersonCamera {
void SetWalkAnimId(uint8_t p_id);
void SetIdleAnimId(uint8_t p_id);
void TriggerEmote(uint8_t p_emoteId);
void SetDisplayActorIndex(uint8_t p_index);
void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world);
@ -54,11 +55,21 @@ class ThirdPersonCamera {
void ApplyIdleFrame0();
void ReinitForCharacter();
bool EnsureDisplayROI();
void CreateDisplayClone();
void DestroyDisplayClone();
bool HasDisplayOverride() const { return m_displayROI != nullptr; }
bool m_enabled;
bool m_active;
bool m_roiUnflipped; // True when Disable() flipped the ROI direction; ReinitForCharacter re-applies
LegoROI* m_playerROI; // Borrowed, not owned
// Display actor override
uint8_t m_displayActorIndex;
LegoROI* m_displayROI; // Owned clone; nullptr = use native ROI
char m_displayUniqueName[32];
// Walk/idle state (same pattern as RemotePlayer)
uint8_t m_walkAnimId;
uint8_t m_idleAnimId;

View File

@ -6,11 +6,14 @@
#include "extensions/multiplayer/protocol.h"
#include "islepathactor.h"
#include "legoactor.h"
#include "legoactors.h"
#include "legoentity.h"
#include "legogamestate.h"
#include "legopathactor.h"
#include "misc.h"
#include <SDL3/SDL_stdinc.h>
#ifdef __EMSCRIPTEN__
#include "extensions/multiplayer/platforms/emscripten/callbacks.h"
#include "extensions/multiplayer/platforms/emscripten/websockettransport.h"
@ -20,6 +23,16 @@
using namespace Extensions;
static uint8_t ResolveDisplayActorIndex(const char* p_name)
{
for (int i = 0; i < static_cast<int>(sizeOfArray(g_actorInfoInit)); i++) {
if (!SDL_strcasecmp(g_actorInfoInit[i].m_name, p_name)) {
return static_cast<uint8_t>(i);
}
}
return Multiplayer::DISPLAY_ACTOR_NONE;
}
std::map<std::string, std::string> MultiplayerExt::options;
bool MultiplayerExt::enabled = false;
std::string MultiplayerExt::relayUrl;
@ -43,6 +56,14 @@ void MultiplayerExt::Initialize()
// Third-person camera enabled by default, toggled via WASM export
s_networkManager->GetThirdPersonCamera().Enable();
std::string actor = options["multiplayer:actor"];
if (!actor.empty()) {
uint8_t displayIndex = ResolveDisplayActorIndex(actor.c_str());
if (displayIndex != Multiplayer::DISPLAY_ACTOR_NONE) {
s_networkManager->SetDisplayActorIndex(displayIndex);
}
}
if (!relayUrl.empty() && !room.empty()) {
s_networkManager->Connect(room.c_str());
}

View File

@ -30,7 +30,8 @@ void NetworkManager::SendMessage(const T& p_msg)
NetworkManager::NetworkManager()
: m_transport(nullptr), m_callbacks(nullptr), m_localPeerId(0), m_hostPeerId(0), m_sequence(0),
m_lastBroadcastTime(0), m_lastValidActorId(0), m_localWalkAnimId(0), m_localIdleAnimId(0), m_inIsleWorld(false),
m_lastBroadcastTime(0), m_lastValidActorId(0), m_localWalkAnimId(0), m_localIdleAnimId(0),
m_localDisplayActorIndex(DISPLAY_ACTOR_NONE), m_inIsleWorld(false),
m_registered(false), m_pendingToggleThirdPerson(false), m_pendingWalkAnim(-1), m_pendingIdleAnim(-1),
m_pendingEmote(-1)
{
@ -265,6 +266,12 @@ void NetworkManager::BroadcastLocalState()
msg.walkAnimId = m_localWalkAnimId;
msg.idleAnimId = m_localIdleAnimId;
uint8_t displayIndex = m_localDisplayActorIndex;
if (displayIndex == DISPLAY_ACTOR_NONE) {
displayIndex = actorId - 1; // actorId already validated above
}
msg.displayActorIndex = displayIndex;
SendMessage(msg);
}
@ -363,9 +370,9 @@ void NetworkManager::UpdateRemotePlayers(float p_deltaTime)
}
}
RemotePlayer* NetworkManager::CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId)
RemotePlayer* NetworkManager::CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)
{
auto player = std::make_unique<RemotePlayer>(p_peerId, p_actorId);
auto player = std::make_unique<RemotePlayer>(p_peerId, p_actorId, p_displayActorIndex);
if (m_inIsleWorld) {
LegoWorld* world = CurrentWorld();
@ -387,7 +394,7 @@ void NetworkManager::HandleJoin(const PlayerJoinMsg& p_msg)
return;
}
CreateAndSpawnPlayer(peerId, p_msg.actorId);
CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.actorId - 1);
NotifyPlayerCountChanged();
}
@ -406,18 +413,21 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg)
return;
}
CreateAndSpawnPlayer(peerId, p_msg.actorId);
CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex);
NotifyPlayerCountChanged();
it = m_remotePlayers.find(peerId);
}
// Handle actor change (e.g., Pepper -> Nick)
if (IsValidActorId(p_msg.actorId) && it->second->GetActorId() != p_msg.actorId) {
// Respawn only if display actor changed (not on actorId change)
if (it->second->GetDisplayActorIndex() != p_msg.displayActorIndex) {
it->second->Despawn();
m_remotePlayers.erase(it);
CreateAndSpawnPlayer(peerId, p_msg.actorId);
CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex);
it = m_remotePlayers.find(peerId);
}
else if (IsValidActorId(p_msg.actorId)) {
it->second->SetActorId(p_msg.actorId); // Update for future use, no visual change
}
int8_t oldWorldId = it->second->GetWorldId();
@ -477,6 +487,12 @@ void NetworkManager::SendEmote(uint8_t p_emoteId)
SendMessage(msg);
}
void NetworkManager::SetDisplayActorIndex(uint8_t p_index)
{
m_localDisplayActorIndex = p_index;
m_thirdPersonCamera.SetDisplayActorIndex(p_index);
}
void NetworkManager::HandleEmote(const EmoteMsg& p_msg)
{
uint32_t peerId = p_msg.header.peerId;

View File

@ -21,15 +21,17 @@
using namespace Multiplayer;
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_targetVehicleType(VEHICLE_NONE), m_targetWorldId(-1), m_lastUpdateTime(SDL_GetTicks()),
m_hasReceivedUpdate(false), 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_rideAnim(nullptr), m_rideRoiMap(nullptr),
m_rideRoiMapSize(0), m_rideVehicleROI(nullptr), m_vehicleROI(nullptr), m_currentVehicleType(VEHICLE_NONE)
RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)
: m_peerId(p_peerId), m_actorId(p_actorId), m_displayActorIndex(p_displayActorIndex), 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_hasReceivedUpdate(false), 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_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0), m_rideVehicleROI(nullptr), m_vehicleROI(nullptr),
m_currentVehicleType(VEHICLE_NONE)
{
SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", LegoActor::GetActorName(p_actorId), p_peerId);
const char* displayName = GetDisplayActorName();
SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", displayName, p_peerId);
ZEROVEC3(m_targetPosition);
m_targetDirection[0] = 0.0f;
@ -60,7 +62,7 @@ void RemotePlayer::Spawn(LegoWorld* p_isleWorld)
return;
}
const char* actorName = LegoActor::GetActorName(m_actorId);
const char* actorName = GetDisplayActorName();
if (!actorName) {
return;
}
@ -107,6 +109,14 @@ void RemotePlayer::Despawn()
m_visible = false;
}
const char* RemotePlayer::GetDisplayActorName() const
{
if (IsValidDisplayActorIndex(m_displayActorIndex)) {
return CharacterManager()->GetActorName(m_displayActorIndex);
}
return LegoActor::GetActorName(m_actorId);
}
void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg)
{
float posDelta = SDL_sqrtf(DISTSQRD3(p_msg.position, m_targetPosition));

View File

@ -2,6 +2,7 @@
#include "3dmanager/lego3dmanager.h"
#include "anim/legoanim.h"
#include "extensions/multiplayer/charactercloner.h"
#include "islepathactor.h"
#include "legoanimpresenter.h"
#include "legocameracontroller.h"
@ -15,6 +16,7 @@
#include "realtime/realtime.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
#include <cmath>
using namespace Multiplayer;
@ -33,12 +35,14 @@ static void FlipROIDirection(LegoROI* p_roi)
}
ThirdPersonCamera::ThirdPersonCamera()
: m_enabled(false), m_active(false), m_roiUnflipped(false), m_playerROI(nullptr), m_walkAnimId(0), m_idleAnimId(0),
: 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_currentVehicleType(VEHICLE_NONE), m_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0),
m_rideVehicleROI(nullptr)
{
SDL_memset(m_displayUniqueName, 0, sizeof(m_displayUniqueName));
}
void ThirdPersonCamera::Enable()
@ -61,8 +65,11 @@ void ThirdPersonCamera::Disable()
// 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).
LegoROI* turnAroundROI =
(m_currentVehicleType == VEHICLE_NONE) ? m_playerROI : (userActor ? userActor->GetROI() : nullptr);
// 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);
@ -84,6 +91,7 @@ void ThirdPersonCamera::Disable()
}
m_active = false;
DestroyDisplayClone();
ClearRideAnimation();
m_animCacheMap.clear();
ClearAnimCaches();
@ -142,7 +150,15 @@ void ThirdPersonCamera::OnActorEnter(IslePathActor* p_actor)
}
// Non-vehicle (walking character) entry — Enter() already called TurnAround.
m_playerROI = newROI;
if (IsValidDisplayActorIndex(m_displayActorIndex)) {
newROI->SetVisibility(FALSE);
if (!EnsureDisplayROI()) {
return;
}
}
else {
m_playerROI = newROI;
}
m_roiUnflipped = false;
m_active = true;
@ -216,7 +232,9 @@ void ThirdPersonCamera::OnCamAnimEnd(LegoPathActor* p_actor)
// 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.
LegoROI* roi = (m_currentVehicleType == VEHICLE_NONE) ? m_playerROI : p_actor->GetROI();
// 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);
}
@ -281,6 +299,16 @@ void ThirdPersonCamera::Tick(float p_deltaTime)
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;
@ -464,6 +492,7 @@ void ThirdPersonCamera::OnWorldDisabled(LegoWorld* p_world)
m_active = false;
m_roiUnflipped = false;
m_playerROI = nullptr;
DestroyDisplayClone();
ClearRideAnimation();
m_animCacheMap.clear();
ClearAnimCaches();
@ -537,6 +566,52 @@ void ThirdPersonCamera::BuildRideAnimation(int8_t p_vehicleType)
m_animTime = 0.0f;
}
void ThirdPersonCamera::SetDisplayActorIndex(uint8_t p_index)
{
m_displayActorIndex = p_index;
}
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);
}
void ThirdPersonCamera::DestroyDisplayClone()
{
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) {
@ -591,6 +666,11 @@ void ThirdPersonCamera::ReinitForCharacter()
m_currentVehicleType = vehicleType;
if (vehicleType != VEHICLE_NONE) {
if (IsValidDisplayActorIndex(m_displayActorIndex) && !EnsureDisplayROI()) {
m_active = false;
return;
}
if (!m_playerROI) {
m_active = false;
return;
@ -618,12 +698,23 @@ void ThirdPersonCamera::ReinitForCharacter()
}
// Reinitializing for walking character
m_playerROI = roi;
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(m_playerROI);
FlipROIDirection(HasDisplayOverride() ? roi : m_playerROI);
m_roiUnflipped = false;
}