From c943d2455d66fbe65bf43d10c8cfe8eb26408a9d Mon Sep 17 00:00:00 2001 From: foxtacles Date: Sat, 7 Mar 2026 10:38:33 -0800 Subject: [PATCH] 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) --- CMakeLists.txt | 1 + .../multiplayer/namebubblerenderer.h | 50 +++ .../extensions/multiplayer/networkmanager.h | 6 + .../include/extensions/multiplayer/protocol.h | 1 + .../extensions/multiplayer/remoteplayer.h | 9 +- .../src/multiplayer/namebubblerenderer.cpp | 335 ++++++++++++++++++ extensions/src/multiplayer/networkmanager.cpp | 35 +- .../platforms/emscripten/wasm_exports.cpp | 8 + extensions/src/multiplayer/remoteplayer.cpp | 63 +++- miniwin/src/d3drm/d3drmtexture.cpp | 44 ++- 10 files changed, 546 insertions(+), 6 deletions(-) create mode 100644 extensions/include/extensions/multiplayer/namebubblerenderer.h create mode 100644 extensions/src/multiplayer/namebubblerenderer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index edaecd66..272513bf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -534,6 +534,7 @@ if (ISLE_EXTENSIONS) extensions/src/multiplayer.cpp extensions/src/multiplayer/animutils.cpp extensions/src/multiplayer/charactercloner.cpp + extensions/src/multiplayer/namebubblerenderer.cpp extensions/src/multiplayer/networkmanager.cpp extensions/src/multiplayer/protocol.cpp extensions/src/multiplayer/remoteplayer.cpp diff --git a/extensions/include/extensions/multiplayer/namebubblerenderer.h b/extensions/include/extensions/multiplayer/namebubblerenderer.h new file mode 100644 index 00000000..27984a03 --- /dev/null +++ b/extensions/include/extensions/multiplayer/namebubblerenderer.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +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 diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index b1d0bdb8..3171690b 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -55,6 +55,9 @@ class NetworkManager : public MxCore { 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 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 OnWorldDisabled(LegoWorld* p_world); @@ -108,10 +111,13 @@ class NetworkManager : public MxCore { bool m_registered; std::atomic m_pendingToggleThirdPerson; + std::atomic m_pendingToggleNameBubbles; std::atomic m_pendingWalkAnim; std::atomic m_pendingIdleAnim; std::atomic m_pendingEmote; + bool m_showNameBubbles; + static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout static const int EXIT_ROOM_FULL = 10; diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index 2e92e6ba..eccb4204 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -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) + char name[8]; // Player display name (7 chars + null terminator) uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65) }; diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index a5aa51c2..8ed7e2f4 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -16,6 +16,8 @@ class LegoTreeNode; namespace Multiplayer { +class NameBubbleRenderer; + class RemotePlayer { public: 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; } int8_t GetWorldId() const { return m_targetWorldId; } uint32_t GetLastUpdateTime() const { return m_lastUpdateTime; } - void SetVisible(bool p_visible); void TriggerEmote(uint8_t p_emoteId); + void SetNameBubbleVisible(bool p_visible); + void CreateNameBubble(); + void DestroyNameBubble(); private: using AnimCache = AnimUtils::AnimCache; @@ -54,6 +58,7 @@ class RemotePlayer { uint8_t m_actorId; uint8_t m_displayActorIndex; char m_uniqueName[32]; + char m_displayName[8]; LegoROI* m_roi; bool m_spawned; @@ -99,6 +104,8 @@ class RemotePlayer { LegoROI* m_vehicleROI; int8_t m_currentVehicleType; + + NameBubbleRenderer* m_nameBubble; }; } // namespace Multiplayer diff --git a/extensions/src/multiplayer/namebubblerenderer.cpp b/extensions/src/multiplayer/namebubblerenderer.cpp new file mode 100644 index 00000000..13b89fe7 --- /dev/null +++ b/extensions/src/multiplayer/namebubblerenderer.cpp @@ -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 +#include + +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); +} diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 4f7125e7..b0fc9860 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -1,5 +1,6 @@ #include "extensions/multiplayer/networkmanager.h" +#include "legogamestate.h" #include "legomain.h" #include "legopathactor.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_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) + m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), + 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) { player->SetVisible(true); + player->SetNameBubbleVisible(m_showNameBubbles); } } } @@ -170,6 +172,7 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world) m_worldSync.SetInIsleWorld(false); for (auto& [peerId, player] : m_remotePlayers) { player->SetVisible(false); + player->SetNameBubbleVisible(false); } NotifyPlayerCountChanged(); @@ -206,6 +209,13 @@ void NetworkManager::ProcessPendingRequests() if (emote >= 0) { SendEmote(static_cast(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() @@ -266,6 +276,26 @@ void NetworkManager::BroadcastLocalState() msg.walkAnimId = m_localWalkAnimId; 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; if (displayIndex == DISPLAY_ACTOR_NONE) { 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); if (it->second->IsSpawned()) { it->second->SetVisible(bothInIsle); + it->second->SetNameBubbleVisible(bothInIsle && m_showNameBubbles); } bool wasInIsle = (oldWorldId == (int8_t) LegoOmni::e_act1); diff --git a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp index 153c4d94..e594d97e 100644 --- a/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/wasm_exports.cpp @@ -42,6 +42,14 @@ extern "C" } } + EMSCRIPTEN_KEEPALIVE void mp_toggle_name_bubbles() + { + Multiplayer::NetworkManager* mgr = MultiplayerExt::GetNetworkManager(); + if (mgr) { + mgr->RequestToggleNameBubbles(); + } + } + } // extern "C" #endif diff --git a/extensions/src/multiplayer/remoteplayer.cpp b/extensions/src/multiplayer/remoteplayer.cpp index ce978475..f788b140 100644 --- a/extensions/src/multiplayer/remoteplayer.cpp +++ b/extensions/src/multiplayer/remoteplayer.cpp @@ -1,5 +1,7 @@ #include "extensions/multiplayer/remoteplayer.h" +#include "extensions/multiplayer/namebubblerenderer.h" + #include "3dmanager/lego3dmanager.h" #include "anim/legoanim.h" #include "extensions/multiplayer/charactercloner.h" @@ -14,6 +16,7 @@ #include "realtime/realtime.h" #include "roi/legoroi.h" +#include #include #include #include @@ -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_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) + m_currentVehicleType(VEHICLE_NONE), m_nameBubble(nullptr) { + m_displayName[0] = '\0'; const char* displayName = GetDisplayActorName(); 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 m_walkAnimCache = GetOrBuildAnimCache(g_walkAnimNames[m_walkAnimId]); 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() @@ -90,6 +99,7 @@ void RemotePlayer::Despawn() return; } + DestroyNameBubble(); ExitVehicle(); if (m_roi) { @@ -136,6 +146,27 @@ void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg) 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 if (p_msg.walkAnimId != m_walkAnimId && p_msg.walkAnimId < g_walkAnimCount) { m_walkAnimId = p_msg.walkAnimId; @@ -158,6 +189,11 @@ void RemotePlayer::Tick(float p_deltaTime) UpdateVehicleState(); UpdateTransform(p_deltaTime); UpdateAnimation(p_deltaTime); + + // Update name bubble position and billboard orientation + if (m_nameBubble) { + m_nameBubble->Update(m_roi); + } } void RemotePlayer::ReAddToScene() @@ -487,3 +523,28 @@ void RemotePlayer::ExitVehicle() m_animTime = 0.0f; 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); + } +} diff --git a/miniwin/src/d3drm/d3drmtexture.cpp b/miniwin/src/d3drm/d3drmtexture.cpp index a5d63588..cc4dbe2b 100644 --- a/miniwin/src/d3drm/d3drmtexture.cpp +++ b/miniwin/src/d3drm/d3drmtexture.cpp @@ -1,9 +1,49 @@ #include "d3drmtexture_impl.h" +#include "ddsurface_impl.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(ddsurface->m_surface->pixels); + int dstPitch = ddsurface->m_surface->pitch; + + if (!image->rgb && image->palette && image->palette_size > 0) { + uint8_t* indices = static_cast(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(ddsurface); } Direct3DRMTextureImpl::Direct3DRMTextureImpl(IDirectDrawSurface* surface, bool holdsRef)