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/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/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/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/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/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(); } } } 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 + }} + +