From d2f0e891b68c8e3227e94ce6e467d9725a390823 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 22 Oct 2026 16:51:35 +0200 Subject: [PATCH 1/3] Keep kicked players' names in everyone's player list PartyService::OnPartyKick called BroadcastPlayerList(pKick). That argument is meant for a player who is leaving the server, so every client dropped the kicked (still connected) player from NotifyPlayerList and the kicked player got no list at all. The kick now broadcasts the full list. The client's PartyService also keeps the last name seen per player id for the session (GetKnownName), so a player who drops out of the list is still shown by name. It is cleared on disconnect. Verified: - Source: the server kick path and BroadcastPlayerList's ignore argument (also used by OnPlayerLeave, which is unchanged). - SkyrimTogetherServer and SkyrimTogetherClient build (releasedbg, MSVC). - Played on the fork's 1.8-playtest builds. Co-Authored-By: Claude Opus 5.5 --- Code/client/Services/Generic/PartyService.cpp | 4 ++++ Code/client/Services/PartyService.h | 7 +++++++ Code/server/Services/PartyService.cpp | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Code/client/Services/Generic/PartyService.cpp b/Code/client/Services/Generic/PartyService.cpp index 60d17fc54..466525f3e 100644 --- a/Code/client/Services/Generic/PartyService.cpp +++ b/Code/client/Services/Generic/PartyService.cpp @@ -103,12 +103,16 @@ void PartyService::OnDisconnected(const DisconnectedEvent& acEvent) noexcept { DestroyParty(); m_invitations.clear(); + m_knownNames.clear(); m_nextUpdate = 0; } void PartyService::OnPlayerList(const NotifyPlayerList& acPlayerList) noexcept { m_players = acPlayerList.Players; + + for (const auto& [playerId, name] : m_players) + m_knownNames[playerId] = name; } void PartyService::OnPartyInfo(const NotifyPartyInfo& acPartyInfo) noexcept diff --git a/Code/client/Services/PartyService.h b/Code/client/Services/PartyService.h index f3ee2f310..730c9339b 100644 --- a/Code/client/Services/PartyService.h +++ b/Code/client/Services/PartyService.h @@ -27,6 +27,12 @@ struct PartyService const Vector& GetPartyMembers() const noexcept { return m_partyMembers; } const Map& GetPlayers() const noexcept { return m_players; } + // Last name seen for a player id this session, kept after they drop out of the player list. + [[nodiscard]] const String* GetKnownName(uint32_t aPlayerId) const noexcept + { + auto it = m_knownNames.find(aPlayerId); + return it != m_knownNames.end() && !it->second.empty() ? &it->second : nullptr; + } Map& GetInvitations() noexcept { return m_invitations; } [[nodiscard]] bool HasPendingInvitations() const noexcept { return !m_invitations.empty(); } @@ -50,6 +56,7 @@ struct PartyService void DestroyParty() noexcept; Map m_players; + Map m_knownNames; Map m_invitations; uint64_t m_nextUpdate{0}; diff --git a/Code/server/Services/PartyService.cpp b/Code/server/Services/PartyService.cpp index 5fc357781..f46305fff 100644 --- a/Code/server/Services/PartyService.cpp +++ b/Code/server/Services/PartyService.cpp @@ -190,7 +190,8 @@ void PartyService::OnPartyKick(const PacketEvent& acPacket) no { spdlog::debug("[PartyService]: Kicking player {} from party", pKick->GetId()); RemovePlayerFromParty(pKick); - BroadcastPlayerList(pKick); + // The kicked player is still connected: everyone, them included, gets the full list. + BroadcastPlayerList(); } } } From f618aefab041cfef1ac59fafc9c99a1949949f3d Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 22 Oct 2026 16:51:36 +0200 Subject: [PATCH 2/3] Add player name tags with privacy settings Name tags (ImGui, NameTagService): - Plain name text above other players, no level or id. The party leader gets the party widget's crown (crown.svg, drawn as convex pieces). An optional thin "Name backdrop" (On/Party/Off, default Party). - Anchored on the neck bone ("NPC Neck [Neck]") plus the head height, so tags stay centred in profile, crouching and lying down, and stay on the body if a dismemberment mod removes the head. Helmet clearance of 16 world units, clamped to 14-56 px; text scale clamp(800 / distance, 0.65, 1.2). - Fade start / invisible distances for names and for party (3000 / 5000). - A tag hides while the crosshair targets that player, so the HUD's activate prompt stays readable. - Hidden while full-screen menus are open; dead players only up close. - Font: the overlay's Futura Condensed (Latin, Cyrillic). Player UI settings (Angular, stored per client): - Known names: All / Party / None. Unknown names show as an italic "Stranger" in tags, the player list, the party HUD, the party menu and party invites. An eye in the player list overrides it per username. - Party invitations (on/off), Preview mini chat (On/Party/Off), Name tags (On/Party/Off, default Party), name tag size. - Locales: English and Swedish; other languages fall back to English. Verified: - Ghidra (SkyrimSE 1.6.1170): - NiAVObject world translate at +0xA0 (decapitation code 0x140685200 walks parent +0x30, local transform +0x48, world +0x7C, reads +0xA0/A4/A8). - NiAVObject::GetByName is vtable slot 0x2A (+0x150), called with FixedStrings entries (0x1406b5c10); "NPC Neck [Neck]" is FixedStrings +0x238. - CrosshairPickData (ID 401585 -> 0x143138ed0): the activate code 0x14073e980 reads the target handle at +0x4. - World-to-screen (ID 70640, 0x140d2c780) only rejects |w| <= tolerance, so the z <= epsilon check is what drops points behind the camera. - SkyrimTogetherClient, SkyrimTogetherServer and the Angular production build succeed. Played on the fork's 1.8-playtest builds. Assumed: - Neck-to-head-top (24 units) and helmet clearance (16 units) are tuned by eye in game, not measured. - Actor nodes and camera globals are read from the ImGui draw (Present path), as the original v1.8.1 name tags did; the thread was not verified. Co-Authored-By: Claude Opus 5.5 --- .../Games/Skyrim/Misc/CrosshairPickData.cpp | 10 + .../Games/Skyrim/Misc/CrosshairPickData.h | 16 + .../Games/Skyrim/NetImmerse/NiAVObject.h | 7 +- Code/client/Services/Generic/ImguiService.cpp | 17 + .../Services/Generic/NameTagService.cpp | 382 ++++++++++++++++++ .../client/Services/Generic/OverlayClient.cpp | 57 +++ Code/client/Services/ImguiService.h | 7 + Code/client/Services/NameTagService.h | 97 +++++ Code/client/Services/OverlayClient.h | 1 + Code/client/World.cpp | 2 + .../app/components/group/group.component.html | 11 +- .../app/components/group/group.component.scss | 5 + .../app/components/group/group.component.ts | 3 + .../notifications/notifications.component.ts | 9 +- .../party-menu/party-menu.component.html | 51 ++- .../party-menu/party-menu.component.scss | 5 + .../party-menu/party-menu.component.ts | 3 + .../player-list/player-list.component.html | 35 +- .../player-list/player-list.component.scss | 21 + .../player-list/player-list.component.ts | 10 + .../src/app/components/root/root.component.ts | 3 + .../settings/settings.component.html | 177 ++++++++ .../settings/settings.component.scss | 6 + .../components/settings/settings.component.ts | 31 ++ .../src/app/mock/skyrimtogether.mock.ts | 4 + .../src/app/services/client.service.ts | 7 + .../src/app/services/player-list.service.ts | 13 +- .../src/app/services/player-ui.service.ts | 173 ++++++++ .../src/app/services/setting.service.ts | 116 ++++++ Code/skyrim_ui/src/assets/i18n/en.json | 36 ++ Code/skyrim_ui/src/assets/i18n/sv.json | 36 ++ Code/skyrim_ui/src/typings.d.ts | 23 ++ Code/tp_process/ProcessHandler.cpp | 1 + 33 files changed, 1354 insertions(+), 21 deletions(-) create mode 100644 Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp create mode 100644 Code/client/Games/Skyrim/Misc/CrosshairPickData.h create mode 100644 Code/client/Services/Generic/NameTagService.cpp create mode 100644 Code/client/Services/NameTagService.h create mode 100644 Code/skyrim_ui/src/app/services/player-ui.service.ts diff --git a/Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp b/Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp new file mode 100644 index 000000000..316974646 --- /dev/null +++ b/Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp @@ -0,0 +1,10 @@ +#include + +#include + +CrosshairPickData* CrosshairPickData::Get() +{ + POINTER_SKYRIMSE(CrosshairPickData*, s_instance, 401585); + + return *s_instance.Get(); +} diff --git a/Code/client/Games/Skyrim/Misc/CrosshairPickData.h b/Code/client/Games/Skyrim/Misc/CrosshairPickData.h new file mode 100644 index 000000000..596cc892a --- /dev/null +++ b/Code/client/Games/Skyrim/Misc/CrosshairPickData.h @@ -0,0 +1,16 @@ +#pragma once + +// What the crosshair points at; Target is the ref the HUD's activate prompt ("E Talk") is for. +struct CrosshairPickData +{ + static CrosshairPickData* Get(); + + uint32_t pad0; + uint32_t Target; // ObjectRefHandle + uint32_t TargetActor; // ObjectRefHandle + uint8_t padC[0x38 - 0xC]; +}; + +static_assert(offsetof(CrosshairPickData, Target) == 0x4); +static_assert(offsetof(CrosshairPickData, TargetActor) == 0x8); +static_assert(sizeof(CrosshairPickData) == 0x38); diff --git a/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h b/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h index 9dcbd4fac..ce5b6775d 100644 --- a/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h +++ b/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h @@ -1,6 +1,7 @@ #pragma once #include +#include struct BSFixedString; @@ -14,7 +15,11 @@ struct NiAVObject : NiObjectNET virtual void sub_29(); virtual NiAVObject* GetByName(BSFixedString& aName); - uint8_t pad30[0x110 - 0x30]; + uint8_t pad30[0xA0 - 0x30]; + // Translation of the world transform (which starts at 0x7C). + NiPoint3 worldTranslate; + uint8_t padAC[0x110 - 0xAC]; }; +static_assert(offsetof(NiAVObject, worldTranslate) == 0xA0); static_assert(sizeof(NiAVObject) == 0x110); diff --git a/Code/client/Services/Generic/ImguiService.cpp b/Code/client/Services/Generic/ImguiService.cpp index 8faba9c50..3cd8a34c0 100644 --- a/Code/client/Services/Generic/ImguiService.cpp +++ b/Code/client/Services/Generic/ImguiService.cpp @@ -22,6 +22,7 @@ ImguiService::~ImguiService() noexcept void ImguiService::Create(RenderSystemD3D11* apRenderSystem, HWND aHwnd) { m_imDriver.Initialize(static_cast(aHwnd)); + LoadNameTagFont(); // init platform if (!ImGui_ImplWin32_Init(aHwnd)) @@ -30,6 +31,22 @@ void ImguiService::Create(RenderSystemD3D11* apRenderSystem, HWND aHwnd) ImGui_ImplDX11_Init(apRenderSystem->GetDevice(), apRenderSystem->GetDeviceContext()); } +void ImguiService::LoadNameTagFont() +{ + // Same font file the overlay UI ships with. Loaded large so tags stay sharp when scaled down. + const auto fontPath = TiltedPhoques::GetPath() / "UI" / "assets" / "futura-condensed-medium.otf"; + if (!std::filesystem::exists(fontPath)) + { + spdlog::warn("Name tag font not found at {}, using the default ImGui font", fontPath.string()); + return; + } + + auto& io = ImGui::GetIO(); + m_pNameTagFont = io.Fonts->AddFontFromFileTTF(fontPath.string().c_str(), 40.f, nullptr, io.Fonts->GetGlyphRangesCyrillic()); + if (!m_pNameTagFont) + spdlog::warn("Failed to load name tag font {}", fontPath.string()); +} + void ImguiService::Render() const { ImGui_ImplDX11_NewFrame(); diff --git a/Code/client/Services/Generic/NameTagService.cpp b/Code/client/Services/Generic/NameTagService.cpp new file mode 100644 index 000000000..30d1a83a6 --- /dev/null +++ b/Code/client/Services/Generic/NameTagService.cpp @@ -0,0 +1,382 @@ +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace +{ +float Clamp01(float aValue) noexcept +{ + return std::clamp(aValue, 0.f, 1.f); +} + +float SmoothStep(float aEdge0, float aEdge1, float aValue) noexcept +{ + if (aEdge0 == aEdge1) + return aValue >= aEdge1 ? 1.f : 0.f; + + const float t = Clamp01((aValue - aEdge0) / (aEdge1 - aEdge0)); + return t * t * (3.f - 2.f * t); +} + +bool HasAllGlyphs(ImFont* apFont, const String& acText) noexcept +{ + const char* pText = acText.c_str(); + const char* pEnd = pText + acText.size(); + while (pText < pEnd) + { + unsigned int codepoint = 0; + pText += ImTextCharFromUtf8(&codepoint, pText, pEnd); + if (codepoint < 0x80) + continue; + if (!apFont->FindGlyphNoFallback(static_cast(codepoint))) + return false; + } + return true; +} + +// The party widget's leader crown (skyrim_ui assets/images/group/crown.svg, a 26x26 box), drawn as +// convex pieces since ImGui 1.89 can't fill concave paths. Points are clockwise, as ImGui's AA fill expects. +void DrawPartyCrown(ImDrawList* apDrawList, ImVec2 aOrigin, float aSize, ImU32 aColor) noexcept +{ + const float s = aSize / 26.f; + auto at = [&](float aX, float aY) { return ImVec2(aOrigin.x + aX * s, aOrigin.y + aY * s); }; + + const ImVec2 centerSpike[] = {at(12.09f, 5.78f), at(13.91f, 5.78f), at(18.f, 14.f), at(8.f, 14.f)}; + const ImVec2 leftSpike[] = {at(2.41f, 10.97f), at(3.53f, 10.28f), at(8.f, 14.f), at(4.09f, 19.f)}; + const ImVec2 rightSpike[] = {at(21.91f, 19.f), at(18.f, 14.f), at(22.47f, 10.28f), at(23.59f, 10.97f)}; + const ImVec2 band[] = {at(8.f, 14.f), at(18.f, 14.f), at(21.91f, 19.f), at(4.09f, 19.f)}; + apDrawList->AddConvexPolyFilled(centerSpike, 4, aColor); + apDrawList->AddConvexPolyFilled(leftSpike, 4, aColor); + apDrawList->AddConvexPolyFilled(rightSpike, 4, aColor); + apDrawList->AddConvexPolyFilled(band, 4, aColor); + + apDrawList->AddCircleFilled(at(13.f, 4.f), 2.f * s, aColor, 12); + apDrawList->AddCircleFilled(at(2.f, 9.f), 2.f * s, aColor, 12); + apDrawList->AddCircleFilled(at(24.f, 9.f), 2.f * s, aColor, 12); + + apDrawList->AddRectFilled(at(4.f, 21.f), at(22.f, 24.f), aColor, 1.5f * s); +} + +// Text with a 1 px shadow; ImGui has no italic style, so italics shear the glyph quads around the baseline. +void DrawShadowedText(ImDrawList* apDrawList, ImFont* apFont, float aSize, ImVec2 aPos, const char* apBegin, const char* apEnd, ImU32 aColor, ImU32 aShadowColor, bool aItalic) noexcept +{ + const int firstVertex = apDrawList->VtxBuffer.Size; + apDrawList->AddText(apFont, aSize, ImVec2(aPos.x + 1.f, aPos.y + 1.f), aShadowColor, apBegin, apEnd); + apDrawList->AddText(apFont, aSize, aPos, aColor, apBegin, apEnd); + + if (aItalic) + { + const float baseline = aPos.y + aSize; + for (int i = firstVertex; i < apDrawList->VtxBuffer.Size; ++i) + apDrawList->VtxBuffer[i].pos.x += (baseline - apDrawList->VtxBuffer[i].pos.y) * NameTagService::kItalicShear; + } +} +} // namespace + +NameTagService::NameTagService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + auto& imgui = m_world.ctx().at(); + m_drawConnection = imgui.OnDraw.connect<&NameTagService::OnDraw>(this); + m_updateConnection = aDispatcher.sink().connect<&NameTagService::OnUpdate>(this); +} + +void NameTagService::OnUpdate(const UpdateEvent&) noexcept +{ + auto* pLocalPlayer = PlayerCharacter::Get(); + if (!pLocalPlayer) + { + m_visibility.clear(); + return; + } + + const uint64_t tick = m_world.GetTick(); + + auto view = m_world.view(); + for (auto entity : view) + { + const auto& playerComponent = view.get(entity); + const auto& formComponent = view.get(entity); + + Actor* pActor = Cast(TESForm::GetById(formComponent.Id)); + if (!pActor) + continue; + + const float dx = pLocalPlayer->position.x - pActor->position.x; + const float dy = pLocalPlayer->position.y - pActor->position.y; + const float dz = pLocalPlayer->position.z - pActor->position.z; + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + + bool isVisible = !pActor->IsDead(); + if (!isVisible && distance <= kNearBypassDistance) + isVisible = true; + + VisibilityInfo& info = m_visibility[playerComponent.Id]; + info.Visible = isVisible; + info.Tick = tick; + } + + constexpr uint64_t kMaxAgeMs = 2000; + for (auto it = m_visibility.begin(); it != m_visibility.end();) + { + if (tick - it->second.Tick > kMaxAgeMs) + it = m_visibility.erase(it); + else + ++it; + } +} + +void NameTagService::SetSettings(Settings aSettings) noexcept +{ + aSettings.NameFadeStart = std::max(aSettings.NameFadeStart, 0.f); + aSettings.NameInvisible = std::max(aSettings.NameInvisible, aSettings.NameFadeStart + 1.f); + aSettings.PartyFadeStart = std::max(aSettings.PartyFadeStart, 0.f); + aSettings.PartyInvisible = std::max(aSettings.PartyInvisible, aSettings.PartyFadeStart + 1.f); + aSettings.FontSize = std::clamp(aSettings.FontSize, 8.f, 48.f); + + m_settings = std::move(aSettings); +} + +void NameTagService::OnDraw() noexcept +{ + if (m_settings.NameTags == Filter::kOff) + return; + + auto* pLocalPlayer = PlayerCharacter::Get(); + if (!pLocalPlayer) + return; + + // Don't draw nametags while menus that cover the screen are open. + if (auto* pUI = UI::Get()) + { + auto menuOpen = [pUI](const char* acName) { + return acName && pUI->GetMenuOpen(BSFixedString(acName)); + }; + + if (menuOpen("Dialogue Menu") || menuOpen("InventoryMenu") || menuOpen("StatsMenu") || + menuOpen("SkillsMenu") || menuOpen("MagicMenu") || menuOpen("MapMenu") || menuOpen("TweenMenu") || + menuOpen("FavoritesMenu")) + { + return; + } + } + + auto view = m_world.view(); + if (view.begin() == view.end()) + return; + + const auto& partyService = m_world.GetPartyService(); + const auto& partyMembers = partyService.GetPartyMembers(); + + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + if (!drawList) + return; + + const ImVec2 viewport = ImGui::GetIO().DisplaySize; + const float uiScale = viewport.y / 1080.f; + ImFont* font = m_world.ctx().at().GetNameTagFont(); + if (!font) + font = ImGui::GetFont(); + + // The label comes translated from the UI; fall back to English when the + // tag font lacks its glyphs (e.g. CJK). + const char* strangerLabel = m_settings.StrangerLabel.c_str(); + if (!HasAllGlyphs(font, m_settings.StrangerLabel)) + strangerLabel = "Stranger"; + + const glm::vec3 localPos = {pLocalPlayer->position.x, pLocalPlayer->position.y, pLocalPlayer->position.z}; + + // The HUD's activate prompt shows over the player the crosshair is on, so their tag ducks. + const CrosshairPickData* pCrosshair = CrosshairPickData::Get(); + const uint32_t crosshairTarget = pCrosshair ? pCrosshair->Target : 0; + + for (auto entity : view) + { + const auto& playerComponent = view.get(entity); + const auto& formComponent = view.get(entity); + + Actor* pActor = Cast(TESForm::GetById(formComponent.Id)); + if (!pActor) + continue; + + const ActorExtension* pExtension = pActor->GetExtension(); + if (!pExtension || pExtension->IsLocalPlayer()) + continue; + + const bool isPartyMember = std::find(partyMembers.begin(), partyMembers.end(), playerComponent.Id) != partyMembers.end(); + if (!IsEnabledFor(m_settings.NameTags, isPartyMember)) + continue; + + if (!ShouldRenderTag(playerComponent.Id)) + continue; + + if (crosshairTarget != 0 && pActor->GetHandle().handle.iBits == crosshairTarget) + continue; + + // Names are cached for the session, so a player dropped from the player list (e.g. after a kick) + // keeps their name. Without one, the stranger label is shown; never a player id. + bool isNameRevealed = m_settings.RevealedPlayerIds.count(playerComponent.Id) > 0; + std::string displayName = strangerLabel; + if (isNameRevealed) + { + if (const String* pName = partyService.GetKnownName(playerComponent.Id)) + displayName = pName->c_str(); + else + isNameRevealed = false; + } + + const bool isLeader = isPartyMember && partyService.GetLeaderPlayerId() == playerComponent.Id; + + const NiPoint3 headTop = BuildHeadTopPoint(pActor); + + ImVec2 headScreen; + float depth = 0.f; + if (!ProjectWorldPoint(headTop, headScreen, depth, viewport)) + continue; + + const float fadeStart = isPartyMember ? m_settings.PartyFadeStart : m_settings.NameFadeStart; + const float invisible = isPartyMember ? m_settings.PartyInvisible : m_settings.NameInvisible; + + const float distance = glm::distance(localPos, glm::vec3{headTop.x, headTop.y, headTop.z}); + if (distance > invisible) + continue; + + float alpha = 1.f; + if (distance > fadeStart) + { + const float fadeT = Clamp01((distance - fadeStart) / (invisible - fadeStart)); + alpha = 1.f - SmoothStep(0.f, 1.f, fadeT); + } + alpha *= Clamp01(1.f - std::max(depth - 0.95f, 0.f) * 4.f); + if (alpha <= 0.01f) + continue; + + // Helmet clearance: a world-space gap, clamped on screen so close tags don't float and far ones don't sit on the head. + const float heightScale = std::clamp(pActor->GetHeight() / 128.f, 0.5f, 2.f); + NiPoint3 clearancePoint = headTop; + clearancePoint.z += kHelmetClearance * heightScale; + float gap = kMaxGapPx * uiScale; + ImVec2 clearanceScreen; + float clearanceDepth = 0.f; + if (ProjectWorldPoint(clearancePoint, clearanceScreen, clearanceDepth, viewport)) + gap = std::clamp(headScreen.y - clearanceScreen.y, kMinGapPx * uiScale, kMaxGapPx * uiScale); + + const float distanceScale = std::clamp(kScaleDistance / std::max(distance, 1.f), kMinScale, kMaxScale); + const float fontSize = m_settings.FontSize * kFontSizeScale * uiScale * distanceScale; + // Proportions below are tuned at the 22 px base size. + const float k = fontSize / 22.f; + + const ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.f, displayName.c_str(), displayName.c_str() + displayName.size()); + const float crownWidth = isLeader ? fontSize * 0.8f : 0.f; + const float crownGap = isLeader ? fontSize * 0.25f : 0.f; + const float paddingX = 5.f * k; + const float paddingY = 2.f * k; + const float contentWidth = crownWidth + crownGap + textSize.x; + + const ImVec2 bottomRight(headScreen.x + contentWidth * 0.5f + paddingX, headScreen.y - gap); + const ImVec2 topLeft(headScreen.x - contentWidth * 0.5f - paddingX, bottomRight.y - textSize.y - paddingY * 2.f); + + // A thin box with no border, so it reads as a backdrop rather than a plate. + if (IsEnabledFor(m_settings.Backdrop, isPartyMember)) + drawList->AddRectFilled(topLeft, bottomRight, ColorWithAlpha(ImVec4(0.f, 0.f, 0.f, 0.30f), alpha)); + + const ImU32 shadowColor = ColorWithAlpha(ImVec4(0.f, 0.f, 0.f, 0.8f), alpha); + const ImVec2 contentPos(topLeft.x + paddingX, topLeft.y + paddingY); + + if (isLeader) + { + // Same icon as the party widget, a square crownWidth wide, centred on the text. + const ImVec2 origin(contentPos.x, contentPos.y + (textSize.y - crownWidth) * 0.5f); + DrawPartyCrown(drawList, ImVec2(origin.x + 1.f, origin.y + 1.f), crownWidth, shadowColor); + DrawPartyCrown(drawList, origin, crownWidth, ColorWithAlpha(ImVec4(0.95f, 0.95f, 0.95f, 1.f), alpha)); + } + + const ImVec2 namePos(contentPos.x + crownWidth + crownGap, contentPos.y); + const ImVec4 nameColor = isNameRevealed ? ImVec4(0.95f, 0.95f, 0.95f, 1.f) : ImVec4(0.78f, 0.78f, 0.78f, 1.f); + + DrawShadowedText(drawList, font, fontSize, namePos, displayName.c_str(), displayName.c_str() + displayName.size(), ColorWithAlpha(nameColor, alpha), shadowColor, !isNameRevealed); + } +} + +bool NameTagService::ShouldRenderTag(uint32_t aPlayerId) const noexcept +{ + const uint64_t tick = m_world.GetTick(); + auto it = m_visibility.find(aPlayerId); + if (it == m_visibility.end()) + return true; + + constexpr uint64_t kStaleThresholdMs = 1500; + if (tick - it->second.Tick > kStaleThresholdMs) + return true; + + return it->second.Visible; +} + +bool NameTagService::ProjectWorldPoint(const NiPoint3& aWorldPoint, ImVec2& aScreenPos, float& aDepth, const ImVec2& aViewport) const noexcept +{ + NiPoint3 projected{}; + if (!HUDMenuUtils::WorldPtToScreenPt3(aWorldPoint, projected)) + return false; + + if (projected.z <= kVisibilityEpsilon || projected.z >= 1.f) + return false; + + aScreenPos.x = projected.x * aViewport.x; + aScreenPos.y = (1.f - projected.y) * aViewport.y; + aDepth = projected.z; + + return true; +} + +NiPoint3 NameTagService::BuildHeadTopPoint(Actor* apActor) const noexcept +{ + NiPoint3 point = apActor->position; + const float height = apActor->GetHeight(); + + // The neck bone follows crouching, sitting and lying down, and stays on the body when a + // dismemberment mod detaches the head. Never freed: the engine's string cache outlives us. + static BSFixedString* s_pNeckNodeName = new BSFixedString("NPC Neck [Neck]"); + if (NiNode* pRoot = apActor->GetNiNode()) + { + if (NiAVObject* pNeck = pRoot->GetByName(*s_pNeckNodeName)) + { + point = pNeck->worldTranslate; + point.z += kHeadTopAboveNeck * std::clamp(height / 128.f, 0.5f, 2.f); + return point; + } + } + + // No 3D yet: the top of the bounding box. + point.z += height; + return point; +} + +bool NameTagService::IsEnabledFor(Filter aFilter, bool aIsPartyMember) noexcept +{ + return aFilter == Filter::kOn || (aFilter == Filter::kParty && aIsPartyMember); +} + +ImU32 NameTagService::ColorWithAlpha(const ImVec4& aColor, float aAlpha) noexcept +{ + ImVec4 adjusted = aColor; + adjusted.w *= Clamp01(aAlpha); + return ImGui::ColorConvertFloat4ToU32(adjusted); +} diff --git a/Code/client/Services/Generic/OverlayClient.cpp b/Code/client/Services/Generic/OverlayClient.cpp index a0378612f..938c68089 100644 --- a/Code/client/Services/Generic/OverlayClient.cpp +++ b/Code/client/Services/Generic/OverlayClient.cpp @@ -11,6 +11,8 @@ #include +#include + #include OverlayClient::OverlayClient(TransportService& aTransport, TiltedPhoques::OverlayRenderHandler* apHandler) @@ -47,6 +49,8 @@ bool OverlayClient::OnProcessMessageReceived(CefRefPtr browser, CefR ProcessDisconnectMessage(); else if (eventName == "revealPlayers") ProcessRevealPlayersMessage(); + else if (eventName == "setNameTagSettings") + ProcessSetNameTagSettings(eventArgs); else if (eventName == "sendMessage") ProcessChatMessage(eventArgs); else if (eventName == "setTime") @@ -167,3 +171,56 @@ void OverlayClient::SetUIVisible(bool aVisible) noexcept World::Get().GetOverlayService().SetActive(aVisible); pRenderer->SetCursorVisible(aVisible); } + +void OverlayClient::ProcessSetNameTagSettings(CefRefPtr aEventArgs) +{ + auto pDict = aEventArgs->GetDictionary(0); + if (!pDict) + return; + + // JS numbers arrive as int or double depending on their value. + auto getNumber = [&pDict](const char* acKey, float aDefault) { + switch (pDict->GetType(acKey)) + { + case VTYPE_INT: return static_cast(pDict->GetInt(acKey)); + case VTYPE_DOUBLE: return static_cast(pDict->GetDouble(acKey)); + default: return aDefault; + } + }; + // A missing key keeps the setting's default. + auto getFilter = [&getNumber](const char* acKey, NameTagService::Filter aDefault) { + const int value = static_cast(getNumber(acKey, static_cast(aDefault))); + return static_cast(std::clamp(value, 0, 2)); + }; + + NameTagService::Settings settings{}; + settings.NameTags = getFilter("nameTags", settings.NameTags); + settings.Backdrop = getFilter("backdrop", settings.Backdrop); + settings.FontSize = getNumber("fontSize", settings.FontSize); + settings.NameFadeStart = getNumber("nameFadeStart", settings.NameFadeStart); + settings.NameInvisible = getNumber("nameInvisible", settings.NameInvisible); + settings.PartyFadeStart = getNumber("partyFadeStart", settings.PartyFadeStart); + settings.PartyInvisible = getNumber("partyInvisible", settings.PartyInvisible); + + const std::string strangerLabel = pDict->GetString("strangerLabel").ToString(); + if (!strangerLabel.empty()) + settings.StrangerLabel = strangerLabel.c_str(); + + auto readIds = [&pDict](const char* acKey, Set& aIds) { + auto pIds = pDict->GetList(acKey); + if (!pIds) + return; + + for (size_t i = 0; i < pIds->GetSize(); ++i) + { + const auto type = pIds->GetType(i); + if (type == VTYPE_INT) + aIds.insert(static_cast(pIds->GetInt(i))); + else if (type == VTYPE_DOUBLE) + aIds.insert(static_cast(pIds->GetDouble(i))); + } + }; + readIds("revealedPlayerIds", settings.RevealedPlayerIds); + + World::Get().GetRunner().Queue([settings = std::move(settings)]() mutable { World::Get().ctx().at().SetSettings(std::move(settings)); }); +} diff --git a/Code/client/Services/ImguiService.h b/Code/client/Services/ImguiService.h index 036eab2c6..5594298fc 100644 --- a/Code/client/Services/ImguiService.h +++ b/Code/client/Services/ImguiService.h @@ -4,6 +4,7 @@ struct RenderSystemD3D9; struct RenderSystemD3D11; +struct ImFont; /** * @brief Draws the ImGui UI. @@ -25,9 +26,15 @@ struct ImguiService LRESULT WndProcHandler(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); void RawInputHandler(RAWINPUT& aRawinput); + // Skyrim Together UI font for in-world name tags, or null if it couldn't be loaded. + [[nodiscard]] ImFont* GetNameTagFont() const noexcept { return m_pNameTagFont; } + entt::sink> OnDraw; private: + void LoadNameTagFont(); + ImGuiImpl::ImGuiDriver m_imDriver; entt::sigh m_drawSignal; + ImFont* m_pNameTagFont = nullptr; }; diff --git a/Code/client/Services/NameTagService.h b/Code/client/Services/NameTagService.h new file mode 100644 index 000000000..937eea85b --- /dev/null +++ b/Code/client/Services/NameTagService.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include + +#include + +#include + +struct World; +struct PlayerCharacter; +struct Actor; +struct NiPoint3; +struct ImguiService; + +/** + * @brief Draws 3D-aware name tags above other players' heads. + * + * Plain name text (a crown marks the party leader), scaled with distance. + * Which tags show, and how, is set by the "Player UI filters" settings in the + * overlay (see SetSettings). + */ +struct NameTagService +{ + // Matches the order of PlayerUiFilter in the UI. + enum class Filter : uint8_t + { + kOn = 0, + kParty = 1, + kOff = 2, + }; + + struct Settings + { + Filter NameTags = Filter::kParty; + Filter Backdrop = Filter::kParty; + float FontSize = 16.f; + float NameFadeStart = 3000.f; + float NameInvisible = 5000.f; + float PartyFadeStart = 3000.f; + float PartyInvisible = 5000.f; + // Drawn in italics instead of the name of players whose name is hidden. + String StrangerLabel = "Stranger"; + // Players whose real name may be shown. The UI decides this from the + // stranger name setting, party membership and the per-name eye. + Set RevealedPlayerIds; + }; + + NameTagService(World& aWorld, entt::dispatcher& aDispatcher) noexcept; + ~NameTagService() noexcept = default; + + TP_NOCOPYMOVE(NameTagService); + + void SetSettings(Settings aSettings) noexcept; + + // Tunables (kept few on purpose). Pixel values are at 1080p and scale with the screen height. + static constexpr float kNearBypassDistance = 600.f; + // The tag anchors to the neck bone, which stays on the body when dismemberment mods remove the head. + // World units from the neck bone to the top of the head, for a 128-unit tall actor. + static constexpr float kHeadTopAboveNeck = 24.f; + // Clearance above the head for helmets, in world units, clamped on screen to [kMinGapPx, kMaxGapPx]. + static constexpr float kHelmetClearance = 16.f; + static constexpr float kMinGapPx = 14.f; + static constexpr float kMaxGapPx = 56.f; + // Text scale = clamp(kScaleDistance / distance, kMinScale, kMaxScale). + static constexpr float kScaleDistance = 800.f; + static constexpr float kMinScale = 0.65f; + static constexpr float kMaxScale = 1.2f; + // UI font sizes are tuned for the overlay; tags match the HUD's activate prompt (16 -> 22 px). + static constexpr float kFontSizeScale = 1.375f; + static constexpr float kItalicShear = 0.2f; + static constexpr float kVisibilityEpsilon = 1e-3f; + +private: + struct VisibilityInfo + { + bool Visible = true; + uint64_t Tick = 0; + }; + + void OnDraw() noexcept; + void OnUpdate(const UpdateEvent& acEvent) noexcept; + + [[nodiscard]] bool ShouldRenderTag(uint32_t aPlayerId) const noexcept; + [[nodiscard]] bool ProjectWorldPoint(const NiPoint3& aWorldPoint, ImVec2& aScreenPos, float& aDepth, const ImVec2& aViewport) const noexcept; + [[nodiscard]] NiPoint3 BuildHeadTopPoint(Actor* apActor) const noexcept; + [[nodiscard]] static bool IsEnabledFor(Filter aFilter, bool aIsPartyMember) noexcept; + [[nodiscard]] static ImU32 ColorWithAlpha(const ImVec4& aColor, float aAlpha) noexcept; + + World& m_world; + + Settings m_settings; + std::unordered_map m_visibility; + + entt::scoped_connection m_drawConnection; + entt::scoped_connection m_updateConnection; +}; diff --git a/Code/client/Services/OverlayClient.h b/Code/client/Services/OverlayClient.h index 0c71ac87d..65e441e2c 100644 --- a/Code/client/Services/OverlayClient.h +++ b/Code/client/Services/OverlayClient.h @@ -30,6 +30,7 @@ struct OverlayClient : TiltedPhoques::OverlayClient void ProcessTeleportMessage(CefRefPtr aEventArgs); void ProcessToggleDebugUI(); void ProcessOpenPlayGuide(); + void ProcessSetNameTagSettings(CefRefPtr aEventArgs); void SetUIVisible(bool aVisible) noexcept; TransportService& m_transport; diff --git a/Code/client/World.cpp b/Code/client/World.cpp index b0596604b..a1830d8d0 100644 --- a/Code/client/World.cpp +++ b/Code/client/World.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -54,6 +55,7 @@ World::World() ctx().emplace(*this, m_transport, m_dispatcher); ctx().emplace(*this, m_transport, m_dispatcher); ctx().emplace(*this, m_dispatcher, m_transport); + ctx().emplace(*this, m_dispatcher); BehaviorVar::Get()->Init(); } diff --git a/Code/skyrim_ui/src/app/components/group/group.component.html b/Code/skyrim_ui/src/app/components/group/group.component.html index 522ea6325..26df57b3d 100644 --- a/Code/skyrim_ui/src/app/components/group/group.component.html +++ b/Code/skyrim_ui/src/app/components/group/group.component.html @@ -13,7 +13,16 @@ {{ player.level }}
- {{ player.name }} + {{ player.name }} + + {{ + 'COMPONENT.PARTY_LIST.STRANGER' | transloco + }} +
; group$: Observable; + hiddenPlayerIds$ = this.playerUiService.hiddenPlayerIds$; public isAutoHide = new BehaviorSubject(true); public isShown = new BehaviorSubject(true); @@ -51,6 +53,7 @@ export class GroupComponent implements OnInit, OnDestroy { private readonly groupService: GroupService, private readonly clientService: ClientService, private readonly settingService: SettingService, + private readonly playerUiService: PlayerUiService, ) { this.isShown.next(this.settings.isPartyShown.getValue()); this.isAutoHide.next(this.settings.autoHideParty.getValue()); diff --git a/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts b/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts index f94900071..50cb3065d 100644 --- a/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts +++ b/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts @@ -1,7 +1,8 @@ import { Component } from '@angular/core'; -import { takeUntil } from 'rxjs'; +import { filter, takeUntil } from 'rxjs'; import { ChatMessage, ChatService } from 'src/app/services/chat.service'; import { DestroyService } from '../../services/destroy.service'; +import { PlayerUiService } from '../../services/player-ui.service'; interface Notification extends ChatMessage { timer: number; @@ -19,9 +20,13 @@ export class NotificationsComponent { public constructor( private readonly destroy$: DestroyService, private readonly chatService: ChatService, + private readonly playerUiService: PlayerUiService, ) { this.chatService.messageList - .pipe(takeUntil(this.destroy$)) + .pipe( + filter(message => this.playerUiService.showsInMiniChat(message)), + takeUntil(this.destroy$), + ) .subscribe(message => { this.notifications.push({ ...message, diff --git a/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html b/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html index dda94c2de..962ea23a1 100644 --- a/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html +++ b/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html @@ -36,19 +36,32 @@ {{ 'COMPONENT.PARTY_MENU.INVITES' | transloco }}
-
-
- {{ 'COMPONENT.PARTY_MENU.NO_INVITES' | transloco }} -
- +
+ +
+ {{ 'COMPONENT.PARTY_MENU.NO_INVITES' | transloco }} +
+ +
@@ -79,7 +92,17 @@ {{ player.level }} - {{ player.name }} + + {{ player.name }} + + {{ + 'COMPONENT.PARTY_LIST.STRANGER' | transloco + }} + + {{ player.cellName }} + + + {{ + player.name + }} + + {{ + 'COMPONENT.PARTY_LIST.STRANGER' | transloco + }} + +