Add player name bubbles above remote players' heads in multiplayer (#7)

Renders a billboard text bubble showing each remote player's display
name. Includes a WASM export to toggle visibility from the frontend.

- Bitmap font renderer generates paletted textures for name labels
- Billboard quad faces the camera each frame via orientation matrix
- Bubble visibility managed globally by NetworkManager toggle
- Fix miniwin D3DRMIMAGE constructor code style (static_cast, const)
This commit is contained in:
foxtacles 2026-03-07 10:38:33 -08:00 committed by GitHub
parent ed4e248be4
commit c943d2455d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 546 additions and 6 deletions

View File

@ -534,6 +534,7 @@ if (ISLE_EXTENSIONS)
extensions/src/multiplayer.cpp extensions/src/multiplayer.cpp
extensions/src/multiplayer/animutils.cpp extensions/src/multiplayer/animutils.cpp
extensions/src/multiplayer/charactercloner.cpp extensions/src/multiplayer/charactercloner.cpp
extensions/src/multiplayer/namebubblerenderer.cpp
extensions/src/multiplayer/networkmanager.cpp extensions/src/multiplayer/networkmanager.cpp
extensions/src/multiplayer/protocol.cpp extensions/src/multiplayer/protocol.cpp
extensions/src/multiplayer/remoteplayer.cpp extensions/src/multiplayer/remoteplayer.cpp

View File

@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
namespace Tgl
{
class Group;
class MeshBuilder;
class Mesh;
class Texture;
} // namespace Tgl
class LegoROI;
namespace Multiplayer
{
class NameBubbleRenderer {
public:
NameBubbleRenderer();
~NameBubbleRenderer();
// Create the 3D billboard with the given name text.
// Must be called after the player's ROI is spawned.
void Create(const char* p_name);
// Remove from scene and release all resources.
void Destroy();
// Update billboard position (above p_roi) and orientation (face camera).
void Update(LegoROI* p_roi);
// Show or hide the billboard.
void SetVisible(bool p_visible);
bool IsCreated() const { return m_group != nullptr; }
private:
void GenerateTexture(const char* p_name);
void CreateQuadMesh();
Tgl::Group* m_group;
Tgl::MeshBuilder* m_meshBuilder;
Tgl::Mesh* m_mesh;
Tgl::Texture* m_texture;
uint8_t* m_texelData;
bool m_visible;
};
} // namespace Multiplayer

View File

@ -55,6 +55,9 @@ class NetworkManager : public MxCore {
void RequestSetWalkAnimation(uint8_t p_index) { m_pendingWalkAnim.store(p_index, std::memory_order_relaxed); } void RequestSetWalkAnimation(uint8_t p_index) { m_pendingWalkAnim.store(p_index, std::memory_order_relaxed); }
void RequestSetIdleAnimation(uint8_t p_index) { m_pendingIdleAnim.store(p_index, std::memory_order_relaxed); } void RequestSetIdleAnimation(uint8_t p_index) { m_pendingIdleAnim.store(p_index, std::memory_order_relaxed); }
void RequestSendEmote(uint8_t p_emoteId) { m_pendingEmote.store(p_emoteId, std::memory_order_relaxed); } void RequestSendEmote(uint8_t p_emoteId) { m_pendingEmote.store(p_emoteId, std::memory_order_relaxed); }
void RequestToggleNameBubbles() { m_pendingToggleNameBubbles.store(true, std::memory_order_relaxed); }
bool GetShowNameBubbles() const { return m_showNameBubbles; }
void OnWorldEnabled(LegoWorld* p_world); void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world);
@ -108,10 +111,13 @@ class NetworkManager : public MxCore {
bool m_registered; bool m_registered;
std::atomic<bool> m_pendingToggleThirdPerson; std::atomic<bool> m_pendingToggleThirdPerson;
std::atomic<bool> m_pendingToggleNameBubbles;
std::atomic<int> m_pendingWalkAnim; std::atomic<int> m_pendingWalkAnim;
std::atomic<int> m_pendingIdleAnim; std::atomic<int> m_pendingIdleAnim;
std::atomic<int> m_pendingEmote; std::atomic<int> m_pendingEmote;
bool m_showNameBubbles;
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
static const int EXIT_ROOM_FULL = 10; static const int EXIT_ROOM_FULL = 10;

View File

@ -81,6 +81,7 @@ struct PlayerStateMsg {
float speed; float speed;
uint8_t walkAnimId; // Index into walk animation table (0 = default) uint8_t walkAnimId; // Index into walk animation table (0 = default)
uint8_t idleAnimId; // Index into idle animation table (0 = default) uint8_t idleAnimId; // Index into idle animation table (0 = default)
char name[8]; // Player display name (7 chars + null terminator)
uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65) uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65)
}; };

View File

@ -16,6 +16,8 @@ class LegoTreeNode;
namespace Multiplayer namespace Multiplayer
{ {
class NameBubbleRenderer;
class RemotePlayer { class RemotePlayer {
public: public:
RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex); RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex);
@ -35,9 +37,11 @@ class RemotePlayer {
bool IsVisible() const { return m_visible; } bool IsVisible() const { return m_visible; }
int8_t GetWorldId() const { return m_targetWorldId; } int8_t GetWorldId() const { return m_targetWorldId; }
uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; } uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; }
void SetVisible(bool p_visible); void SetVisible(bool p_visible);
void TriggerEmote(uint8_t p_emoteId); void TriggerEmote(uint8_t p_emoteId);
void SetNameBubbleVisible(bool p_visible);
void CreateNameBubble();
void DestroyNameBubble();
private: private:
using AnimCache = AnimUtils::AnimCache; using AnimCache = AnimUtils::AnimCache;
@ -54,6 +58,7 @@ class RemotePlayer {
uint8_t m_actorId; uint8_t m_actorId;
uint8_t m_displayActorIndex; uint8_t m_displayActorIndex;
char m_uniqueName[32]; char m_uniqueName[32];
char m_displayName[8];
LegoROI* m_roi; LegoROI* m_roi;
bool m_spawned; bool m_spawned;
@ -99,6 +104,8 @@ class RemotePlayer {
LegoROI* m_vehicleROI; LegoROI* m_vehicleROI;
int8_t m_currentVehicleType; int8_t m_currentVehicleType;
NameBubbleRenderer* m_nameBubble;
}; };
} // namespace Multiplayer } // namespace Multiplayer

View File

@ -0,0 +1,335 @@
#include "extensions/multiplayer/namebubblerenderer.h"
#include "3dmanager/lego3dmanager.h"
#include "legovideomanager.h"
#include "misc.h"
#include "roi/legoroi.h"
#include "tgl/tgl.h"
#include <SDL3/SDL_stdinc.h>
#include <vec.h>
using namespace Multiplayer;
// 5x5 bitmap font for A-Z (each row is a byte with bits 4..0 representing columns)
// clang-format off
static const uint8_t g_letterFont[26][5] = {
{0x0E, 0x11, 0x1F, 0x11, 0x11}, // A
{0x1E, 0x11, 0x1E, 0x11, 0x1E}, // B
{0x0F, 0x10, 0x10, 0x10, 0x0F}, // C
{0x1E, 0x11, 0x11, 0x11, 0x1E}, // D
{0x1F, 0x10, 0x1E, 0x10, 0x1F}, // E
{0x1F, 0x10, 0x1E, 0x10, 0x10}, // F
{0x0F, 0x10, 0x13, 0x11, 0x0F}, // G
{0x11, 0x11, 0x1F, 0x11, 0x11}, // H
{0x0E, 0x04, 0x04, 0x04, 0x0E}, // I
{0x01, 0x01, 0x01, 0x11, 0x0E}, // J
{0x11, 0x12, 0x1C, 0x12, 0x11}, // K
{0x10, 0x10, 0x10, 0x10, 0x1F}, // L
{0x11, 0x1B, 0x15, 0x11, 0x11}, // M
{0x11, 0x19, 0x15, 0x13, 0x11}, // N
{0x0E, 0x11, 0x11, 0x11, 0x0E}, // O
{0x1E, 0x11, 0x1E, 0x10, 0x10}, // P
{0x0E, 0x11, 0x15, 0x12, 0x0D}, // Q
{0x1E, 0x11, 0x1E, 0x12, 0x11}, // R
{0x0F, 0x10, 0x0E, 0x01, 0x1E}, // S
{0x1F, 0x04, 0x04, 0x04, 0x04}, // T
{0x11, 0x11, 0x11, 0x11, 0x0E}, // U
{0x11, 0x11, 0x11, 0x0A, 0x04}, // V
{0x11, 0x11, 0x15, 0x1B, 0x11}, // W
{0x11, 0x0A, 0x04, 0x0A, 0x11}, // X
{0x11, 0x0A, 0x04, 0x04, 0x04}, // Y
{0x1F, 0x02, 0x04, 0x08, 0x1F}, // Z
};
// clang-format on
// Texture dimensions (must be power of 2)
static const int TEX_WIDTH = 64;
static const int TEX_HEIGHT = 16;
// Palette indices
static const uint8_t PAL_TRANSPARENT = 0;
static const uint8_t PAL_BLACK = 1;
static const uint8_t PAL_WHITE = 2;
// Billboard world-space size
static const float BUBBLE_WIDTH = 1.2f;
static const float BUBBLE_HEIGHT = 0.3f;
// Vertical offset above ROI bounding sphere
static const float BUBBLE_Y_OFFSET = 0.15f;
NameBubbleRenderer::NameBubbleRenderer()
: m_group(nullptr), m_meshBuilder(nullptr), m_mesh(nullptr), m_texture(nullptr), m_texelData(nullptr),
m_visible(true)
{
}
NameBubbleRenderer::~NameBubbleRenderer()
{
Destroy();
}
void NameBubbleRenderer::GenerateTexture(const char* p_name)
{
m_texelData = new uint8_t[TEX_WIDTH * TEX_HEIGHT];
SDL_memset(m_texelData, PAL_TRANSPARENT, TEX_WIDTH * TEX_HEIGHT);
int nameLen = (int) SDL_strlen(p_name);
if (nameLen <= 0) {
return;
}
// Each letter is 5px wide + 1px spacing; 3px horizontal and 2px vertical padding
int bubbleW = nameLen * 6 - 1 + 6;
int bubbleH = 9;
int bx = SDL_max((TEX_WIDTH - bubbleW) / 2, 0);
int by = SDL_max((TEX_HEIGHT - bubbleH) / 2, 0);
// Draw white bubble background with rounded corners
for (int y = by; y < by + bubbleH && y < TEX_HEIGHT; y++) {
for (int x = bx; x < bx + bubbleW && x < TEX_WIDTH; x++) {
int lx = x - bx;
int ly = y - by;
if ((lx == 0 || lx == bubbleW - 1) && (ly == 0 || ly == bubbleH - 1)) {
continue;
}
m_texelData[y * TEX_WIDTH + x] = PAL_WHITE;
}
}
// Draw black border (top/bottom edges, then left/right edges)
for (int x = bx + 1; x < bx + bubbleW - 1 && x < TEX_WIDTH; x++) {
m_texelData[by * TEX_WIDTH + x] = PAL_BLACK;
m_texelData[(by + bubbleH - 1) * TEX_WIDTH + x] = PAL_BLACK;
}
for (int y = by + 1; y < by + bubbleH - 1 && y < TEX_HEIGHT; y++) {
m_texelData[y * TEX_WIDTH + bx] = PAL_BLACK;
m_texelData[y * TEX_WIDTH + bx + bubbleW - 1] = PAL_BLACK;
}
// Draw text (black on white bubble)
int textX = bx + 3;
int textY = by + 2;
for (int i = 0; i < nameLen; i++) {
char ch = SDL_toupper(p_name[i]);
if (ch < 'A' || ch > 'Z') {
continue;
}
for (int row = 0; row < 5; row++) {
uint8_t bits = g_letterFont[ch - 'A'][row];
for (int col = 0; col < 5; col++) {
if (bits & (1 << (4 - col))) {
int px = textX + i * 6 + col;
int py = textY + row;
if (px < TEX_WIDTH && py < TEX_HEIGHT) {
m_texelData[py * TEX_WIDTH + px] = PAL_BLACK;
}
}
}
}
}
}
void NameBubbleRenderer::CreateQuadMesh()
{
Tgl::Renderer* renderer = VideoManager()->GetRenderer();
if (!renderer) {
return;
}
m_meshBuilder = renderer->CreateMeshBuilder();
if (!m_meshBuilder) {
return;
}
float halfW = BUBBLE_WIDTH * 0.5f;
float halfH = BUBBLE_HEIGHT * 0.5f;
// Vertex order chosen so that triangles (0,1,2) and (0,2,3) have CW winding
// when viewed from +Z, matching the renderer's glFrontFace(GL_CW) setting.
float positions[4][3] = {
{-halfW, -halfH, 0.0f}, // 0: bottom-left
{-halfW, halfH, 0.0f}, // 1: top-left
{ halfW, halfH, 0.0f}, // 2: top-right
{ halfW, -halfH, 0.0f} // 3: bottom-right
};
float normals[4][3] = {
{0.0f, 0.0f, 1.0f},
{0.0f, 0.0f, 1.0f},
{0.0f, 0.0f, 1.0f},
{0.0f, 0.0f, 1.0f}
};
float texCoords[4][2] = {
{0.0f, 1.0f}, // 0: bottom-left of texture
{0.0f, 0.0f}, // 1: top-left
{1.0f, 0.0f}, // 2: top-right
{1.0f, 1.0f} // 3: bottom-right
};
// Tgl::CreateMesh expects packed face indices where each uint32 encodes:
// low 16 bits = position vertex index
// high 16 bits = normal vertex index | 0x8000 (bit 15 = "packed vertex" flag)
// Without the 0x8000 flag, the entry is a simple reference to an already-created
// vertex (no new vertex is allocated). Each packed entry creates a new vertex,
// so shared vertices (0 and 2, used in both triangles) must use simple refs in
// the second triangle to stay within the p_numVertices allocation.
unsigned int faceIndices[2][3] = {
{0x80000000, 0x80010001, 0x80020002}, // create vertices 0, 1, 2
{0x00000000, 0x00000002, 0x80030003} // reuse 0, reuse 2, create vertex 3
};
unsigned int texIndices[2][3] = {
{0, 1, 2},
{0, 0, 3} // only index 5 (value 3) is read; indices 3-4 are simple refs
};
m_mesh = m_meshBuilder->CreateMesh(2, 4, positions, normals, texCoords, faceIndices, texIndices, Tgl::Flat);
}
void NameBubbleRenderer::Create(const char* p_name)
{
if (m_group || !p_name || p_name[0] == '\0') {
return;
}
Tgl::Renderer* renderer = VideoManager()->GetRenderer();
if (!renderer) {
return;
}
// Generate the name texture
GenerateTexture(p_name);
// Create Tgl texture from pixel data
Tgl::PaletteEntry palette[3];
palette[PAL_TRANSPARENT] = {255, 255, 255};
palette[PAL_BLACK] = {0, 0, 0};
palette[PAL_WHITE] = {255, 255, 255};
m_texture = renderer->CreateTexture(TEX_WIDTH, TEX_HEIGHT, 8, m_texelData, TRUE, 3, palette);
if (!m_texture) {
Destroy();
return;
}
// Create the quad mesh
CreateQuadMesh();
if (!m_mesh) {
Destroy();
return;
}
// Apply texture to mesh
m_mesh->SetTexture(m_texture);
m_mesh->SetShadingModel(Tgl::Flat);
// Set alpha < 1.0 so the renderer treats this as transparent (deferred draw
// with blending enabled). The actual per-pixel alpha comes from the texture.
m_mesh->SetColor(1.0f, 1.0f, 1.0f, 254.0f / 255.0f);
// Create a group (D3DRM frame) to hold the billboard
Tgl::Group* scene = VideoManager()->Get3DManager()->GetScene();
m_group = renderer->CreateGroup(scene);
if (!m_group) {
Destroy();
return;
}
m_group->Add(m_meshBuilder);
}
void NameBubbleRenderer::Destroy()
{
if (m_group) {
if (m_visible) {
Tgl::Group* scene = VideoManager()->Get3DManager()->GetScene();
if (scene) {
scene->Remove(m_group);
}
}
delete m_group;
m_group = nullptr;
}
if (m_meshBuilder) {
delete m_meshBuilder;
m_meshBuilder = nullptr;
m_mesh = nullptr; // owned by meshBuilder
}
if (m_texture) {
delete m_texture;
m_texture = nullptr;
}
if (m_texelData) {
delete[] m_texelData;
m_texelData = nullptr;
}
}
void NameBubbleRenderer::SetVisible(bool p_visible)
{
if (m_visible == p_visible || !m_group) {
return;
}
m_visible = p_visible;
Tgl::Group* scene = VideoManager()->Get3DManager()->GetScene();
if (!scene) {
return;
}
if (p_visible) {
scene->Add(m_group);
}
else {
scene->Remove(m_group);
}
}
void NameBubbleRenderer::Update(LegoROI* p_roi)
{
if (!m_group || !p_roi || !m_visible) {
return;
}
LegoROI* viewROI = VideoManager()->GetViewROI();
if (!viewROI) {
return;
}
// Billboard normal = camera's backward-z direction (faces toward camera)
const float* normal = viewROI->GetWorldDirection();
const float* camUp = viewROI->GetWorldUp();
// Build billboard basis vectors
float right[3], up[3];
VXV3(right, camUp, normal);
float rLen = SDL_sqrtf(NORMSQRD3(right));
if (rLen > 0.0001f) {
VDS3(right, right, rLen);
}
VXV3(up, normal, right);
// Position above the player's bounding sphere
const BoundingSphere& sphere = p_roi->GetWorldBoundingSphere();
float pos[3];
SET3(pos, sphere.Center());
pos[1] += sphere.Radius() + BUBBLE_Y_OFFSET;
// Build transformation: rows are right, up, normal, position
Tgl::FloatMatrix4 mat = {};
SET3(mat[0], right);
SET3(mat[1], up);
SET3(mat[2], normal);
SET3(mat[3], pos);
mat[3][3] = 1.0f;
m_group->SetTransformation(mat);
}

View File

@ -1,5 +1,6 @@
#include "extensions/multiplayer/networkmanager.h" #include "extensions/multiplayer/networkmanager.h"
#include "legogamestate.h"
#include "legomain.h" #include "legomain.h"
#include "legopathactor.h" #include "legopathactor.h"
#include "legoworld.h" #include "legoworld.h"
@ -32,8 +33,8 @@ NetworkManager::NetworkManager()
: m_transport(nullptr), m_callbacks(nullptr), m_localPeerId(0), m_hostPeerId(0), m_sequence(0), : 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_lastBroadcastTime(0), m_lastValidActorId(0), m_localWalkAnimId(0), m_localIdleAnimId(0),
m_localDisplayActorIndex(DISPLAY_ACTOR_NONE), m_inIsleWorld(false), m_localDisplayActorIndex(DISPLAY_ACTOR_NONE), m_inIsleWorld(false),
m_registered(false), m_pendingToggleThirdPerson(false), m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false),
m_pendingEmote(-1) m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_showNameBubbles(true)
{ {
} }
@ -149,6 +150,7 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) { if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) {
player->SetVisible(true); player->SetVisible(true);
player->SetNameBubbleVisible(m_showNameBubbles);
} }
} }
} }
@ -170,6 +172,7 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world)
m_worldSync.SetInIsleWorld(false); m_worldSync.SetInIsleWorld(false);
for (auto& [peerId, player] : m_remotePlayers) { for (auto& [peerId, player] : m_remotePlayers) {
player->SetVisible(false); player->SetVisible(false);
player->SetNameBubbleVisible(false);
} }
NotifyPlayerCountChanged(); NotifyPlayerCountChanged();
@ -206,6 +209,13 @@ void NetworkManager::ProcessPendingRequests()
if (emote >= 0) { if (emote >= 0) {
SendEmote(static_cast<uint8_t>(emote)); SendEmote(static_cast<uint8_t>(emote));
} }
if (m_pendingToggleNameBubbles.exchange(false, std::memory_order_relaxed)) {
m_showNameBubbles = !m_showNameBubbles;
for (auto& [peerId, player] : m_remotePlayers) {
player->SetNameBubbleVisible(m_showNameBubbles);
}
}
} }
void NetworkManager::BroadcastLocalState() void NetworkManager::BroadcastLocalState()
@ -266,6 +276,26 @@ void NetworkManager::BroadcastLocalState()
msg.walkAnimId = m_localWalkAnimId; msg.walkAnimId = m_localWalkAnimId;
msg.idleAnimId = m_localIdleAnimId; msg.idleAnimId = m_localIdleAnimId;
// Convert Username letters (0-25 = A-Z) to ASCII string.
// The active player is always at m_players[0] after RegisterPlayer/SwitchPlayer.
SDL_memset(msg.name, 0, sizeof(msg.name));
LegoGameState* gs = GameState();
if (gs && gs->m_playerCount > 0) {
const LegoGameState::Username& username = gs->m_players[0];
for (int i = 0; i < 7; i++) {
MxS16 letter = username.m_letters[i];
if (letter < 0) {
break;
}
if (letter <= 25) {
msg.name[i] = (char) ('A' + letter);
}
else {
msg.name[i] = '?';
}
}
}
uint8_t displayIndex = m_localDisplayActorIndex; uint8_t displayIndex = m_localDisplayActorIndex;
if (displayIndex == DISPLAY_ACTOR_NONE) { if (displayIndex == DISPLAY_ACTOR_NONE) {
displayIndex = actorId - 1; // actorId already validated above displayIndex = actorId - 1; // actorId already validated above
@ -436,6 +466,7 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg)
bool bothInIsle = m_inIsleWorld && (p_msg.worldId == (int8_t) LegoOmni::e_act1); bool bothInIsle = m_inIsleWorld && (p_msg.worldId == (int8_t) LegoOmni::e_act1);
if (it->second->IsSpawned()) { if (it->second->IsSpawned()) {
it->second->SetVisible(bothInIsle); it->second->SetVisible(bothInIsle);
it->second->SetNameBubbleVisible(bothInIsle && m_showNameBubbles);
} }
bool wasInIsle = (oldWorldId == (int8_t) LegoOmni::e_act1); bool wasInIsle = (oldWorldId == (int8_t) LegoOmni::e_act1);

View File

@ -42,6 +42,14 @@ extern "C"
} }
} }
EMSCRIPTEN_KEEPALIVE void mp_toggle_name_bubbles()
{
Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager();
if (mgr) {
mgr->RequestToggleNameBubbles();
}
}
} // extern "C" } // extern "C"
#endif #endif

View File

@ -1,5 +1,7 @@
#include "extensions/multiplayer/remoteplayer.h" #include "extensions/multiplayer/remoteplayer.h"
#include "extensions/multiplayer/namebubblerenderer.h"
#include "3dmanager/lego3dmanager.h" #include "3dmanager/lego3dmanager.h"
#include "anim/legoanim.h" #include "anim/legoanim.h"
#include "extensions/multiplayer/charactercloner.h" #include "extensions/multiplayer/charactercloner.h"
@ -14,6 +16,7 @@
#include "realtime/realtime.h" #include "realtime/realtime.h"
#include "roi/legoroi.h" #include "roi/legoroi.h"
#include <SDL3/SDL_log.h>
#include <SDL3/SDL_stdinc.h> #include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h> #include <SDL3/SDL_timer.h>
#include <cmath> #include <cmath>
@ -28,8 +31,9 @@ RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displ
m_walkAnimCache(nullptr), m_idleAnimCache(nullptr), m_animTime(0.0f), m_idleTime(0.0f), m_idleAnimTime(0.0f), 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_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_rideAnim(nullptr), m_rideRoiMap(nullptr), m_rideRoiMapSize(0), m_rideVehicleROI(nullptr), m_vehicleROI(nullptr),
m_currentVehicleType(VEHICLE_NONE) m_currentVehicleType(VEHICLE_NONE), m_nameBubble(nullptr)
{ {
m_displayName[0] = '\0';
const char* displayName = GetDisplayActorName(); const char* displayName = GetDisplayActorName();
SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", displayName, p_peerId); SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", displayName, p_peerId);
@ -82,6 +86,11 @@ void RemotePlayer::Spawn(LegoWorld* p_isleWorld)
// Build initial walk and idle animation caches // Build initial walk and idle animation caches
m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]); m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]);
m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]); m_idleAnimCache = GetOrBuildAnimCache(g_idleAnimNames[m_idleAnimId]);
// Create name bubble if we already have a name
if (m_displayName[0] != '\0') {
CreateNameBubble();
}
} }
void RemotePlayer::Despawn() void RemotePlayer::Despawn()
@ -90,6 +99,7 @@ void RemotePlayer::Despawn()
return; return;
} }
DestroyNameBubble();
ExitVehicle(); ExitVehicle();
if (m_roi) { if (m_roi) {
@ -136,6 +146,27 @@ void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg)
m_hasReceivedUpdate = true; m_hasReceivedUpdate = true;
} }
// Update display name (can change when player switches save file)
char newName[8];
SDL_memcpy(newName, p_msg.name, sizeof(newName));
newName[sizeof(newName) - 1] = '\0';
if (SDL_strcmp(m_displayName, newName) != 0) {
SDL_Log(
"RemotePlayer[%u] name changed: '%s' -> '%s' (spawned=%d)",
m_peerId,
m_displayName,
newName,
m_spawned
);
SDL_memcpy(m_displayName, newName, sizeof(m_displayName));
// Recreate bubble with new name (or create for the first time)
if (m_spawned) {
DestroyNameBubble();
CreateNameBubble();
}
}
// Swap walk animation if changed // Swap walk animation if changed
if (p_msg.walkAnimId != m_walkAnimId && p_msg.walkAnimId < g_walkAnimCount) { if (p_msg.walkAnimId != m_walkAnimId && p_msg.walkAnimId < g_walkAnimCount) {
m_walkAnimId = p_msg.walkAnimId; m_walkAnimId = p_msg.walkAnimId;
@ -158,6 +189,11 @@ void RemotePlayer::Tick(float p_deltaTime)
UpdateVehicleState(); UpdateVehicleState();
UpdateTransform(p_deltaTime); UpdateTransform(p_deltaTime);
UpdateAnimation(p_deltaTime); UpdateAnimation(p_deltaTime);
// Update name bubble position and billboard orientation
if (m_nameBubble) {
m_nameBubble->Update(m_roi);
}
} }
void RemotePlayer::ReAddToScene() void RemotePlayer::ReAddToScene()
@ -487,3 +523,28 @@ void RemotePlayer::ExitVehicle()
m_animTime = 0.0f; m_animTime = 0.0f;
m_wasMoving = false; m_wasMoving = false;
} }
void RemotePlayer::CreateNameBubble()
{
if (m_nameBubble || m_displayName[0] == '\0') {
return;
}
m_nameBubble = new NameBubbleRenderer();
m_nameBubble->Create(m_displayName);
}
void RemotePlayer::DestroyNameBubble()
{
if (m_nameBubble) {
delete m_nameBubble;
m_nameBubble = nullptr;
}
}
void RemotePlayer::SetNameBubbleVisible(bool p_visible)
{
if (m_nameBubble) {
m_nameBubble->SetVisible(p_visible);
}
}

View File

@ -1,9 +1,49 @@
#include "d3drmtexture_impl.h" #include "d3drmtexture_impl.h"
#include "ddsurface_impl.h"
#include "miniwin.h" #include "miniwin.h"
Direct3DRMTextureImpl::Direct3DRMTextureImpl(D3DRMIMAGE* image) Direct3DRMTextureImpl::Direct3DRMTextureImpl(D3DRMIMAGE* image) : m_holdsRef(true)
{ {
MINIWIN_NOT_IMPLEMENTED(); if (!image || !image->buffer1 || image->width == 0 || image->height == 0) {
m_surface = nullptr;
return;
}
auto* ddsurface = new DirectDrawSurfaceImpl(image->width, image->height, SDL_PIXELFORMAT_RGBA32);
if (!ddsurface->m_surface) {
ddsurface->Release();
m_surface = nullptr;
return;
}
uint8_t* dst = static_cast<uint8_t*>(ddsurface->m_surface->pixels);
int dstPitch = ddsurface->m_surface->pitch;
if (!image->rgb && image->palette && image->palette_size > 0) {
uint8_t* indices = static_cast<uint8_t*>(image->buffer1);
for (int y = 0; y < image->height; y++) {
for (int x = 0; x < image->width; x++) {
uint8_t idx = indices[y * image->bytes_per_line + x];
uint8_t* pixel = dst + y * dstPitch + x * 4;
if (idx < image->palette_size) {
const D3DRMPALETTEENTRY& e = image->palette[idx];
pixel[0] = e.red;
pixel[1] = e.green;
pixel[2] = e.blue;
// Palette index 0 is transparent by convention (color key)
pixel[3] = (idx == 0) ? 0 : 255;
}
else {
pixel[0] = pixel[1] = pixel[2] = pixel[3] = 0;
}
}
}
}
else {
SDL_memset(dst, 0, dstPitch * image->height);
}
m_surface = static_cast<IDirectDrawSurface*>(ddsurface);
} }
Direct3DRMTextureImpl::Direct3DRMTextureImpl(IDirectDrawSurface* surface, bool holdsRef) Direct3DRMTextureImpl::Direct3DRMTextureImpl(IDirectDrawSurface* surface, bool holdsRef)