From fbe6880deed02c1045a9375f46d8dbe7a6f36335 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Thu, 15 Oct 2026 12:27:34 -0400 Subject: [PATCH 1/5] Widgets: World text markup, layout and draw Layout and drawing for world-anchored labels: inline colour/bold/italic markup, wrapping, alignment, outline, shadow and a background plate, drawn from a laid-out block at any size. Glyphs are looked up on every draw, since their UVs move when the atlas repacks. --- .../imgui/widgets/world_text_layout.h | 346 ++++++++++++++++++ .../imgui/widgets/world_text_markup.h | 165 +++++++++ code/tests/CMakeLists.txt | 2 +- code/tests/framework_ut.cpp | 4 + code/tests/modules/world_text_layout_ut.h | 129 +++++++ code/tests/modules/world_text_ut.h | 88 +++++ 6 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 code/framework/src/external/imgui/widgets/world_text_layout.h create mode 100644 code/framework/src/external/imgui/widgets/world_text_markup.h create mode 100644 code/tests/modules/world_text_layout_ut.h create mode 100644 code/tests/modules/world_text_ut.h diff --git a/code/framework/src/external/imgui/widgets/world_text_layout.h b/code/framework/src/external/imgui/widgets/world_text_layout.h new file mode 100644 index 000000000..f67a1263c --- /dev/null +++ b/code/framework/src/external/imgui/widgets/world_text_layout.h @@ -0,0 +1,346 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "world_text.h" +#include "world_text_markup.h" + +#include + +#include +#include +#include +#include +#include + +namespace Framework::External::ImGUI::Widgets { + enum class WorldTextAlign : uint8_t { + Left, + Center, + Right, + }; + + // Which point of the text block sits on the projected anchor. + enum class WorldTextAnchor : uint8_t { + Center, + Bottom, + Top, + }; + + // A missing bold or italic face is synthesized from the nearest one that exists. + struct WorldTextFonts { + ImFont *regular = nullptr; // null = the frame's current font + ImFont *bold = nullptr; + ImFont *italic = nullptr; + ImFont *boldItalic = nullptr; + }; + + struct WorldTextSpan { + ImFont *font = nullptr; + std::optional color; // 0xAARRGGBB; unset = the look's colour + bool fauxBold = false; + bool fauxItalic = false; + }; + + struct WorldTextGlyph { + ImWchar codepoint = 0; + uint16_t span = 0; + float x = 0.0f; // pen position from the block's top-left, in bakeSize pixels + float y = 0.0f; + float advance = 0.0f; + }; + + // Text laid out at one baked pixel size. It only holds code points and pen positions: glyph UVs + // move whenever the atlas repacks, so they are looked up again on every draw. + struct WorldTextLaidOut { + float bakeSize = 0.0f; + float width = 0.0f; + float height = 0.0f; + std::vector spans; + std::vector glyphs; + }; + + struct WorldTextLook { + uint32_t color = 0xFFFFFFFF; // all colours 0xAARRGGBB + float outlineWidth = 0.0f; + uint32_t outlineColor = 0xFF000000; + float shadowX = 0.0f; + float shadowY = 0.0f; + float shadowBlur = 0.0f; + uint32_t shadowColor = 0; // alpha 0 = no shadow + uint32_t backgroundColor = 0; // alpha 0 = no plate + float backgroundPadding = 4.0f; + float backgroundRadius = 4.0f; + WorldTextAnchor anchor = WorldTextAnchor::Center; + }; + + constexpr float kWorldTextMaxOutline = 4.0f; + constexpr float kWorldTextMaxBlur = 8.0f; + + inline ImU32 WorldTextArgb(uint32_t argb) { + return IM_COL32((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF, (argb >> 24) & 0xFF); + } + + // Exact up to 24 px, then a ~8% ladder: a size that changes every frame (world-scaled text) + // would otherwise rasterize a fresh copy of every glyph at each whole pixel it passes. + inline float WorldTextBakeSize(float size) { + constexpr float kExactUpTo = 24.0f; + constexpr float kMaxBake = 256.0f; + if (!(size > 0.0f)) { + return 0.0f; + } + if (size <= kExactUpTo) { + return std::max(1.0f, std::round(size)); + } + const float step = std::round(std::log(std::min(size, kMaxBake) / kExactUpTo) / std::log(1.08f)); + return std::min(kMaxBake, std::round(kExactUpTo * std::pow(1.08f, step))); + } + + inline float WorldTextBoldOffset(float bakeSize) { + return std::max(1.0f, bakeSize / 24.0f); + } + + // wrapWidth and the result are in bakeSize pixels; wrapWidth <= 0 never wraps. Breaks after + // spaces, and inside a word only when the word alone is wider than the line. ImGui thread only. + inline WorldTextLaidOut LayoutWorldText(const std::vector &runs, const WorldTextFonts &fonts, float bakeSize, float wrapWidth, float lineSpacing, WorldTextAlign align) { + WorldTextLaidOut out; + out.bakeSize = bakeSize; + if (bakeSize <= 0.0f) { + return out; + } + + ImFont *regular = fonts.regular ? fonts.regular : ImGui::GetFont(); + const auto spanFor = [&](const WorldTextRun &run) { + WorldTextSpan span; + span.color = run.color; + span.font = regular; + if (run.bold && run.italic && fonts.boldItalic) { + span.font = fonts.boldItalic; + } + else if (run.bold && fonts.bold) { + span.font = fonts.bold; + span.fauxItalic = run.italic; + } + else if (run.italic && fonts.italic) { + span.font = fonts.italic; + span.fauxBold = run.bold; + } + else { + span.fauxBold = run.bold; + span.fauxItalic = run.italic; + } + return span; + }; + + struct Line { + size_t begin; + size_t end; + }; + std::vector lines; + const float lineHeight = bakeSize * std::max(0.1f, lineSpacing); + const float boldExtra = WorldTextBoldOffset(bakeSize); + + size_t lineBegin = 0; + size_t breakAt = 0; // first glyph after the line's last space; 0 = no break yet + float penX = 0.0f; + float lineY = 0.0f; + + const auto newLine = [&](size_t carryFrom) { + lines.push_back({lineBegin, carryFrom}); + lineY += lineHeight; + const float shift = carryFrom < out.glyphs.size() ? out.glyphs[carryFrom].x : penX; // nothing carried: the pen restarts + for (size_t k = carryFrom; k < out.glyphs.size(); ++k) { + out.glyphs[k].x -= shift; + out.glyphs[k].y = lineY; + } + penX -= shift; + lineBegin = carryFrom; + breakAt = 0; + }; + + for (const WorldTextRun &run : runs) { + if (out.spans.size() >= UINT16_MAX) { + break; + } + const auto spanIndex = static_cast(out.spans.size()); + out.spans.push_back(spanFor(run)); + const WorldTextSpan &span = out.spans.back(); + ImFontBaked *baked = span.font->GetFontBaked(bakeSize); + if (!baked) { + continue; + } + + for (size_t i = 0; i < run.text.size();) { + uint32_t cp = DecodeUtf8(run.text, i); + if (cp == '\r') { + continue; + } + if (cp == '\n') { + newLine(out.glyphs.size()); + continue; + } + if (cp == '\t') { + cp = ' '; + } + if (cp > IM_UNICODE_CODEPOINT_MAX) { + cp = 0xFFFD; + } + + const auto codepoint = static_cast(cp); + const float advance = baked->GetCharAdvance(codepoint) + (span.fauxBold ? boldExtra : 0.0f); + if (wrapWidth > 0.0f && cp != ' ' && penX + advance > wrapWidth && out.glyphs.size() > lineBegin) { + newLine(breakAt > lineBegin ? breakAt : out.glyphs.size()); + } + + out.glyphs.push_back({codepoint, spanIndex, penX, lineY, advance}); + penX += advance; + if (cp == ' ') { + breakAt = out.glyphs.size(); + } + } + } + lines.push_back({lineBegin, out.glyphs.size()}); + + // Trailing spaces never count toward a line's width, or alignment would leave a gap. + std::vector widths(lines.size(), 0.0f); + for (size_t l = 0; l < lines.size(); ++l) { + for (size_t k = lines[l].begin; k < lines[l].end; ++k) { + if (out.glyphs[k].codepoint != ' ') { + widths[l] = std::max(widths[l], out.glyphs[k].x + out.glyphs[k].advance); + } + } + out.width = std::max(out.width, widths[l]); + } + const float alignFactor = align == WorldTextAlign::Left ? 0.0f : (align == WorldTextAlign::Center ? 0.5f : 1.0f); + for (size_t l = 0; l < lines.size(); ++l) { + const float shift = std::round((out.width - widths[l]) * alignFactor); + for (size_t k = lines[l].begin; k < lines[l].end; ++k) { + out.glyphs[k].x += shift; + } + } + out.height = static_cast(lines.size() - 1) * lineHeight + bakeSize; + return out; + } + + // drawSize is the pixel size to draw at (the layout scales from its bakeSize); styleScale scales + // outline, shadow and plate metrics alongside it. alpha multiplies every colour. + inline void DrawWorldTextLaidOut(ImDrawList *drawList, const WorldTextLaidOut &text, const WorldTextLook &look, ImVec2 anchor, float drawSize, float styleScale, float alpha) { + if (!drawList || text.glyphs.empty() || text.bakeSize <= 0.0f || drawSize <= 0.0f || alpha <= 0.0f) { + return; + } + + const float scale = drawSize / text.bakeSize; + const float width = text.width * scale; + const float height = text.height * scale; + float originY = anchor.y - height * 0.5f; + if (look.anchor == WorldTextAnchor::Bottom) { + originY = anchor.y - height; + } + else if (look.anchor == WorldTextAnchor::Top) { + originY = anchor.y; + } + const ImVec2 origin(std::floor(anchor.x - width * 0.5f), std::floor(originY)); + + if ((look.backgroundColor >> 24) != 0) { + const float pad = look.backgroundPadding * styleScale; + drawList->AddRectFilled(ImVec2(origin.x - pad, origin.y - pad), ImVec2(origin.x + width + pad, origin.y + height + pad), WorldTextModulateAlpha(WorldTextArgb(look.backgroundColor), alpha), look.backgroundRadius * styleScale); + } + + drawList->PushTexture(text.spans.front().font->OwnerAtlas->TexRef); + + constexpr float kItalicShear = 0.2f; + const float boldOffset = WorldTextBoldOffset(text.bakeSize) * scale; + const ImU32 baseColor = WorldTextModulateAlpha(WorldTextArgb(look.color), alpha); + + // One pass of every glyph. tint replaces the spans' colours (outline, shadow); colour glyphs + // (emoji) then have no silhouette to draw, so they only appear in the untinted pass. + const auto pass = [&](ImVec2 offset, std::optional tint) { + if (tint && ((*tint >> IM_COL32_A_SHIFT) & 0xFF) == 0) { + return; + } + int spanIndex = -1; + ImFontBaked *baked = nullptr; + ImU32 spanColor = baseColor; + for (const WorldTextGlyph &g : text.glyphs) { + const WorldTextSpan &span = text.spans[g.span]; + if (g.span != spanIndex) { + spanIndex = g.span; + baked = span.font->GetFontBaked(text.bakeSize); + spanColor = span.color ? WorldTextModulateAlpha(WorldTextArgb(*span.color), alpha) : baseColor; + } + if (!baked) { + continue; + } + const ImFontGlyph *glyph = baked->FindGlyph(g.codepoint); + if (!glyph || !glyph->Visible || (tint && glyph->Colored)) { + continue; + } + ImU32 color = tint ? *tint : spanColor; + if (glyph->Colored) { + color |= ~IM_COL32_A_MASK; + } + + const float x0 = origin.x + offset.x + (g.x + glyph->X0) * scale; + const float x1 = origin.x + offset.x + (g.x + glyph->X1) * scale; + const float y0 = origin.y + offset.y + (g.y + glyph->Y0) * scale; + const float y1 = origin.y + offset.y + (g.y + glyph->Y1) * scale; + float topShear = 0.0f, bottomShear = 0.0f; + if (span.fauxItalic) { + const float baseline = origin.y + offset.y + (g.y + baked->Ascent) * scale; + topShear = (baseline - y0) * kItalicShear; + bottomShear = (baseline - y1) * kItalicShear; + } + + const int copies = span.fauxBold ? 2 : 1; + drawList->PrimReserve(6 * copies, 4 * copies); + for (int c = 0; c < copies; ++c) { + const float dx = c * boldOffset; + drawList->PrimQuadUV(ImVec2(x0 + topShear + dx, y0), ImVec2(x1 + topShear + dx, y0), ImVec2(x1 + bottomShear + dx, y1), ImVec2(x0 + bottomShear + dx, y1), ImVec2(glyph->U0, glyph->V0), ImVec2(glyph->U1, glyph->V0), ImVec2(glyph->U1, glyph->V1), + ImVec2(glyph->U0, glyph->V1), color); + } + } + }; + + // A ring of offset copies: 8 directions for a hairline, more as the radius grows. + const auto ring = [&](ImVec2 centre, float radius, ImU32 tint) { + const int directions = radius <= 1.5f ? 8 : (radius <= 3.0f ? 12 : 16); + for (int d = 0; d < directions; ++d) { + const float angle = 6.2831853f * static_cast(d) / static_cast(directions); + pass(ImVec2(centre.x + std::cos(angle) * radius, centre.y + std::sin(angle) * radius), tint); + } + }; + + if ((look.shadowColor >> 24) != 0) { + const ImVec2 at(look.shadowX * styleScale, look.shadowY * styleScale); + const float blur = std::min(look.shadowBlur, kWorldTextMaxBlur) * styleScale; + if (blur < 0.5f) { + pass(at, WorldTextModulateAlpha(WorldTextArgb(look.shadowColor), alpha)); + } + else { + // Stacked faint copies stand in for a blur: ImGui has no filter pass. + const ImU32 faint = WorldTextModulateAlpha(WorldTextArgb(look.shadowColor), alpha * 0.25f); + pass(at, faint); + ring(at, blur * 0.5f, faint); + ring(at, blur, faint); + } + } + + const float outline = std::min(look.outlineWidth, kWorldTextMaxOutline) * styleScale; + if (outline > 0.0f) { + const ImU32 tint = WorldTextModulateAlpha(WorldTextArgb(look.outlineColor), alpha); + if (outline > 2.0f) { + ring(ImVec2(0.0f, 0.0f), outline * 0.5f, tint); + } + ring(ImVec2(0.0f, 0.0f), outline, tint); + } + + pass(ImVec2(0.0f, 0.0f), std::nullopt); + drawList->PopTexture(); + } +} // namespace Framework::External::ImGUI::Widgets diff --git a/code/framework/src/external/imgui/widgets/world_text_markup.h b/code/framework/src/external/imgui/widgets/world_text_markup.h new file mode 100644 index 000000000..3292d2a36 --- /dev/null +++ b/code/framework/src/external/imgui/widgets/world_text_markup.h @@ -0,0 +1,165 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace Framework::External::ImGUI::Widgets { + // A stretch of text sharing one inline style. color is 0xAARRGGBB; unset follows the label's own. + struct WorldTextRun { + std::string text; + std::optional color; + bool bold = false; + bool italic = false; + }; + + namespace Detail { + inline int HexDigit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + } + + // "#RRGGBB" or "#RRGGBBAA" (CSS order) to 0xAARRGGBB. + inline std::optional ParseHexColor(std::string_view tag) { + if (tag.size() != 7 && tag.size() != 9) { + return std::nullopt; + } + uint32_t value = 0; + for (size_t i = 1; i < tag.size(); ++i) { + const int digit = HexDigit(tag[i]); + if (digit < 0) { + return std::nullopt; + } + value = (value << 4) | static_cast(digit); + } + if (tag.size() == 7) { + return 0xFF000000u | value; + } + return (value >> 8) | ((value & 0xFFu) << 24); + } + } // namespace Detail + + // Reads one code point at `i` and advances past it; malformed input yields U+FFFD one byte on. + inline uint32_t DecodeUtf8(std::string_view text, size_t &i) { + const auto byte = [&](size_t at) { + return static_cast(text[at]); + }; + const uint8_t lead = byte(i); + size_t length = 0; + uint32_t cp = 0; + if (lead < 0x80) { + ++i; + return lead; + } + if ((lead & 0xE0) == 0xC0) { + length = 2; + cp = lead & 0x1F; + } + else if ((lead & 0xF0) == 0xE0) { + length = 3; + cp = lead & 0x0F; + } + else if ((lead & 0xF8) == 0xF0) { + length = 4; + cp = lead & 0x07; + } + if (length == 0 || i + length > text.size()) { + ++i; + return 0xFFFD; + } + for (size_t k = 1; k < length; ++k) { + if ((byte(i + k) & 0xC0) != 0x80) { + ++i; + return 0xFFFD; + } + cp = (cp << 6) | (byte(i + k) & 0x3F); + } + static constexpr uint32_t kMinimum[] {0, 0, 0x80, 0x800, 0x10000}; + i += length; + if (cp < kMinimum[length] || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { + return 0xFFFD; + } + return cp; + } + + // Tags: {#RRGGBB} / {#RRGGBBAA} set the colour, {#} restores the label's, {b}{/b} and {i}{/i} + // toggle bold and italic, {{ is a literal brace. Anything else in braces stays as text. + inline std::vector ParseWorldTextMarkup(std::string_view text) { + std::vector runs; + WorldTextRun current; + const auto flush = [&]() { + if (!current.text.empty()) { + runs.push_back(current); + current.text.clear(); + } + }; + + for (size_t i = 0; i < text.size(); ++i) { + const char c = text[i]; + if (c != '{') { + current.text.push_back(c); + continue; + } + if (i + 1 < text.size() && text[i + 1] == '{') { + current.text.push_back('{'); + ++i; + continue; + } + const size_t close = text.find('}', i + 1); + if (close == std::string_view::npos) { + current.text.push_back(c); + continue; + } + + const std::string_view tag = text.substr(i + 1, close - i - 1); + std::optional color; + bool known = true; + if (tag == "#") { + flush(); + current.color.reset(); + } + else if (!tag.empty() && tag[0] == '#' && (color = Detail::ParseHexColor(tag))) { + flush(); + current.color = color; + } + else if (tag == "b" || tag == "/b") { + flush(); + current.bold = tag == "b"; + } + else if (tag == "i" || tag == "/i") { + flush(); + current.italic = tag == "i"; + } + else { + known = false; + } + + if (known) { + i = close; + } + else { + current.text.push_back(c); + } + } + flush(); + return runs; + } +} // namespace Framework::External::ImGUI::Widgets diff --git a/code/tests/CMakeLists.txt b/code/tests/CMakeLists.txt index adf3e50f7..a39524167 100644 --- a/code/tests/CMakeLists.txt +++ b/code/tests/CMakeLists.txt @@ -25,7 +25,7 @@ if(WIN32) ../framework/src/utils/hooking/hooking_patterns.cpp ../framework/src/utils/hooking/hooking.cpp ../framework/src/graphics/backend/d3d12_ui_compositor.cpp) - target_link_libraries(FrameworkTests d3d12 dxgi d3dcompiler) + target_link_libraries(FrameworkTests d3d12 dxgi d3dcompiler imgui::imgui) add_executable(FrameworkSentryTests sentry_ut.cpp) target_include_directories(FrameworkSentryTests PRIVATE ../framework/src) diff --git a/code/tests/framework_ut.cpp b/code/tests/framework_ut.cpp index f0983eb7c..965bd703c 100644 --- a/code/tests/framework_ut.cpp +++ b/code/tests/framework_ut.cpp @@ -46,9 +46,11 @@ #include "modules/voice_positions_ut.h" #include "modules/voice_ptt_gate_ut.h" #include "modules/voice_router_ut.h" +#include "modules/world_text_ut.h" #ifdef _WIN32 #include "modules/d3d12_ui_compositor_ut.h" +#include "modules/world_text_layout_ut.h" // The hooking layer, and so the pattern table, is Windows-only. #include "modules/pattern_hints_ut.h" #include "modules/pattern_table_ut.h" @@ -75,6 +77,7 @@ int main() { UNIT_MODULE(bitops); UNIT_MODULE(string_utils); + UNIT_MODULE(world_text); UNIT_MODULE(interpolator); UNIT_MODULE(result); UNIT_MODULE(network_packets); @@ -107,6 +110,7 @@ int main() { UNIT_MODULE(streamed_assets); #ifdef _WIN32 UNIT_MODULE(d3d12_ui_compositor); + UNIT_MODULE(world_text_layout); UNIT_MODULE(pattern_hints); UNIT_MODULE(pattern_table); UNIT_MODULE(physical_keys); diff --git a/code/tests/modules/world_text_layout_ut.h b/code/tests/modules/world_text_layout_ut.h new file mode 100644 index 000000000..51893ce1a --- /dev/null +++ b/code/tests/modules/world_text_layout_ut.h @@ -0,0 +1,129 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +// Windows only: ImGui comes from a Windows-only vcpkg port. + +#include "external/imgui/widgets/world_text_layout.h" + +#include + +#include +#include +#include + +namespace WorldTextLayoutTest { + namespace W = Framework::External::ImGUI::Widgets; + + // A headless frame: layout needs a current font and baked sizes, never a renderer. + struct Frame { + ImGuiContext *context = nullptr; + Frame() { + context = ImGui::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.DisplaySize = ImVec2(1280.0f, 720.0f); + io.DeltaTime = 1.0f / 60.0f; + io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; + io.Fonts->AddFontDefault(); + ImGui::NewFrame(); + } + ~Frame() { + ImGui::EndFrame(); + ImGui::DestroyContext(context); + } + }; + + inline std::vector Plain(const char *text) { + return {W::WorldTextRun {text}}; + } + + inline int LineCount(const W::WorldTextLaidOut &layout, float lineHeight) { + return static_cast(std::lround((layout.height - layout.bakeSize) / lineHeight)) + 1; + } +} // namespace WorldTextLayoutTest + +MODULE(world_text_layout, { + using namespace WorldTextLayoutTest; + + IT("bake sizes are exact up to 24 px, then a capped ladder", { + UEQUALS(W::WorldTextBakeSize(0.0f), 0.0f); + UEQUALS(W::WorldTextBakeSize(17.4f), 17.0f); + UEQUALS(W::WorldTextBakeSize(24.0f), 24.0f); + float previous = 24.0f; + int distinct = 0; + for (float size = 24.5f; size < 400.0f; size += 0.5f) { + const float bake = W::WorldTextBakeSize(size); + EQUALS((bake >= previous), true); + EQUALS((bake <= 256.0f), true); + if (size <= 256.0f) { + EQUALS((std::abs(bake - size) / size < 0.06f), true); + } + distinct += bake != previous ? 1 : 0; + previous = bake; + } + EQUALS((distinct < 40), true); + }); + + IT("layout breaks lines at newlines", { + Frame frame; + const auto one = W::LayoutWorldText(Plain("Hogwarts"), {}, 20.0f, 0.0f, 1.0f, W::WorldTextAlign::Center); + const auto two = W::LayoutWorldText(Plain("Hogwarts\nYear 5"), {}, 20.0f, 0.0f, 1.5f, W::WorldTextAlign::Center); + UEQUALS(one.height, 20.0f); + EQUALS((one.width > 0.0f), true); + UEQUALS(two.height, 20.0f + 30.0f); + UEQUALS(one.glyphs.size(), 8u); + UEQUALS(two.glyphs.size(), 14u); + }); + + IT("layout wraps after spaces and never counts trailing ones", { + Frame frame; + const auto full = W::LayoutWorldText(Plain("aaa bbb"), {}, 20.0f, 0.0f, 1.0f, W::WorldTextAlign::Left); + const auto word = W::LayoutWorldText(Plain("aaa"), {}, 20.0f, 0.0f, 1.0f, W::WorldTextAlign::Left); + const auto wrapped = W::LayoutWorldText(Plain("aaa bbb ccc"), {}, 20.0f, full.width + 1.0f, 1.0f, W::WorldTextAlign::Left); + UEQUALS(LineCount(wrapped, 20.0f), 2); + UEQUALS(wrapped.width, full.width); + // "ccc" starts its own line at the left edge. + UEQUALS(wrapped.glyphs.back().x + wrapped.glyphs.back().advance, word.width); + UEQUALS(wrapped.glyphs.back().y, 20.0f); + + const auto trailing = W::LayoutWorldText(Plain("aaa "), {}, 20.0f, 0.0f, 1.0f, W::WorldTextAlign::Left); + UEQUALS(trailing.width, word.width); + }); + + IT("layout splits a word wider than the line", { + Frame frame; + const auto word = W::LayoutWorldText(Plain("abcdefgh"), {}, 20.0f, 0.0f, 1.0f, W::WorldTextAlign::Left); + const auto wrapped = W::LayoutWorldText(Plain("abcdefgh"), {}, 20.0f, word.width * 0.5f, 1.0f, W::WorldTextAlign::Left); + EQUALS((LineCount(wrapped, 20.0f) >= 2), true); + EQUALS((wrapped.width <= word.width * 0.5f + 0.01f), true); + }); + + IT("layout aligns shorter lines", { + Frame frame; + const auto left = W::LayoutWorldText(Plain("long line\nab"), {}, 20.0f, 0.0f, 1.0f, W::WorldTextAlign::Left); + const auto right = W::LayoutWorldText(Plain("long line\nab"), {}, 20.0f, 0.0f, 1.0f, W::WorldTextAlign::Right); + const auto &b = right.glyphs.back(); + UEQUALS(left.glyphs.back().y, 20.0f); + EQUALS((left.glyphs[left.glyphs.size() - 2].x == 0.0f), true); + UEQUALS(b.x + b.advance, right.width); + }); + + IT("faux bold widens and markup colours reach their spans", { + Frame frame; + const auto plain = W::LayoutWorldText(Plain("Name"), {}, 24.0f, 0.0f, 1.0f, W::WorldTextAlign::Center); + const auto bold = W::LayoutWorldText(W::ParseWorldTextMarkup("{b}Name"), {}, 24.0f, 0.0f, 1.0f, W::WorldTextAlign::Center); + EQUALS((bold.width > plain.width), true); + EQUALS((bold.spans[0].fauxBold), true); + + const auto coloured = W::LayoutWorldText(W::ParseWorldTextMarkup("{#ff0000}Red{#} base"), {}, 24.0f, 0.0f, 1.0f, W::WorldTextAlign::Center); + UEQUALS(coloured.spans.size(), 2u); + UEQUALS(*coloured.spans[coloured.glyphs.front().span].color, 0xFFFF0000u); + EQUALS((!coloured.spans[coloured.glyphs.back().span].color.has_value()), true); + }); +}); diff --git a/code/tests/modules/world_text_ut.h b/code/tests/modules/world_text_ut.h new file mode 100644 index 000000000..f63ff0294 --- /dev/null +++ b/code/tests/modules/world_text_ut.h @@ -0,0 +1,88 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "external/imgui/widgets/world_text_markup.h" + +#include +#include +#include +#include + +namespace WorldTextTest { + namespace W = Framework::External::ImGUI::Widgets; +} // namespace WorldTextTest + +MODULE(world_text, { + using namespace WorldTextTest; + + IT("markup without tags is one plain run", { + const auto runs = W::ParseWorldTextMarkup("Gryffindor"); + UEQUALS(runs.size(), 1u); + EQUALS((runs[0].text == "Gryffindor"), true); + EQUALS((!runs[0].color.has_value()), true); + EQUALS((!runs[0].bold && !runs[0].italic), true); + }); + + IT("markup colour tags set and reset the colour", { + const auto runs = W::ParseWorldTextMarkup("{#ff0000}Red{#}Base{#00ff0080}Half"); + UEQUALS(runs.size(), 3u); + UEQUALS(*runs[0].color, 0xFFFF0000u); + EQUALS((!runs[1].color.has_value()), true); + // CSS order RRGGBBAA arrives as 0xAARRGGBB. + UEQUALS(*runs[2].color, 0x8000FF00u); + }); + + IT("markup bold and italic toggle independently", { + const auto runs = W::ParseWorldTextMarkup("{b}B{i}BI{/b}I{/i}N"); + UEQUALS(runs.size(), 4u); + EQUALS((runs[0].bold && !runs[0].italic), true); + EQUALS((runs[1].bold && runs[1].italic), true); + EQUALS((!runs[2].bold && runs[2].italic), true); + EQUALS((!runs[3].bold && !runs[3].italic), true); + }); + + IT("markup keeps escaped, unknown and unterminated braces as text", { + const auto runs = W::ParseWorldTextMarkup("{{a} {name} {#12} {open"); + UEQUALS(runs.size(), 1u); + EQUALS((runs[0].text == "{a} {name} {#12} {open"), true); + }); + + IT("utf8 decodes every sequence length", { + const std::string text = "A\xC3\xAD\xE6\x97\xA5\xF0\x9F\x98\x80"; + size_t i = 0; + const uint32_t a = W::DecodeUtf8(text, i); + const uint32_t b = W::DecodeUtf8(text, i); + const uint32_t c = W::DecodeUtf8(text, i); + const uint32_t d = W::DecodeUtf8(text, i); + UEQUALS(a, 0x41u); + UEQUALS(b, 0xEDu); + UEQUALS(c, 0x65E5u); + UEQUALS(d, 0x1F600u); + UEQUALS(i, text.size()); + }); + + IT("utf8 replaces malformed input one byte at a time", { + const std::string truncated = "\xE6\x97"; + size_t i = 0; + const uint32_t cut = W::DecodeUtf8(truncated, i); + UEQUALS(cut, 0xFFFDu); + UEQUALS(i, 1u); + + const std::string overlong = "\xC0\x80"; + i = 0; + const uint32_t padded = W::DecodeUtf8(overlong, i); + UEQUALS(padded, 0xFFFDu); + + const std::string surrogate = "\xED\xA0\x80"; + i = 0; + const uint32_t half = W::DecodeUtf8(surrogate, i); + UEQUALS(half, 0xFFFDu); + }); +}); From b3b544b0feb3a222e307735d56edb5755bbf0723 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Thu, 15 Oct 2026 12:27:43 -0400 Subject: [PATCH 2/5] TextLabel: Full look, attach and options, in the game's units TextLabelEntity carries the whole look (markup, world or screen size, opacity, outline, shadow, plate, alignment, wrap, occlusion) and a working attach; style stays its nearest preset for preset-only renderers. TextLabel.create/update take an options object, validated in full before anything changes; the positional create and setters still work. Draw distances scale by the game's SetUnitsPerMetre and double as the streaming range; an attached label streams to its whole world. --- .../integrations/client/ui/text_label_imgui.h | 53 ++ .../replication/entities/text_label_entity.h | 115 ++- .../src/scripting/builtins/text_label.cpp | 861 ++++++++++++++++-- .../src/scripting/builtins/text_label.h | 29 +- code/tests/framework_ut.cpp | 2 + code/tests/modules/js_features_ut.h | 46 + code/tests/modules/text_label_ut.h | 130 +++ 7 files changed, 1156 insertions(+), 80 deletions(-) create mode 100644 code/framework/src/integrations/client/ui/text_label_imgui.h create mode 100644 code/tests/modules/text_label_ut.h diff --git a/code/framework/src/integrations/client/ui/text_label_imgui.h b/code/framework/src/integrations/client/ui/text_label_imgui.h new file mode 100644 index 000000000..c052ecf54 --- /dev/null +++ b/code/framework/src/integrations/client/ui/text_label_imgui.h @@ -0,0 +1,53 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include +#include +#include + +#include + +namespace Framework::Integrations::Client::UI::TextLabels { + // A replicated TextLabelEntity in the terms of the world text widgets. The game resolves where the + // label is and whether it can be seen; this is only what it looks like. + using ::Framework::Networking::Replication::Entities::TextLabelEntity; + + static_assert(static_cast(::Framework::External::ImGUI::Widgets::WorldTextAlign::Right) == static_cast(::Framework::Networking::Replication::Entities::TextLabelAlign::Right)); + static_assert(static_cast(::Framework::External::ImGUI::Widgets::WorldTextAnchor::Top) == static_cast(::Framework::Networking::Replication::Entities::TextLabelAnchor::Top)); + static_assert(static_cast(::Framework::External::ImGUI::Widgets::WorldTextAnchor::Bottom) == static_cast(::Framework::Networking::Replication::Entities::TextLabelAnchor::Bottom)); + + inline ::Framework::External::ImGUI::Widgets::WorldTextLook Look(const TextLabelEntity &label) { + ::Framework::External::ImGUI::Widgets::WorldTextLook look; + look.color = label.color; + look.outlineWidth = label.outlineWidth; + look.outlineColor = label.outlineColor; + look.shadowX = label.shadowOffsetX; + look.shadowY = label.shadowOffsetY; + look.shadowBlur = label.shadowBlur; + look.shadowColor = label.shadowColor; + look.backgroundColor = label.backgroundColor; + look.backgroundPadding = label.backgroundPadding; + look.backgroundRadius = label.backgroundRadius; + look.anchor = static_cast<::Framework::External::ImGUI::Widgets::WorldTextAnchor>(label.anchor); + return look; + } + + inline ::Framework::External::ImGUI::Widgets::WorldTextAlign Align(const TextLabelEntity &label) { + return static_cast<::Framework::External::ImGUI::Widgets::WorldTextAlign>(label.align); + } + + // Markup is parsed only when the label asks for it, so plain text never loses a brace. + inline std::vector<::Framework::External::ImGUI::Widgets::WorldTextRun> Runs(const TextLabelEntity &label) { + if (label.markup) { + return ::Framework::External::ImGUI::Widgets::ParseWorldTextMarkup(label.text); + } + return {::Framework::External::ImGUI::Widgets::WorldTextRun {label.text}}; + } +} // namespace Framework::Integrations::Client::UI::TextLabels diff --git a/code/framework/src/networking/replication/entities/text_label_entity.h b/code/framework/src/networking/replication/entities/text_label_entity.h index 99f36b35b..db86df5bb 100644 --- a/code/framework/src/networking/replication/entities/text_label_entity.h +++ b/code/framework/src/networking/replication/entities/text_label_entity.h @@ -22,9 +22,27 @@ namespace Framework::Networking::Replication::Entities { Box = 3, }; + enum class TextLabelSizeMode : uint8_t { + Screen = 0, // fontSize pixels at any distance + World = 1, // fontSize pixels at one metre, shrinking with distance + }; + + // Wire values are shared with External::ImGUI::Widgets::WorldTextAlign / WorldTextAnchor. + enum class TextLabelAlign : uint8_t { + Left = 0, + Center = 1, + Right = 2, + }; + + enum class TextLabelAnchor : uint8_t { + Center = 0, + Bottom = 1, + Top = 2, + }; + // A server-owned piece of world-space text, interest-streamed and drawn client-side as a screen - // overlay (External::ImGUI::Widgets::DrawWorldText). Mods register the type on both peers. - class TextLabelEntity : public NetworkEntity { + // overlay. Mods register the type on both peers. + class TextLabelEntity: public NetworkEntity { public: static constexpr const char *kTypeName = "Framework::TextLabel"; @@ -34,12 +52,47 @@ namespace Framework::Networking::Replication::Entities { float drawDistance = 30.0f; // hard cull (world units) float fadeDistance = 0.0f; // fade start; <=0 derives from drawDistance + // The preset nearest the look below, for renderers that only draw presets. TextLabelStyle style = TextLabelStyle::Shadow; - // Reserved: follow another entity at an offset (no renderer consumes these yet). + // Follow another entity at an offset; 0 keeps the label at its own position. uint64_t attachNetworkId = 0; glm::vec3 attachOffset = glm::vec3(0.0f); + // The full look. Colours are 0xAARRGGBB; a colour with zero alpha turns its part off. + bool markup = false; // {#RRGGBB[AA]} {#} {b}{/b} {i}{/i}, {{ for a brace + TextLabelSizeMode sizeMode = TextLabelSizeMode::Screen; + float minSize = 0.0f; // world mode clamps; 0 = none + float maxSize = 0.0f; + float opacity = 1.0f; + float outlineWidth = 0.0f; + uint32_t outlineColor = 0xFF000000; + uint32_t shadowColor = 0xFF000000; + float shadowOffsetX = 1.0f; + float shadowOffsetY = 1.0f; + float shadowBlur = 0.0f; + uint32_t backgroundColor = 0; + float backgroundPadding = 4.0f; + float backgroundRadius = 4.0f; + TextLabelAlign align = TextLabelAlign::Center; + TextLabelAnchor anchor = TextLabelAnchor::Center; + float lineSpacing = 1.0f; + float maxWidth = 0.0f; // wrap width in fontSize pixels; 0 = never + bool occlusion = true; // hidden behind world geometry + + // Not replicated: bumped whenever the label is constructed or updated, so a renderer can + // keep its layout until something changes. + uint32_t revision = 0; + + void OnConstructed() override { + ++revision; + } + + void OnDeserialized(bool transformUpdated) override { + (void)transformUpdated; + ++revision; + } + void SerializeFields(FieldSerializer &fields) override { // position lives in the NetworkEntity base; serialize it on the reliable channel. fields.Field(position.x); @@ -60,9 +113,63 @@ namespace Framework::Networking::Replication::Entities { fields.Field(attachOffset.y); fields.Field(attachOffset.z); + uint8_t sizeModeRaw = static_cast(sizeMode); + uint8_t alignRaw = static_cast(align); + uint8_t anchorRaw = static_cast(anchor); + fields.Field(markup); + fields.Field(sizeModeRaw); + fields.Field(minSize); + fields.Field(maxSize); + fields.Field(opacity); + fields.Field(outlineWidth); + fields.Field(outlineColor); + fields.Field(shadowColor); + fields.Field(shadowOffsetX); + fields.Field(shadowOffsetY); + fields.Field(shadowBlur); + fields.Field(backgroundColor); + fields.Field(backgroundPadding); + fields.Field(backgroundRadius); + fields.Field(alignRaw); + fields.Field(anchorRaw); + fields.Field(lineSpacing); + fields.Field(maxWidth); + fields.Field(occlusion); + if (!fields.Writing()) { - style = static_cast(styleRaw); + style = static_cast(styleRaw); + sizeMode = static_cast(sizeModeRaw); + align = static_cast(alignRaw); + anchor = static_cast(anchorRaw); } } }; + + // Sets the look a preset stands for: the parts DrawWorldText draws for it, the rest off. + inline void ApplyTextLabelStylePreset(TextLabelEntity &label, TextLabelStyle preset) { + label.style = preset; + label.outlineWidth = preset == TextLabelStyle::Outline ? 1.0f : 0.0f; + label.outlineColor = 0xFF000000; + label.shadowColor = preset == TextLabelStyle::Shadow ? 0xFF000000 : 0; + label.shadowOffsetX = 1.0f; + label.shadowOffsetY = 1.0f; + label.shadowBlur = 0.0f; + label.backgroundColor = preset == TextLabelStyle::Box ? 0x99000000 : 0; + label.backgroundPadding = 4.0f; + label.backgroundRadius = 4.0f; + } + + // The preset a look reads closest to: a plate dominates, then an outline, then a shadow. + inline TextLabelStyle NearestTextLabelStylePreset(const TextLabelEntity &label) { + if ((label.backgroundColor >> 24) != 0) { + return TextLabelStyle::Box; + } + if (label.outlineWidth > 0.0f && (label.outlineColor >> 24) != 0) { + return TextLabelStyle::Outline; + } + if ((label.shadowColor >> 24) != 0) { + return TextLabelStyle::Shadow; + } + return TextLabelStyle::None; + } } // namespace Framework::Networking::Replication::Entities diff --git a/code/framework/src/scripting/builtins/text_label.cpp b/code/framework/src/scripting/builtins/text_label.cpp index 920b793b3..503eb471f 100644 --- a/code/framework/src/scripting/builtins/text_label.cpp +++ b/code/framework/src/scripting/builtins/text_label.cpp @@ -10,13 +10,612 @@ #include "../engine_helpers.h" #include "../scripting_catalog.h" #include "color.h" +#include "vector3.h" #include +#include +#include #include +#include +#include #include +#include namespace Framework::Scripting::Builtins { + namespace { + using Networking::Replication::Entities::TextLabelAlign; + using Networking::Replication::Entities::TextLabelAnchor; + using Networking::Replication::Entities::TextLabelEntity; + using Networking::Replication::Entities::TextLabelSizeMode; + using Networking::Replication::Entities::TextLabelStyle; + + // The world text widget's own limits (world_text_layout.h), which the server can't include. + constexpr double kMaxOutlineWidth = 4.0; + constexpr double kMaxShadowBlur = 8.0; + constexpr double kMaxBlockPixels = 64.0; + + // Everything an options object can carry, validated in full before any of it is applied, so + // a bad key leaves the label as it was. + struct Options { + struct Outline { + float width = 1.0f; + uint32_t color = 0xFF000000; + }; + struct Shadow { + uint32_t color = 0xFF000000; + float offsetX = 1.0f; + float offsetY = 1.0f; + float blur = 0.0f; + }; + struct Background { + uint32_t color = 0x99000000; + float padding = 4.0f; + float radius = 4.0f; + }; + + std::optional text; + std::optional markup; + std::optional position; + std::optional attachTo; // 0 detaches + std::optional offset; + std::optional fontSize; + std::optional sizeMode; + std::optional minSize; + std::optional maxSize; + std::optional color; + std::optional opacity; + std::optional style; + std::optional> outline; // set but empty = turned off + std::optional> shadow; + std::optional> background; + std::optional align; + std::optional anchor; + std::optional lineSpacing; + std::optional maxWidth; + std::optional drawDistance; + std::optional fadeDistance; + std::optional occlusion; + std::optional virtualWorld; + }; + + constexpr std::string_view kOptionKeys[] {"text", "markup", "position", "attachTo", "offset", "fontSize", "sizeMode", "minSize", "maxSize", "color", "opacity", "style", "outline", "shadow", "background", "align", "anchor", "lineSpacing", "maxWidth", "drawDistance", + "fadeDistance", "occlusion", "virtualWorld"}; + + constexpr std::pair kStyles[] {{"none", TextLabelStyle::None}, {"shadow", TextLabelStyle::Shadow}, {"outline", TextLabelStyle::Outline}, {"box", TextLabelStyle::Box}}; + constexpr std::pair kSizeModes[] {{"screen", TextLabelSizeMode::Screen}, {"world", TextLabelSizeMode::World}}; + constexpr std::pair kAligns[] {{"left", TextLabelAlign::Left}, {"center", TextLabelAlign::Center}, {"right", TextLabelAlign::Right}}; + constexpr std::pair kAnchors[] {{"center", TextLabelAnchor::Center}, {"bottom", TextLabelAnchor::Bottom}, {"top", TextLabelAnchor::Top}}; + + v8::Local Str(v8::Isolate *isolate, std::string_view text) { + return v8::String::NewFromUtf8(isolate, text.data(), v8::NewStringType::kNormal, static_cast(text.size())).ToLocalChecked(); + } + + bool Fail(v8::Isolate *isolate, const std::string &message) { + ThrowTypeError(isolate, ("TextLabel: " + message).c_str()); + return false; + } + + bool Get(v8::Isolate *isolate, v8::Local object, std::string_view key, v8::Local &value) { + return object->Get(isolate->GetCurrentContext(), Str(isolate, key)).ToLocal(&value); + } + + std::optional Number(v8::Isolate *isolate, v8::Local value, const std::string &name, double min, double max) { + const double number = value->IsNumber() ? value->NumberValue(isolate->GetCurrentContext()).FromMaybe(NAN) : NAN; + if (!std::isfinite(number) || number < min || number > max) { + std::ostringstream ss; + ss << name << " must be a number from " << min << " to " << max; + Fail(isolate, ss.str()); + return std::nullopt; + } + return static_cast(number); + } + + std::optional Bool(v8::Isolate *isolate, v8::Local value, const std::string &name) { + if (!value->IsBoolean()) { + Fail(isolate, name + " must be a boolean"); + return std::nullopt; + } + return value->BooleanValue(isolate); + } + + // 0xAARRGGBB (or its signed int32 form), "#RRGGBB" / "#RRGGBBAA", or a Color. + std::optional ColorValue(v8::Isolate *isolate, v8::Local value, const std::string &name) { + if (value->IsNumber()) { + const double n = value->NumberValue(isolate->GetCurrentContext()).FromMaybe(NAN); + if (std::isfinite(n) && std::floor(n) == n && n >= -2147483648.0 && n <= 4294967295.0) { + return n < 0 ? static_cast(static_cast(n)) : static_cast(n); + } + } + else if (value->IsString()) { + if (const auto parsed = External::ImGUI::Widgets::Detail::ParseHexColor(v8pp::from_v8(isolate, value))) { + return parsed; + } + } + else if (auto *color = v8pp::class_::unwrap_object(isolate, value)) { + return color->toARGB(); + } + Fail(isolate, name + " must be 0xAARRGGBB, \"#RRGGBB[AA]\" or a Color"); + return std::nullopt; + } + + // A Vector3, {x, y, z} or [x, y, z]. + std::optional Vec3(v8::Isolate *isolate, v8::Local value, const std::string &name) { + if (auto *vector = v8pp::class_::unwrap_object(isolate, value)) { + return glm::vec3(vector->getX(), vector->getY(), vector->getZ()); + } + v8::Local parts[3]; + if (value->IsArray() && value.As()->Length() == 3) { + for (uint32_t i = 0; i < 3; ++i) { + if (!value.As()->Get(isolate->GetCurrentContext(), i).ToLocal(&parts[i])) { + return std::nullopt; + } + } + } + else if (value->IsObject()) { + constexpr std::string_view keys[] {"x", "y", "z"}; + for (int i = 0; i < 3; ++i) { + if (!Get(isolate, value.As(), keys[i], parts[i])) { + return std::nullopt; + } + } + } + else { + Fail(isolate, name + " must be a Vector3, {x, y, z} or [x, y, z]"); + return std::nullopt; + } + glm::vec3 out(0.0f); + for (int i = 0; i < 3; ++i) { + const auto component = Number(isolate, parts[i], name, -1.0e9, 1.0e9); + if (!component) { + return std::nullopt; + } + out[i] = *component; + } + return out; + } + + template + std::optional Enum(v8::Isolate *isolate, v8::Local value, const std::string &name, const std::pair (&entries)[N]) { + if (value->IsString()) { + const auto text = v8pp::from_v8(isolate, value); + for (const auto &[label, entry] : entries) { + if (text == label) { + return entry; + } + } + } + std::string accepted; + for (const auto &[label, entry] : entries) { + accepted += (accepted.empty() ? "'" : ", '") + std::string(label) + "'"; + } + Fail(isolate, name + " must be one of " + accepted); + return std::nullopt; + } + + // An entity handle (anything with a numeric id) or a network id; null or 0 detaches. + std::optional Target(v8::Isolate *isolate, v8::Local value, const std::string &name) { + if (value->IsNull()) { + return 0; + } + v8::Local id = value; + if (value->IsObject() && !Get(isolate, value.As(), "id", id)) { + return std::nullopt; + } + const double n = id->IsNumber() ? id->NumberValue(isolate->GetCurrentContext()).FromMaybe(NAN) : NAN; + if (!std::isfinite(n) || n < 0.0 || n > 9007199254740991.0 || std::floor(n) != n) { + Fail(isolate, name + " must be an entity or its network id"); + return std::nullopt; + } + return static_cast(n); + } + + // false/null turns a block off, true takes its defaults, an object sets it whole. + enum class BlockState { + Invalid, + Off, + Defaults, + Object, + }; + BlockState Block(v8::Isolate *isolate, v8::Local value, const std::string &name) { + if (value->IsNull() || (value->IsBoolean() && !value->BooleanValue(isolate))) { + return BlockState::Off; + } + if (value->IsBoolean()) { + return BlockState::Defaults; + } + if (value->IsObject() && !value->IsArray()) { + return BlockState::Object; + } + Fail(isolate, name + " must be an object, true or false"); + return BlockState::Invalid; + } + + // Reads `key` off a block when present; absent keeps `out` at its default. + template + bool BlockField(v8::Isolate *isolate, v8::Local block, std::string_view key, T &out, const Reader &read) { + v8::Local value; + if (!Get(isolate, block, key, value)) { + return false; + } + if (value->IsUndefined()) { + return true; + } + const auto parsed = read(value); + if (!parsed) { + return false; + } + out = *parsed; + return true; + } + + bool ParseOptions(v8::Isolate *isolate, v8::Local object, float unitsPerMetre, Options &options) { + auto context = isolate->GetCurrentContext(); + v8::Local names; + if (!object->GetOwnPropertyNames(context).ToLocal(&names)) { + return false; + } + for (uint32_t i = 0; i < names->Length(); ++i) { + v8::Local name; + if (!names->Get(context, i).ToLocal(&name)) { + return false; + } + const auto key = v8pp::from_v8(isolate, name); + if (std::find(std::begin(kOptionKeys), std::end(kOptionKeys), key) == std::end(kOptionKeys)) { + return Fail(isolate, "unknown option '" + key + "'"); + } + } + + const double maxDistance = TextLabel::kMaxDrawMetres * unitsPerMetre; + for (const std::string_view key : kOptionKeys) { + v8::Local value; + if (!Get(isolate, object, key, value)) { + return false; + } + if (value->IsUndefined()) { + continue; + } + const std::string name(key); + const auto number = [&](double min, double max, std::optional &out) { + out = Number(isolate, value, name, min, max); + return out.has_value(); + }; + + bool ok = true; + if (key == "text") { + ok = value->IsString() || Fail(isolate, "text must be a string"); + if (ok) { + options.text = v8pp::from_v8(isolate, value); + } + } + else if (key == "markup") { + ok = (options.markup = Bool(isolate, value, name)).has_value(); + } + else if (key == "position") { + ok = (options.position = Vec3(isolate, value, name)).has_value(); + } + else if (key == "attachTo") { + ok = (options.attachTo = Target(isolate, value, name)).has_value(); + } + else if (key == "offset") { + ok = (options.offset = Vec3(isolate, value, name)).has_value(); + } + else if (key == "fontSize") { + ok = number(TextLabel::kMinFontSize, TextLabel::kMaxFontSize, options.fontSize); + } + else if (key == "sizeMode") { + ok = (options.sizeMode = Enum(isolate, value, name, kSizeModes)).has_value(); + } + else if (key == "minSize") { + ok = number(0.0, TextLabel::kMaxFontSize, options.minSize); + } + else if (key == "maxSize") { + ok = number(0.0, TextLabel::kMaxFontSize, options.maxSize); + } + else if (key == "color") { + ok = (options.color = ColorValue(isolate, value, name)).has_value(); + } + else if (key == "opacity") { + ok = number(0.0, 1.0, options.opacity); + } + else if (key == "style") { + if (value->IsNumber()) { + const double raw = value->NumberValue(context).FromMaybe(NAN); + ok = std::isfinite(raw) && raw >= 0.0 && raw <= static_cast(TextLabelStyle::Box) && std::floor(raw) == raw; + if (ok) { + options.style = static_cast(static_cast(raw)); + } + else { + Fail(isolate, "style must be 'none', 'shadow', 'outline', 'box' or 0-3"); + } + } + else { + ok = (options.style = Enum(isolate, value, name, kStyles)).has_value(); + } + } + else if (key == "outline") { + const auto state = Block(isolate, value, name); + ok = state != BlockState::Invalid; + Options::Outline outline; + if (state == BlockState::Object) { + const auto block = value.As(); + ok = BlockField(isolate, block, "color", outline.color, [&](auto v) { + return ColorValue(isolate, v, "outline.color"); + }) && BlockField(isolate, block, "width", outline.width, [&](auto v) { + return Number(isolate, v, "outline.width", 0.0, kMaxOutlineWidth); + }); + } + if (ok) { + options.outline = state == BlockState::Off ? std::optional {} : std::optional {outline}; + } + } + else if (key == "shadow") { + const auto state = Block(isolate, value, name); + ok = state != BlockState::Invalid; + Options::Shadow shadow; + if (state == BlockState::Object) { + const auto block = value.As(); + ok = BlockField(isolate, block, "color", shadow.color, [&](auto v) { + return ColorValue(isolate, v, "shadow.color"); + }) && BlockField(isolate, block, "offsetX", shadow.offsetX, [&](auto v) { + return Number(isolate, v, "shadow.offsetX", -32.0, 32.0); + }) && BlockField(isolate, block, "offsetY", shadow.offsetY, [&](auto v) { + return Number(isolate, v, "shadow.offsetY", -32.0, 32.0); + }) && BlockField(isolate, block, "blur", shadow.blur, [&](auto v) { + return Number(isolate, v, "shadow.blur", 0.0, kMaxShadowBlur); + }); + } + if (ok) { + options.shadow = state == BlockState::Off ? std::optional {} : std::optional {shadow}; + } + } + else if (key == "background") { + const auto state = Block(isolate, value, name); + ok = state != BlockState::Invalid; + Options::Background background; + if (state == BlockState::Object) { + const auto block = value.As(); + ok = BlockField(isolate, block, "color", background.color, [&](auto v) { + return ColorValue(isolate, v, "background.color"); + }) && BlockField(isolate, block, "padding", background.padding, [&](auto v) { + return Number(isolate, v, "background.padding", 0.0, kMaxBlockPixels); + }) && BlockField(isolate, block, "radius", background.radius, [&](auto v) { + return Number(isolate, v, "background.radius", 0.0, kMaxBlockPixels); + }); + } + if (ok) { + options.background = state == BlockState::Off ? std::optional {} : std::optional {background}; + } + } + else if (key == "align") { + ok = (options.align = Enum(isolate, value, name, kAligns)).has_value(); + } + else if (key == "anchor") { + ok = (options.anchor = Enum(isolate, value, name, kAnchors)).has_value(); + } + else if (key == "lineSpacing") { + ok = number(0.5, 4.0, options.lineSpacing); + } + else if (key == "maxWidth") { + ok = number(0.0, 4096.0, options.maxWidth); + } + else if (key == "drawDistance") { + ok = number(1.0, maxDistance, options.drawDistance); + } + else if (key == "fadeDistance") { + ok = number(0.0, maxDistance, options.fadeDistance); + } + else if (key == "occlusion") { + ok = (options.occlusion = Bool(isolate, value, name)).has_value(); + } + else if (key == "virtualWorld") { + const double world = value->IsNumber() ? value->NumberValue(context).FromMaybe(NAN) : NAN; + ok = std::isfinite(world) && world >= 0.0 && world <= 4294967295.0 && std::floor(world) == world; + if (ok) { + options.virtualWorld = static_cast(world); + } + else { + Fail(isolate, "virtualWorld must be a whole number from 0 to 4294967295"); + } + } + if (!ok) { + return false; + } + } + return true; + } + + void ApplyOptions(TextLabel &handle, TextLabelEntity &label, const Options &options) { + if (options.text) { + handle.SetText(*options.text); + } + if (options.markup) { + label.markup = *options.markup; + } + if (options.position) { + label.position = *options.position; + } + if (options.attachTo) { + if (*options.attachTo == 0) { + handle.Detach(); + } + else { + handle.AttachTo(*options.attachTo, options.offset.value_or(label.attachOffset)); + } + } + else if (options.offset) { + label.attachOffset = *options.offset; + } + if (options.fontSize) { + handle.SetFontSize(*options.fontSize); + } + if (options.sizeMode) { + label.sizeMode = *options.sizeMode; + } + if (options.minSize) { + label.minSize = *options.minSize; + } + if (options.maxSize) { + label.maxSize = *options.maxSize; + } + if (options.color) { + label.color = *options.color; + } + if (options.opacity) { + label.opacity = *options.opacity; + } + + // A preset first, so explicit blocks in the same call refine it. + if (options.style) { + Networking::Replication::Entities::ApplyTextLabelStylePreset(label, *options.style); + } + if (options.outline) { + const Options::Outline outline = options.outline->value_or(Options::Outline {0.0f}); + label.outlineWidth = outline.width; + label.outlineColor = outline.color; + } + if (options.shadow) { + const Options::Shadow shadow = options.shadow->value_or(Options::Shadow {0}); + label.shadowColor = shadow.color; + label.shadowOffsetX = shadow.offsetX; + label.shadowOffsetY = shadow.offsetY; + label.shadowBlur = shadow.blur; + } + if (options.background) { + const Options::Background background = options.background->value_or(Options::Background {0}); + label.backgroundColor = background.color; + label.backgroundPadding = background.padding; + label.backgroundRadius = background.radius; + } + if (options.style || options.outline || options.shadow || options.background) { + label.style = Networking::Replication::Entities::NearestTextLabelStylePreset(label); + } + + if (options.align) { + label.align = *options.align; + } + if (options.anchor) { + label.anchor = *options.anchor; + } + if (options.lineSpacing) { + label.lineSpacing = *options.lineSpacing; + } + if (options.maxWidth) { + label.maxWidth = *options.maxWidth; + } + if (options.drawDistance) { + handle.SetDrawDistance(*options.drawDistance); + } + if (options.fadeDistance) { + handle.SetFadeDistance(*options.fadeDistance); + } + if (options.occlusion) { + label.occlusion = *options.occlusion; + } + if (options.virtualWorld) { + handle.SetVirtualWorld(*options.virtualWorld); + } + } + + template + std::string EnumName(T value, const std::pair (&entries)[N]) { + for (const auto &[label, entry] : entries) { + if (entry == value) { + return std::string(label); + } + } + return std::string(entries[0].first); + } + + // The label as an options object: what update() takes, so one label's look can seed another's. + v8::Local OptionsObject(v8::Isolate *isolate, const TextLabelEntity &label, uint32_t virtualWorld) { + auto context = isolate->GetCurrentContext(); + const auto put = [&](v8::Local object, std::string_view key, v8::Local value) { + object->Set(context, Str(isolate, key), value).Check(); + }; + const auto set = [&](v8::Local object, std::string_view key, auto value) { + put(object, key, v8pp::to_v8(isolate, value)); + }; + const auto vec = [&](const glm::vec3 &v) { + auto object = v8::Object::New(isolate); + set(object, "x", v.x); + set(object, "y", v.y); + set(object, "z", v.z); + return object; + }; + + auto out = v8::Object::New(isolate); + set(out, "text", label.text); + set(out, "markup", label.markup); + put(out, "position", vec(label.position)); + if (label.attachNetworkId != 0) { + set(out, "attachTo", static_cast(label.attachNetworkId)); + } + else { + put(out, "attachTo", v8::Null(isolate)); + } + put(out, "offset", vec(label.attachOffset)); + set(out, "fontSize", label.fontSize); + set(out, "sizeMode", EnumName(label.sizeMode, kSizeModes)); + set(out, "minSize", label.minSize); + set(out, "maxSize", label.maxSize); + set(out, "color", label.color); + set(out, "opacity", label.opacity); + set(out, "style", EnumName(label.style, kStyles)); + + if (label.outlineWidth > 0.0f) { + auto outline = v8::Object::New(isolate); + set(outline, "width", label.outlineWidth); + set(outline, "color", label.outlineColor); + put(out, "outline", outline); + } + else { + set(out, "outline", false); + } + if ((label.shadowColor >> 24) != 0) { + auto shadow = v8::Object::New(isolate); + set(shadow, "color", label.shadowColor); + set(shadow, "offsetX", label.shadowOffsetX); + set(shadow, "offsetY", label.shadowOffsetY); + set(shadow, "blur", label.shadowBlur); + put(out, "shadow", shadow); + } + else { + set(out, "shadow", false); + } + if ((label.backgroundColor >> 24) != 0) { + auto background = v8::Object::New(isolate); + set(background, "color", label.backgroundColor); + set(background, "padding", label.backgroundPadding); + set(background, "radius", label.backgroundRadius); + put(out, "background", background); + } + else { + set(out, "background", false); + } + + set(out, "align", EnumName(label.align, kAligns)); + set(out, "anchor", EnumName(label.anchor, kAnchors)); + set(out, "lineSpacing", label.lineSpacing); + set(out, "maxWidth", label.maxWidth); + set(out, "drawDistance", label.drawDistance); + set(out, "fadeDistance", label.fadeDistance); + set(out, "occlusion", label.occlusion); + set(out, "virtualWorld", virtualWorld); + return out; + } + } // namespace + + void TextLabel::SetUnitsPerMetre(float unitsPerMetre) { + if (std::isfinite(unitsPerMetre) && unitsPerMetre > 0.0f) { + _unitsPerMetre = unitsPerMetre; + } + } + + float TextLabel::GetUnitsPerMetre() { + return _unitsPerMetre; + } + TextLabel::LabelEntity *TextLabel::ResolveLabel() const { return dynamic_cast(Resolve()); } @@ -29,6 +628,7 @@ namespace Framework::Scripting::Builtins { void TextLabel::SetText(const std::string &text) { if (auto *label = ResolveLabel()) { label->text = text; + Utils::StringUtils::TruncateUtf8(label->text, kMaxTextBytes); } } @@ -57,7 +657,7 @@ namespace Framework::Scripting::Builtins { void TextLabel::SetFontSize(float size) { if (auto *label = ResolveLabel()) { - label->fontSize = std::clamp(size, 6.0f, 128.0f); + label->fontSize = std::clamp(size, kMinFontSize, kMaxFontSize); } } @@ -66,9 +666,12 @@ namespace Framework::Scripting::Builtins { return label ? label->drawDistance : 0.0f; } + // The draw distance is also the label's streaming radius, so a game that holds the type to its + // entities' own range sends a label only to players who could see it. void TextLabel::SetDrawDistance(float distance) { if (auto *label = ResolveLabel()) { - label->drawDistance = std::clamp(distance, 1.0f, 1000.0f); + label->drawDistance = std::clamp(distance, 1.0f, kMaxDrawMetres * _unitsPerMetre); + label->streaming.range = label->drawDistance; } } @@ -91,11 +694,23 @@ namespace Framework::Scripting::Builtins { void TextLabel::SetStyle(uint32_t style) { if (auto *label = ResolveLabel()) { if (style <= static_cast(LabelStyle::Box)) { - label->style = static_cast(style); + Networking::Replication::Entities::ApplyTextLabelStylePreset(*label, static_cast(style)); } } } + void TextLabel::AttachTo(uint64_t networkId, const glm::vec3 &offset) { + if (auto *label = ResolveLabel()) { + label->attachNetworkId = networkId; + label->attachOffset = offset; + label->streaming.alwaysVisible = networkId != 0; + } + } + + void TextLabel::Detach() { + AttachTo(0, glm::vec3(0.0f)); + } + std::string TextLabel::ToString() const { std::ostringstream ss; ss << "TextLabel{ id: " << _id << ", text: \"" << GetText() << "\" }"; @@ -113,68 +728,104 @@ namespace Framework::Scripting::Builtins { } v8pp::class_ &TextLabel::GetClass(v8::Isolate *isolate) { - auto it = _classes.find(isolate); - if (it != _classes.end()) { - return *it->second; - } - - Entity::GetClass(isolate); - - auto &cls = _classes[isolate]; - cls = std::make_unique>(isolate, GetScriptingCatalog(isolate), "TextLabel", "Server-created replicated world-space text label."); - cls->auto_wrap_objects(true); - cls->inherit() - .ctor(v8pp::metadata::docs("void", {v8pp::metadata::param("id", "number", false, "Existing text-label network entity identifier.")}, "Creates a wrapper for an existing text label; use TextLabel.create to spawn one.")) - .function("toString", &TextLabel::ToString, v8pp::metadata::docs("string", {}, "Formats this label for logging and debugging.", "Text containing the label ID and current text.")) - .function("destroy", &TextLabel::Destroy, v8pp::metadata::docs("void", {}, "Destroys this replicated label; stale wrappers no longer resolve afterward.")) - .function("setText", &TextLabel::SetText, v8pp::metadata::docs("void", {v8pp::metadata::param("text", "string", false, "Replacement text replicated to clients.")}, "Changes the label's displayed text.")) - .function("setFontSize", &TextLabel::SetFontSize, v8pp::metadata::docs("void", {v8pp::metadata::param("size", "number", false, "Font size clamped from 6 to 128.")}, "Changes the rendered font size.")) - .function("setDrawDistance", &TextLabel::SetDrawDistance, v8pp::metadata::docs("void", {v8pp::metadata::param("distance", "number", false, "Maximum rendering distance, clamped from 1 to 1000 world units.")}, "Changes how far away clients can render the label.")) - .function("setFadeDistance", &TextLabel::SetFadeDistance, - v8pp::metadata::docs("void", {v8pp::metadata::param("distance", "number", false, "Distance over which the label fades out; negative values become zero.")}, "Changes the fade range approaching the draw-distance limit.")) - .function("setStyle", &TextLabel::SetStyle, v8pp::metadata::docs("void", {v8pp::metadata::param("style", "number", false, "Framework text-label style enum value; unsupported values are ignored.")}, "Changes the label's rendering style.")) - .property("text", &TextLabel::GetText, v8pp::metadata::property_docs("string", "Current replicated label text.")) - .property("color", &TextLabel::GetColor, v8pp::metadata::property_docs("number", "Current packed ARGB color.")) - .property("fontSize", &TextLabel::GetFontSize, v8pp::metadata::property_docs("number", "Current font size.")) - .property("drawDistance", &TextLabel::GetDrawDistance, v8pp::metadata::property_docs("number", "Current maximum rendering distance in world units.")) - .property("fadeDistance", &TextLabel::GetFadeDistance, v8pp::metadata::property_docs("number", "Current fade range in world units.")) - .property("style", &TextLabel::GetStyle, v8pp::metadata::property_docs("number", "Current framework text-label style enum value.")); - - // setColor accepts either a Color or byte components (r, g, b, a from 0-255), so scripts - // don't have to hand-pack this API's ARGB layout. Registered as a raw prototype function - // because v8pp's typed binding can't express the Color|number overload. - cls->prototype_function( - "setColor", - [](const v8::FunctionCallbackInfo &info) { - v8::Isolate *iso = info.GetIsolate(); - auto *self = v8pp::class_::unwrap_object(iso, info.This()); - if (!self) { - return; - } - if (info.Length() >= 1) { - if (auto *col = v8pp::class_::unwrap_object(iso, info[0])) { - self->SetColorPacked(col->toARGB()); + return _classes.Get(isolate, [isolate]() { + Entity::GetClass(isolate); + + auto cls = std::make_unique>(isolate, GetScriptingCatalog(isolate), "TextLabel", "Server-created replicated world-space text label."); + cls->auto_wrap_objects(true); + cls->inherit() + .ctor(v8pp::metadata::docs("void", {v8pp::metadata::param("id", "number", false, "Existing text-label network entity identifier.")}, "Creates a wrapper for an existing text label; use TextLabel.create to spawn one.")) + .function("toString", &TextLabel::ToString, v8pp::metadata::docs("string", {}, "Formats this label for logging and debugging.", "Text containing the label ID and current text.")) + .function("destroy", &TextLabel::Destroy, v8pp::metadata::docs("void", {}, "Destroys this replicated label; stale wrappers no longer resolve afterward.")) + .function("setText", &TextLabel::SetText, v8pp::metadata::docs("void", {v8pp::metadata::param("text", "string", false, "Replacement text replicated to clients; at most 1024 bytes.")}, "Changes the label's displayed text.")) + .function("setFontSize", &TextLabel::SetFontSize, v8pp::metadata::docs("void", {v8pp::metadata::param("size", "number", false, "Font size in pixels, clamped from 1 to 512.")}, "Changes the rendered font size.")) + .function("setDrawDistance", &TextLabel::SetDrawDistance, + v8pp::metadata::docs("void", {v8pp::metadata::param("distance", "number", false, "Maximum rendering distance in world units, clamped from 1 to 1000 metres' worth.")}, "Changes how far away clients can render the label.")) + .function("setFadeDistance", &TextLabel::SetFadeDistance, + v8pp::metadata::docs("void", {v8pp::metadata::param("distance", "number", false, "Distance in world units where fading starts; 0 starts it at 75% of the draw distance.")}, "Changes where the label starts fading out.")) + .function("setStyle", &TextLabel::SetStyle, v8pp::metadata::docs("void", {v8pp::metadata::param("style", "number", false, "0 none, 1 shadow, 2 outline, 3 box; unsupported values are ignored.")}, "Resets the outline, shadow and background to a preset.")) + .function("detach", &TextLabel::Detach, v8pp::metadata::docs("void", {}, "Stops following an entity; the label returns to its own position.")) + .property("text", &TextLabel::GetText, v8pp::metadata::property_docs("string", "Current replicated label text.")) + .property("color", &TextLabel::GetColor, v8pp::metadata::property_docs("number", "Current packed ARGB color.")) + .property("fontSize", &TextLabel::GetFontSize, v8pp::metadata::property_docs("number", "Current font size.")) + .property("drawDistance", &TextLabel::GetDrawDistance, v8pp::metadata::property_docs("number", "Current maximum rendering distance in world units.")) + .property("fadeDistance", &TextLabel::GetFadeDistance, v8pp::metadata::property_docs("number", "Distance in world units where fading starts.")) + .property("style", &TextLabel::GetStyle, v8pp::metadata::property_docs("number", "The preset nearest the current look: 0 none, 1 shadow, 2 outline, 3 box.")); + + // setColor accepts either a Color or byte components (r, g, b, a from 0-255), so scripts + // don't have to hand-pack this API's ARGB layout. Registered as a raw prototype function + // because v8pp's typed binding can't express the Color|number overload. + cls->prototype_function( + "setColor", + [](const v8::FunctionCallbackInfo &info) { + v8::Isolate *iso = info.GetIsolate(); + auto *self = v8pp::class_::unwrap_object(iso, info.This()); + if (!self) { return; } - } - v8::Local ctx = iso->GetCurrentContext(); - const auto num = [&](int i, int fallback) { - return (info.Length() > i && info[i]->IsNumber()) ? info[i]->Int32Value(ctx).FromMaybe(fallback) : fallback; - }; - self->SetColor(num(0, 0), num(1, 0), num(2, 0), num(3, 255)); - }, - v8pp::metadata::docs("void", - {v8pp::metadata::param("colorOrR", "Color | number", false, "A Color, or the red byte (0-255) when passing components."), - v8pp::metadata::param("g", "number", true, "Green byte (0-255) when passing components."), v8pp::metadata::param("b", "number", true, "Blue byte (0-255) when passing components."), - v8pp::metadata::param("a", "number", true, "Alpha byte (0-255) when passing components; defaults to 255.")}, - "Changes the label's packed ARGB color from a Color or byte components.")); + if (info.Length() >= 1) { + if (auto *col = v8pp::class_::unwrap_object(iso, info[0])) { + self->SetColorPacked(col->toARGB()); + return; + } + } + v8::Local ctx = iso->GetCurrentContext(); + const auto num = [&](int i, int fallback) { + return (info.Length() > i && info[i]->IsNumber()) ? info[i]->Int32Value(ctx).FromMaybe(fallback) : fallback; + }; + self->SetColor(num(0, 0), num(1, 0), num(2, 0), num(3, 255)); + }, + v8pp::metadata::docs("void", + {v8pp::metadata::param("colorOrR", "Color | number", false, "A Color, or the red byte (0-255) when passing components."), v8pp::metadata::param("g", "number", true, "Green byte (0-255) when passing components."), + v8pp::metadata::param("b", "number", true, "Blue byte (0-255) when passing components."), v8pp::metadata::param("a", "number", true, "Alpha byte (0-255) when passing components; defaults to 255.")}, + "Changes the label's packed ARGB color from a Color or byte components.")); + + cls->prototype_function("update", &TextLabel::JS_Update, + v8pp::metadata::docs("void", {v8pp::metadata::param("options", "TextLabelOptions", false, "Only the keys given change; outline, shadow and background are replaced whole.")}, "Changes any part of the label at once. A bad key throws and changes nothing.")); + cls->prototype_function("attachTo", &TextLabel::JS_AttachTo, + v8pp::metadata::docs("void", + {v8pp::metadata::param("target", "Entity | number", false, "The entity to follow, or its network id."), + v8pp::metadata::param("offset", "Vector3 | { x: number; y: number; z: number }", true, "Where to draw relative to it; the game decides the reference point (a player's head, say).")}, + "Follows an entity instead of staying at the label's own position. An attached label streams to every player in its virtual world.")); + cls->prototype_function("getOptions", &TextLabel::JS_GetOptions, v8pp::metadata::docs("TextLabelOptions", {}, "The label's current look and placement, in the shape update() takes.")); + return cls; + }); + } - return *cls; + void TextLabel::RegisterOptionsType(v8::Isolate *isolate) { + auto &type = GetScriptingCatalog(isolate).data_type("TextLabelOptions", "A text label's look and placement. Every key is optional; colours are 0xAARRGGBB numbers, \"#RRGGBB[AA]\" strings or Colors."); + const auto optional = [](const char *name) { + return v8pp::metadata::type {name, {}, true}; + }; + type.add_property("text", optional("string"), "UTF-8 text, at most 1024 bytes; '\\n' breaks lines."); + type.add_property("markup", optional("boolean"), "Parse inline tags: {#RRGGBB} / {#RRGGBBAA} colour, {#} back to `color`, {b}{/b}, {i}{/i}, {{ for a brace. Default false."); + type.add_property("position", optional("Vector3 | { x: number; y: number; z: number }"), "Where the label stands while it is not attached."); + type.add_property("attachTo", optional("Entity | number | null"), "An entity to follow, or its network id; null or 0 detaches."); + type.add_property("offset", optional("Vector3 | { x: number; y: number; z: number }"), "Relative to the attached entity; the game decides the reference point (a player's head, say)."); + type.add_property("fontSize", optional("number"), "Pixels, 1-512. Default 16."); + type.add_property("sizeMode", optional("\"screen\" | \"world\""), "\"screen\" (default): fontSize pixels at any distance. \"world\": fontSize pixels at one metre on a 1080p view, shrinking with distance."); + type.add_property("minSize", optional("number"), "World mode: never smaller than this many pixels."); + type.add_property("maxSize", optional("number"), "World mode: never larger than this many pixels; 0 = no cap."); + type.add_property("color", optional("number | string | Color"), "Text colour. Default white."); + type.add_property("opacity", optional("number"), "0-1, multiplies every colour. Default 1."); + type.add_property("style", optional("\"none\" | \"shadow\" | \"outline\" | \"box\" | number"), "A preset for outline, shadow and background; blocks in the same call refine it."); + type.add_property("outline", optional("boolean | null | { color?: number | string | Color; width?: number }"), "true is 1 px black; width is at most 4."); + type.add_property("shadow", optional("boolean | null | { color?: number | string | Color; offsetX?: number; offsetY?: number; blur?: number }"), "A drop shadow; blur (at most 8) is approximated. Default a 1 px shadow."); + type.add_property("background", optional("boolean | null | { color?: number | string | Color; padding?: number; radius?: number }"), "A plate behind the text; true is translucent black, 4 px padding and radius."); + type.add_property("align", optional("\"left\" | \"center\" | \"right\""), "How lines align with each other. Default \"center\"."); + type.add_property("anchor", optional("\"center\" | \"bottom\" | \"top\""), "Which part of the text block sits on the point. \"bottom\" grows upward, as a tag over a head."); + type.add_property("lineSpacing", optional("number"), "Line height as a multiple of the size. Default 1."); + type.add_property("maxWidth", optional("number"), "Wraps at spaces past this width, in fontSize pixels. 0 (default) never wraps."); + type.add_property("drawDistance", optional("number"), "World units; the label neither draws nor streams past it. Default 30 metres' worth."); + type.add_property("fadeDistance", optional("number"), "World units where fading starts; 0 starts at 75% of drawDistance."); + type.add_property("occlusion", optional("boolean"), "Hidden behind world geometry. Default true."); + type.add_property("virtualWorld", optional("number"), "The virtual world it lives in."); } void TextLabel::Register(v8::Isolate *isolate, v8::Local global) { v8pp::class_ &cls = GetClass(isolate); - auto ctx = isolate->GetCurrentContext(); + RegisterOptionsType(isolate); + auto ctx = isolate->GetCurrentContext(); auto ctorFn = cls.js_function_template()->GetFunction(ctx).ToLocalChecked(); @@ -185,11 +836,11 @@ namespace Framework::Scripting::Builtins { v8pp::metadata::param("y", "number", false, "World-space Y coordinate."), v8pp::metadata::param("z", "number", false, "World-space Z coordinate."), v8pp::metadata::param("text", "string", false, "Initial displayed text."), - v8pp::metadata::param("fontSize", "number", true, "Optional initial font size from 6 to 128."), - v8pp::metadata::param("drawDistance", "number", true, "Optional initial draw distance from 1 to 1000 world units."), - v8pp::metadata::param("virtualWorld", "number", true, "Optional virtual-world identifier; defaults to the entity default."), + v8pp::metadata::param("fontSizeOrOptions", "number | TextLabelOptions", true, "Look and placement, or (the older form) the font size from 1 to 512."), + v8pp::metadata::param("drawDistance", "number", true, "Older form only: the draw distance in world units."), + v8pp::metadata::param("virtualWorld", "number", true, "Older form only: the virtual-world identifier."), }, - "Creates and replicates a world-space text label, up to the server-wide limit of 512.", "Handle for the newly created label.")); + "Creates and replicates a world-space text label, up to the server-wide limit of 512. A bad option throws and creates nothing.", "Handle for the newly created label.")); cls.publish(global, ctorFn); } @@ -198,7 +849,14 @@ namespace Framework::Scripting::Builtins { v8::HandleScope hs(isolate); if (info.Length() < 4 || !info[0]->IsNumber() || !info[1]->IsNumber() || !info[2]->IsNumber() || !info[3]->IsString()) { - ThrowTypeError(isolate, "TextLabel.create: expected (x, y, z, text, fontSize?, drawDistance?, virtualWorld?)"); + ThrowTypeError(isolate, "TextLabel.create: expected (x, y, z, text, fontSize? | options?, drawDistance?, virtualWorld?)"); + return; + } + + // Options are checked before anything exists, so a bad one leaves nothing half-made. + Options options; + const bool withOptions = info.Length() >= 5 && info[4]->IsObject() && !info[4]->IsArray(); + if (withOptions && !ParseOptions(isolate, info[4].As(), _unitsPerMetre, options)) { return; } @@ -224,25 +882,84 @@ namespace Framework::Scripting::Builtins { } label->position = glm::vec3(static_cast(v8pp::from_v8(isolate, info[0])), static_cast(v8pp::from_v8(isolate, info[1])), static_cast(v8pp::from_v8(isolate, info[2]))); - label->text = v8pp::from_v8(isolate, info[3]); auto wrapped = v8pp::class_::create_object(isolate, label->GetNetworkID()); auto *handle = v8pp::class_::unwrap_object(isolate, wrapped); + handle->SetText(v8pp::from_v8(isolate, info[3])); + handle->SetDrawDistance(kDefaultMetres * _unitsPerMetre); - if (info.Length() >= 5 && info[4]->IsNumber()) { - handle->SetFontSize(static_cast(v8pp::from_v8(isolate, info[4]))); - } - if (info.Length() >= 6 && info[5]->IsNumber()) { - handle->SetDrawDistance(static_cast(v8pp::from_v8(isolate, info[5]))); + if (withOptions) { + ApplyOptions(*handle, *label, options); } - if (info.Length() >= 7 && info[6]->IsNumber()) { - handle->SetVirtualWorld(static_cast(v8pp::from_v8(isolate, info[6]))); + else { + if (info.Length() >= 5 && info[4]->IsNumber()) { + handle->SetFontSize(static_cast(v8pp::from_v8(isolate, info[4]))); + } + if (info.Length() >= 6 && info[5]->IsNumber()) { + handle->SetDrawDistance(static_cast(v8pp::from_v8(isolate, info[5]))); + } + if (info.Length() >= 7 && info[6]->IsNumber()) { + handle->SetVirtualWorld(static_cast(v8pp::from_v8(isolate, info[6]))); + } } info.GetReturnValue().Set(wrapped); } + void TextLabel::JS_Update(const v8::FunctionCallbackInfo &info) { + v8::Isolate *isolate = info.GetIsolate(); + auto *self = v8pp::class_::unwrap_object(isolate, info.This()); + auto *label = self ? self->ResolveLabel() : nullptr; + if (!label) { + return; + } + if (info.Length() < 1 || !info[0]->IsObject() || info[0]->IsArray()) { + Fail(isolate, "update expects an options object"); + return; + } + Options options; + if (ParseOptions(isolate, info[0].As(), _unitsPerMetre, options)) { + ApplyOptions(*self, *label, options); + } + } + + void TextLabel::JS_AttachTo(const v8::FunctionCallbackInfo &info) { + v8::Isolate *isolate = info.GetIsolate(); + auto *self = v8pp::class_::unwrap_object(isolate, info.This()); + if (!self || !self->ResolveLabel()) { + return; + } + const auto target = Target(isolate, info.Length() > 0 ? info[0] : v8::Undefined(isolate).As(), "target"); + if (!target) { + return; + } + std::optional offset = glm::vec3(0.0f); + if (info.Length() > 1 && !info[1]->IsUndefined()) { + offset = Vec3(isolate, info[1], "offset"); + if (!offset) { + return; + } + } + if (*target == 0) { + self->Detach(); + } + else { + self->AttachTo(*target, *offset); + } + } + + void TextLabel::JS_GetOptions(const v8::FunctionCallbackInfo &info) { + v8::Isolate *isolate = info.GetIsolate(); + auto *self = v8pp::class_::unwrap_object(isolate, info.This()); + auto *label = self ? self->ResolveLabel() : nullptr; + if (!label) { + info.GetReturnValue().SetNull(); + return; + } + info.GetReturnValue().Set(OptionsObject(isolate, *label, self->GetVirtualWorld())); + } + void TextLabel::UnregisterIsolate(v8::Isolate *isolate) { - _classes.erase(isolate); + _classes.Unregister(isolate); } } // namespace Framework::Scripting::Builtins diff --git a/code/framework/src/scripting/builtins/text_label.h b/code/framework/src/scripting/builtins/text_label.h index 19429fea4..566d5768a 100644 --- a/code/framework/src/scripting/builtins/text_label.h +++ b/code/framework/src/scripting/builtins/text_label.h @@ -23,15 +23,25 @@ namespace Framework::Scripting::Builtins { // Server-side scripting handle for a replicated world-space text label (TextLabelEntity). - class TextLabel final : public Entity { + class TextLabel final: public Entity { public: using LabelEntity = Networking::Replication::Entities::TextLabelEntity; using LabelStyle = Networking::Replication::Entities::TextLabelStyle; - static constexpr int kMaxLabels = 512; + static constexpr int kMaxLabels = 512; + static constexpr size_t kMaxTextBytes = 1024; + static constexpr float kMinFontSize = 1.0f; + static constexpr float kMaxFontSize = 512.0f; + static constexpr float kDefaultMetres = 30.0f; + static constexpr float kMaxDrawMetres = 1000.0f; TextLabel(uint64_t networkId): Entity(networkId) {} + // World units in one metre, for the game to set before scripts run: draw-distance defaults + // and limits are metres, scaled by it. 1 for a game in metres, 100 for one in centimetres. + static void SetUnitsPerMetre(float unitsPerMetre); + static float GetUnitsPerMetre(); + LabelEntity *ResolveLabel() const; std::string GetText() const; @@ -54,6 +64,11 @@ namespace Framework::Scripting::Builtins { uint32_t GetStyle() const; void SetStyle(uint32_t style); + // An attached label streams to every viewer in its virtual world, since its own position no + // longer says where it is drawn. + void AttachTo(uint64_t networkId, const glm::vec3 &offset); + void Detach(); + std::string ToString() const override; void Destroy(); @@ -65,9 +80,15 @@ namespace Framework::Scripting::Builtins { static void UnregisterIsolate(v8::Isolate *isolate); private: - // JS: TextLabel.create(x, y, z, text, fontSize?, drawDistance?, virtualWorld?) -> TextLabel + // JS: TextLabel.create(x, y, z, text, fontSize? | options?, drawDistance?, virtualWorld?) -> TextLabel static void JS_Create(const v8::FunctionCallbackInfo &info); + static void JS_Update(const v8::FunctionCallbackInfo &info); + static void JS_AttachTo(const v8::FunctionCallbackInfo &info); + static void JS_GetOptions(const v8::FunctionCallbackInfo &info); + + static void RegisterOptionsType(v8::Isolate *isolate); - inline static std::unordered_map>> _classes; + inline static float _unitsPerMetre = 1.0f; + inline static ClassCache _classes; }; } // namespace Framework::Scripting::Builtins diff --git a/code/tests/framework_ut.cpp b/code/tests/framework_ut.cpp index 965bd703c..8733563c1 100644 --- a/code/tests/framework_ut.cpp +++ b/code/tests/framework_ut.cpp @@ -39,6 +39,7 @@ #include "modules/state_machine_ut.h" #include "modules/streamed_assets_ut.h" #include "modules/string_utils_ut.h" +#include "modules/text_label_ut.h" #include "modules/transform_batch_ut.h" #include "modules/url_protocol_ut.h" #include "modules/voice_activity_ut.h" @@ -78,6 +79,7 @@ int main() { UNIT_MODULE(bitops); UNIT_MODULE(string_utils); UNIT_MODULE(world_text); + UNIT_MODULE(text_label); UNIT_MODULE(interpolator); UNIT_MODULE(result); UNIT_MODULE(network_packets); diff --git a/code/tests/modules/js_features_ut.h b/code/tests/modules/js_features_ut.h index 0834c7526..cab224aeb 100644 --- a/code/tests/modules/js_features_ut.h +++ b/code/tests/modules/js_features_ut.h @@ -17,6 +17,7 @@ #include "scripting/builtins/imports.h" #include "scripting/builtins/messages.h" #include "scripting/builtins/player.h" +#include "scripting/builtins/text_label.h" #include "scripting/resource/resource.h" #include "scripting/resource/resource_manager.h" #include "utils/version.h" @@ -1016,6 +1017,51 @@ MODULE(js_features, { EventsTestHelper::Cleanup(); }); + // TextLabel options are validated before anything is created, so a bad key throws a TypeError + // with no replication at all, and a good set gets as far as the replication check. + IT("TextLabel.create validates its options before it needs replication", { + EventsTestHelper::Setup(); + NodeEngine engine({}); + EQUALS(engine.Init(), ScriptingError::SCRIPTING_NONE); + + v8::Isolate *isolate = engine.GetIsolate(); + { + v8::Locker locker(isolate); + v8::Isolate::Scope isolateScope(isolate); + v8::HandleScope handleScope(isolate); + v8::Local context = engine.GetContext(); + v8::Context::Scope contextScope(context); + v8::Local target = context->Global(); + Builtins::RegisterValueTypes(isolate, target); + Builtins::TextLabel::Register(isolate, target); + } + + const char *rejected[] { + "{ colour: 1 }", + "{ fontSize: 0 }", + "{ color: 'red' }", + "{ outline: { width: 9 } }", + "{ shadow: 'soft' }", + "{ sizeMode: 'huge' }", + "{ style: 7 }", + "{ attachTo: -1 }", + "{ position: 'here' }", + "{ drawDistance: 0 }", + "{ virtualWorld: 1.5 }", + }; + for (const char *options : rejected) { + STREQUALS(RunJSErrorName(engine, std::string("TextLabel.create(0, 0, 0, 'x', ") + options + ")").c_str(), "TypeError"); + } + + const std::string accepted = "TextLabel.create(0, 0, 0, 'x', { fontSize: 20, opacity: 0.5, outline: { width: 2 }, shadow: { blur: 4 }, background: true, sizeMode: 'world', " + "style: 'box', attachTo: 5, offset: [0, 0, 10], markup: true, align: 'left', anchor: 'bottom', color: new Color(1, 0, 0, 1) })"; + STREQUALS(RunJSErrorName(engine, accepted).c_str(), "Error"); + STREQUALS(RunJSErrorName(engine, "TextLabel.create(0, 0, 0, 'x', 18, 40)").c_str(), "Error"); + + engine.Shutdown(); + EventsTestHelper::Cleanup(); + }); + // ======================================== // JS-FACING NAMING // ======================================== diff --git a/code/tests/modules/text_label_ut.h b/code/tests/modules/text_label_ut.h new file mode 100644 index 000000000..f1811fee3 --- /dev/null +++ b/code/tests/modules/text_label_ut.h @@ -0,0 +1,130 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "networking/replication/entities/text_label_entity.h" +#include "networking/replication/network_entity.h" + +#include + +#include + +MODULE(text_label, { + using namespace Framework::Networking::Replication; + using namespace Framework::Networking::Replication::Entities; + + IT("the full look survives a round trip on the wire", { + TextLabelEntity sent; + sent.position = glm::vec3(1.0f, -2.0f, 3.5f); + sent.text = "Gryffindor\n{#ff0000}Prefect"; + sent.color = 0x80FF0000; + sent.fontSize = 42.0f; + sent.drawDistance = 2500.0f; + sent.fadeDistance = 1800.0f; + sent.attachNetworkId = 77; + sent.attachOffset = glm::vec3(0.0f, 0.0f, 25.0f); + sent.markup = true; + sent.sizeMode = TextLabelSizeMode::World; + sent.minSize = 8.0f; + sent.maxSize = 96.0f; + sent.opacity = 0.5f; + sent.outlineWidth = 2.5f; + sent.outlineColor = 0xFF102030; + sent.shadowColor = 0xC0000000; + sent.shadowOffsetX = 3.0f; + sent.shadowOffsetY = -2.0f; + sent.shadowBlur = 6.0f; + sent.backgroundColor = 0xA0203040; + sent.backgroundPadding = 9.0f; + sent.backgroundRadius = 7.0f; + sent.align = TextLabelAlign::Right; + sent.anchor = TextLabelAnchor::Bottom; + sent.lineSpacing = 1.5f; + sent.maxWidth = 240.0f; + sent.occlusion = false; + sent.style = NearestTextLabelStylePreset(sent); + + MafiaNet::BitStream bs; + FieldSerializer writer(&bs, true); + sent.SerializeFields(writer); + EQUALS(writer.Good(), true); + + TextLabelEntity got; + FieldSerializer reader(&bs, false); + got.SerializeFields(reader); + EQUALS(reader.Good(), true); + + EQUALS((got.position == sent.position), true); + EQUALS((got.text == sent.text), true); + UEQUALS(got.color, sent.color); + EQUALS((got.fontSize == sent.fontSize), true); + EQUALS((got.drawDistance == sent.drawDistance && got.fadeDistance == sent.fadeDistance), true); + UEQUALS(got.attachNetworkId, sent.attachNetworkId); + EQUALS((got.attachOffset == sent.attachOffset), true); + EQUALS(got.markup, true); + EQUALS((got.sizeMode == TextLabelSizeMode::World), true); + EQUALS((got.minSize == 8.0f && got.maxSize == 96.0f && got.opacity == 0.5f), true); + EQUALS((got.outlineWidth == 2.5f), true); + UEQUALS(got.outlineColor, sent.outlineColor); + UEQUALS(got.shadowColor, sent.shadowColor); + EQUALS((got.shadowOffsetX == 3.0f && got.shadowOffsetY == -2.0f && got.shadowBlur == 6.0f), true); + UEQUALS(got.backgroundColor, sent.backgroundColor); + EQUALS((got.backgroundPadding == 9.0f && got.backgroundRadius == 7.0f), true); + EQUALS((got.align == TextLabelAlign::Right && got.anchor == TextLabelAnchor::Bottom), true); + EQUALS((got.lineSpacing == 1.5f && got.maxWidth == 240.0f), true); + EQUALS(got.occlusion, false); + EQUALS((got.style == TextLabelStyle::Box), true); + }); + + IT("a preset sets the parts DrawWorldText draws for it and turns the rest off", { + TextLabelEntity label; + label.backgroundColor = 0xFF00FF00; + label.shadowBlur = 5.0f; + + ApplyTextLabelStylePreset(label, TextLabelStyle::Outline); + EQUALS((label.style == TextLabelStyle::Outline), true); + EQUALS((label.outlineWidth == 1.0f), true); + UEQUALS(label.outlineColor, 0xFF000000u); + UEQUALS(label.shadowColor, 0u); + UEQUALS(label.backgroundColor, 0u); + EQUALS((label.shadowBlur == 0.0f), true); + + ApplyTextLabelStylePreset(label, TextLabelStyle::Box); + UEQUALS(label.backgroundColor, 0x99000000u); + EQUALS((label.backgroundPadding == 4.0f && label.backgroundRadius == 4.0f), true); + EQUALS((label.outlineWidth == 0.0f), true); + + ApplyTextLabelStylePreset(label, TextLabelStyle::Shadow); + UEQUALS(label.shadowColor, 0xFF000000u); + EQUALS((label.shadowOffsetX == 1.0f && label.shadowOffsetY == 1.0f), true); + UEQUALS(label.backgroundColor, 0u); + + ApplyTextLabelStylePreset(label, TextLabelStyle::None); + EQUALS((NearestTextLabelStylePreset(label) == TextLabelStyle::None), true); + }); + + IT("the nearest preset prefers a plate, then an outline, then a shadow", { + TextLabelEntity label; + ApplyTextLabelStylePreset(label, TextLabelStyle::None); + label.shadowColor = 0x80000000; + EQUALS((NearestTextLabelStylePreset(label) == TextLabelStyle::Shadow), true); + label.outlineWidth = 2.0f; + EQUALS((NearestTextLabelStylePreset(label) == TextLabelStyle::Outline), true); + label.outlineColor = 0x00000000; // invisible outline + EQUALS((NearestTextLabelStylePreset(label) == TextLabelStyle::Shadow), true); + label.backgroundColor = 0x40FFFFFF; + EQUALS((NearestTextLabelStylePreset(label) == TextLabelStyle::Box), true); + }); + + IT("a new label's look matches its default Shadow preset", { + TextLabelEntity label; + EQUALS((label.style == TextLabelStyle::Shadow), true); + EQUALS((NearestTextLabelStylePreset(label) == TextLabelStyle::Shadow), true); + }); +}); From a0d78354fddeb2b24867a594a6e76ab084a7560d Mon Sep 17 00:00:00 2001 From: KHeartz Date: Thu, 15 Oct 2026 13:09:08 -0400 Subject: [PATCH 3/5] Widgets: Hex colours need their leading # ParseHexColor never checked the first character, and TextLabel's colour options pass script strings to it directly, so '1234567' was taken as a colour. --- .../src/external/imgui/widgets/world_text_markup.h | 4 ++-- code/tests/modules/js_features_ut.h | 2 ++ code/tests/modules/world_text_ut.h | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/code/framework/src/external/imgui/widgets/world_text_markup.h b/code/framework/src/external/imgui/widgets/world_text_markup.h index 3292d2a36..90b999489 100644 --- a/code/framework/src/external/imgui/widgets/world_text_markup.h +++ b/code/framework/src/external/imgui/widgets/world_text_markup.h @@ -39,7 +39,7 @@ namespace Framework::External::ImGUI::Widgets { // "#RRGGBB" or "#RRGGBBAA" (CSS order) to 0xAARRGGBB. inline std::optional ParseHexColor(std::string_view tag) { - if (tag.size() != 7 && tag.size() != 9) { + if ((tag.size() != 7 && tag.size() != 9) || tag[0] != '#') { return std::nullopt; } uint32_t value = 0; @@ -136,7 +136,7 @@ namespace Framework::External::ImGUI::Widgets { flush(); current.color.reset(); } - else if (!tag.empty() && tag[0] == '#' && (color = Detail::ParseHexColor(tag))) { + else if ((color = Detail::ParseHexColor(tag))) { flush(); current.color = color; } diff --git a/code/tests/modules/js_features_ut.h b/code/tests/modules/js_features_ut.h index cab224aeb..ab1797213 100644 --- a/code/tests/modules/js_features_ut.h +++ b/code/tests/modules/js_features_ut.h @@ -1040,6 +1040,8 @@ MODULE(js_features, { "{ colour: 1 }", "{ fontSize: 0 }", "{ color: 'red' }", + "{ color: '1234567' }", + "{ outline: { color: 'x00ff00' } }", "{ outline: { width: 9 } }", "{ shadow: 'soft' }", "{ sizeMode: 'huge' }", diff --git a/code/tests/modules/world_text_ut.h b/code/tests/modules/world_text_ut.h index f63ff0294..b5551dbfb 100644 --- a/code/tests/modules/world_text_ut.h +++ b/code/tests/modules/world_text_ut.h @@ -54,6 +54,12 @@ MODULE(world_text, { EQUALS((runs[0].text == "{a} {name} {#12} {open"), true); }); + IT("hex colours need their leading #", { + EQUALS((!W::Detail::ParseHexColor("1234567").has_value()), true); + EQUALS((!W::Detail::ParseHexColor("x00ff00").has_value()), true); + UEQUALS(*W::Detail::ParseHexColor("#00ff00"), 0xFF00FF00u); + }); + IT("utf8 decodes every sequence length", { const std::string text = "A\xC3\xAD\xE6\x97\xA5\xF0\x9F\x98\x80"; size_t i = 0; From 745a1e596ebda5831af2e7a6743c251b44748deb Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 16 Oct 2026 13:43:01 -0400 Subject: [PATCH 4/5] Widgets: Look world text glyphs up once per draw Every outline and shadow copy used to repeat FindGlyph for each glyph; a draw now resolves the glyphs into quads once and the copies only re-emit them. Each run of glyphs binds its own font atlas instead of assuming the first span's. DrawWorldText, which only draws the presets, is deprecated in favour of the layout API. --- .../src/external/imgui/widgets/world_text.h | 5 +- .../imgui/widgets/world_text_layout.h | 128 ++++++++++++------ 2 files changed, 87 insertions(+), 46 deletions(-) diff --git a/code/framework/src/external/imgui/widgets/world_text.h b/code/framework/src/external/imgui/widgets/world_text.h index b13a2f289..d2742fed8 100644 --- a/code/framework/src/external/imgui/widgets/world_text.h +++ b/code/framework/src/external/imgui/widgets/world_text.h @@ -45,8 +45,9 @@ namespace Framework::External::ImGUI::Widgets { } // Draw multi-line ('\n') UTF-8 text centered on screenPos (pixels). color is an ImU32 with its - // own alpha; `alpha` multiplies on top (distance fade). - inline void DrawWorldText(ImDrawList *drawList, ImVec2 screenPos, const char *text, ImU32 color, float fontSize, WorldTextStyle style, float alpha = 1.0f) { + // own alpha; `alpha` multiplies on top (distance fade). Only the presets: LayoutWorldText and + // DrawWorldTextLaidOut (world_text_layout.h) draw a label's full look. + [[deprecated("Use LayoutWorldText and DrawWorldTextLaidOut from world_text_layout.h")]] inline void DrawWorldText(ImDrawList *drawList, ImVec2 screenPos, const char *text, ImU32 color, float fontSize, WorldTextStyle style, float alpha = 1.0f) { if (!drawList || !text || !text[0] || alpha <= 0.0f) { return; } diff --git a/code/framework/src/external/imgui/widgets/world_text_layout.h b/code/framework/src/external/imgui/widgets/world_text_layout.h index f67a1263c..3c5f2945f 100644 --- a/code/framework/src/external/imgui/widgets/world_text_layout.h +++ b/code/framework/src/external/imgui/widgets/world_text_layout.h @@ -252,58 +252,96 @@ namespace Framework::External::ImGUI::Widgets { drawList->AddRectFilled(ImVec2(origin.x - pad, origin.y - pad), ImVec2(origin.x + width + pad, origin.y + height + pad), WorldTextModulateAlpha(WorldTextArgb(look.backgroundColor), alpha), look.backgroundRadius * styleScale); } - drawList->PushTexture(text.spans.front().font->OwnerAtlas->TexRef); - constexpr float kItalicShear = 0.2f; const float boldOffset = WorldTextBoldOffset(text.bakeSize) * scale; const ImU32 baseColor = WorldTextModulateAlpha(WorldTextArgb(look.color), alpha); - // One pass of every glyph. tint replaces the spans' colours (outline, shadow); colour glyphs - // (emoji) then have no silhouette to draw, so they only appear in the untinted pass. - const auto pass = [&](ImVec2 offset, std::optional tint) { + // Every glyph is looked up once, into quads relative to the origin; each outline and shadow + // copy below only re-emits them. Glyph UVs move when the atlas repacks, so this is per draw. + struct Quad { + ImVec2 corners[4]; + ImVec2 uvMin; + ImVec2 uvMax; + ImU32 color; + bool colored; // colour glyphs (emoji) have no silhouette for an outline or shadow + ImFontAtlas *atlas; + }; + static thread_local std::vector quads; + quads.clear(); + int spanIndex = -1; + ImFontBaked *baked = nullptr; + ImU32 spanColor = baseColor; + for (const WorldTextGlyph &g : text.glyphs) { + const WorldTextSpan &span = text.spans[g.span]; + if (g.span != spanIndex) { + spanIndex = g.span; + baked = span.font->GetFontBaked(text.bakeSize); + spanColor = span.color ? WorldTextModulateAlpha(WorldTextArgb(*span.color), alpha) : baseColor; + } + if (!baked) { + continue; + } + const ImFontGlyph *glyph = baked->FindGlyph(g.codepoint); + if (!glyph || !glyph->Visible) { + continue; + } + const float x0 = (g.x + glyph->X0) * scale; + const float x1 = (g.x + glyph->X1) * scale; + const float y0 = (g.y + glyph->Y0) * scale; + const float y1 = (g.y + glyph->Y1) * scale; + float topShear = 0.0f, bottomShear = 0.0f; + if (span.fauxItalic) { + const float baseline = (g.y + baked->Ascent) * scale; + topShear = (baseline - y0) * kItalicShear; + bottomShear = (baseline - y1) * kItalicShear; + } + const ImU32 color = glyph->Colored ? (spanColor | ~IM_COL32_A_MASK) : spanColor; + const int copies = span.fauxBold ? 2 : 1; + for (int c = 0; c < copies; ++c) { + const float dx = c * boldOffset; + quads.push_back( + {{ImVec2(x0 + topShear + dx, y0), ImVec2(x1 + topShear + dx, y0), ImVec2(x1 + bottomShear + dx, y1), ImVec2(x0 + bottomShear + dx, y1)}, ImVec2(glyph->U0, glyph->V0), ImVec2(glyph->U1, glyph->V1), color, glyph->Colored != 0, span.font->OwnerAtlas}); + } + } + if (quads.empty()) { + return; + } + + // One pass of every quad. tint replaces the spans' colours (outline, shadow). Fonts from + // different atlases draw from different textures, so each run of one atlas binds its own. + ImFontAtlas *bound = nullptr; + const auto pass = [&](ImVec2 offset, std::optional tint) { if (tint && ((*tint >> IM_COL32_A_SHIFT) & 0xFF) == 0) { return; } - int spanIndex = -1; - ImFontBaked *baked = nullptr; - ImU32 spanColor = baseColor; - for (const WorldTextGlyph &g : text.glyphs) { - const WorldTextSpan &span = text.spans[g.span]; - if (g.span != spanIndex) { - spanIndex = g.span; - baked = span.font->GetFontBaked(text.bakeSize); - spanColor = span.color ? WorldTextModulateAlpha(WorldTextArgb(*span.color), alpha) : baseColor; - } - if (!baked) { - continue; - } - const ImFontGlyph *glyph = baked->FindGlyph(g.codepoint); - if (!glyph || !glyph->Visible || (tint && glyph->Colored)) { - continue; + const ImVec2 at(origin.x + offset.x, origin.y + offset.y); + for (size_t begin = 0; begin < quads.size();) { + ImFontAtlas *atlas = quads[begin].atlas; + size_t end = begin; + int count = 0; + for (; end < quads.size() && quads[end].atlas == atlas; ++end) { + count += tint && quads[end].colored ? 0 : 1; } - ImU32 color = tint ? *tint : spanColor; - if (glyph->Colored) { - color |= ~IM_COL32_A_MASK; - } - - const float x0 = origin.x + offset.x + (g.x + glyph->X0) * scale; - const float x1 = origin.x + offset.x + (g.x + glyph->X1) * scale; - const float y0 = origin.y + offset.y + (g.y + glyph->Y0) * scale; - const float y1 = origin.y + offset.y + (g.y + glyph->Y1) * scale; - float topShear = 0.0f, bottomShear = 0.0f; - if (span.fauxItalic) { - const float baseline = origin.y + offset.y + (g.y + baked->Ascent) * scale; - topShear = (baseline - y0) * kItalicShear; - bottomShear = (baseline - y1) * kItalicShear; - } - - const int copies = span.fauxBold ? 2 : 1; - drawList->PrimReserve(6 * copies, 4 * copies); - for (int c = 0; c < copies; ++c) { - const float dx = c * boldOffset; - drawList->PrimQuadUV(ImVec2(x0 + topShear + dx, y0), ImVec2(x1 + topShear + dx, y0), ImVec2(x1 + bottomShear + dx, y1), ImVec2(x0 + bottomShear + dx, y1), ImVec2(glyph->U0, glyph->V0), ImVec2(glyph->U1, glyph->V0), ImVec2(glyph->U1, glyph->V1), - ImVec2(glyph->U0, glyph->V1), color); + if (count > 0) { + if (atlas != bound) { + if (bound) { + drawList->PopTexture(); + } + drawList->PushTexture(atlas->TexRef); + bound = atlas; + } + drawList->PrimReserve(6 * count, 4 * count); + for (size_t k = begin; k < end; ++k) { + const Quad &q = quads[k]; + if (tint && q.colored) { + continue; + } + const ImVec2 *c = q.corners; + drawList->PrimQuadUV(ImVec2(at.x + c[0].x, at.y + c[0].y), ImVec2(at.x + c[1].x, at.y + c[1].y), ImVec2(at.x + c[2].x, at.y + c[2].y), ImVec2(at.x + c[3].x, at.y + c[3].y), q.uvMin, ImVec2(q.uvMax.x, q.uvMin.y), q.uvMax, ImVec2(q.uvMin.x, q.uvMax.y), + tint ? *tint : q.color); + } } + begin = end; } }; @@ -341,6 +379,8 @@ namespace Framework::External::ImGUI::Widgets { } pass(ImVec2(0.0f, 0.0f), std::nullopt); - drawList->PopTexture(); + if (bound) { + drawList->PopTexture(); + } } } // namespace Framework::External::ImGUI::Widgets From 55ae977940934d7865357ba174d7256d929379e7 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 16 Oct 2026 13:43:11 -0400 Subject: [PATCH 5/5] TextLabel: Attached labels follow their target attachTo takes only another live entity. Instead of streaming to everyone, an attached label moves with its target on the server each tick, in its virtual world, so the interest grid and draw distance apply; peers are still sent its own position, so a moving target doesn't resend it. The label is destroyed with its target, and detaching restores its position and world. The server puts TextLabel on its own interest range, so a long draw distance no longer widens every viewer's query. update() parses its options before looking the label up, since a getter can destroy it. minSize can't exceed maxSize, the outline and blur limits are shared from the entity header, and the names are fully qualified. --- .../integrations/client/ui/text_label_imgui.h | 14 +- .../src/integrations/server/instance.cpp | 11 ++ .../replication/entities/text_label_entity.h | 31 +++- .../src/scripting/builtins/text_label.cpp | 168 +++++++++++++----- .../src/scripting/builtins/text_label.h | 22 ++- code/tests/modules/js_features_ut.h | 38 ++++ code/tests/modules/text_label_ut.h | 147 ++++++++++++++- 7 files changed, 372 insertions(+), 59 deletions(-) diff --git a/code/framework/src/integrations/client/ui/text_label_imgui.h b/code/framework/src/integrations/client/ui/text_label_imgui.h index c052ecf54..82f9bac6c 100644 --- a/code/framework/src/integrations/client/ui/text_label_imgui.h +++ b/code/framework/src/integrations/client/ui/text_label_imgui.h @@ -12,25 +12,27 @@ #include #include +#include #include namespace Framework::Integrations::Client::UI::TextLabels { // A replicated TextLabelEntity in the terms of the world text widgets. The game resolves where the // label is and whether it can be seen; this is only what it looks like. - using ::Framework::Networking::Replication::Entities::TextLabelEntity; static_assert(static_cast(::Framework::External::ImGUI::Widgets::WorldTextAlign::Right) == static_cast(::Framework::Networking::Replication::Entities::TextLabelAlign::Right)); static_assert(static_cast(::Framework::External::ImGUI::Widgets::WorldTextAnchor::Top) == static_cast(::Framework::Networking::Replication::Entities::TextLabelAnchor::Top)); static_assert(static_cast(::Framework::External::ImGUI::Widgets::WorldTextAnchor::Bottom) == static_cast(::Framework::Networking::Replication::Entities::TextLabelAnchor::Bottom)); + static_assert(::Framework::Networking::Replication::Entities::kTextLabelMaxOutlineWidth <= ::Framework::External::ImGUI::Widgets::kWorldTextMaxOutline, "the widget would cut a label's outline short"); + static_assert(::Framework::Networking::Replication::Entities::kTextLabelMaxShadowBlur <= ::Framework::External::ImGUI::Widgets::kWorldTextMaxBlur, "the widget would cut a label's blur short"); - inline ::Framework::External::ImGUI::Widgets::WorldTextLook Look(const TextLabelEntity &label) { + inline ::Framework::External::ImGUI::Widgets::WorldTextLook Look(const ::Framework::Networking::Replication::Entities::TextLabelEntity &label) { ::Framework::External::ImGUI::Widgets::WorldTextLook look; look.color = label.color; - look.outlineWidth = label.outlineWidth; + look.outlineWidth = std::clamp(label.outlineWidth, 0.0f, ::Framework::Networking::Replication::Entities::kTextLabelMaxOutlineWidth); look.outlineColor = label.outlineColor; look.shadowX = label.shadowOffsetX; look.shadowY = label.shadowOffsetY; - look.shadowBlur = label.shadowBlur; + look.shadowBlur = std::clamp(label.shadowBlur, 0.0f, ::Framework::Networking::Replication::Entities::kTextLabelMaxShadowBlur); look.shadowColor = label.shadowColor; look.backgroundColor = label.backgroundColor; look.backgroundPadding = label.backgroundPadding; @@ -39,12 +41,12 @@ namespace Framework::Integrations::Client::UI::TextLabels { return look; } - inline ::Framework::External::ImGUI::Widgets::WorldTextAlign Align(const TextLabelEntity &label) { + inline ::Framework::External::ImGUI::Widgets::WorldTextAlign Align(const ::Framework::Networking::Replication::Entities::TextLabelEntity &label) { return static_cast<::Framework::External::ImGUI::Widgets::WorldTextAlign>(label.align); } // Markup is parsed only when the label asks for it, so plain text never loses a brace. - inline std::vector<::Framework::External::ImGUI::Widgets::WorldTextRun> Runs(const TextLabelEntity &label) { + inline std::vector<::Framework::External::ImGUI::Widgets::WorldTextRun> Runs(const ::Framework::Networking::Replication::Entities::TextLabelEntity &label) { if (label.markup) { return ::Framework::External::ImGUI::Widgets::ParseWorldTextMarkup(label.text); } diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index f244bfe14..8c8e545bb 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -27,6 +27,7 @@ #include "core_modules.h" #include "integrations/shared/rpc/emit_script_event.h" +#include "networking/replication/entities/text_label_entity.h" #include "networking/replication/network_entity.h" #include "networking/replication/replication_manager.h" #include "networking/rpc/chat_message.h" @@ -40,6 +41,7 @@ #include "scripting/builtins/events.h" #include "scripting/builtins/player.h" +#include "scripting/builtins/text_label.h" #include "scripting/engine_scope.h" #include "scripting/node_engine.h" #include "scripting/resource/resource_manager.h" @@ -238,6 +240,9 @@ namespace Framework::Integrations::Server { if (replication) { replication->SetAutoSerializeInterval(static_cast(Utils::Time::SecondsToMs(_opts.worldConfig.tickInterval))); replication->ConfigureGrid(_opts.worldConfig.streamCellSize, _opts.worldConfig.streamWorldMin, _opts.worldConfig.streamWorldMax); + // A text label streams exactly as far as it draws. Held to its own range, labels are + // filed apart and never widen what every other viewer queries. + replication->SetInterestUsesEntityRange(Framework::Networking::Replication::Entities::TextLabelEntity::kTypeName, true); // Replication owns connection teardown: when a peer drops, it notifies the game (avatar // still resolvable) just before destroying and broadcasting the destruction of the avatar. replication->SetOnClientDisconnect([this](MafiaNet::PeerGuid guid) { @@ -1426,6 +1431,12 @@ namespace Framework::Integrations::Server { _voiceServer.SyncAvatars(*replication); } + // Before the interest rebuild in the packet pump, so attached labels stream from where + // their targets are now. + if (replication) { + Framework::Scripting::Builtins::TextLabel::FollowTargets(*replication); + } + if (_networkingEngine) { FW_PROFILE_SCOPE_N("Server::Networking"); _networkingEngine->Update(); diff --git a/code/framework/src/networking/replication/entities/text_label_entity.h b/code/framework/src/networking/replication/entities/text_label_entity.h index db86df5bb..9303754f6 100644 --- a/code/framework/src/networking/replication/entities/text_label_entity.h +++ b/code/framework/src/networking/replication/entities/text_label_entity.h @@ -40,9 +40,13 @@ namespace Framework::Networking::Replication::Entities { Top = 2, }; + // Limits on a label's look: the scripting API enforces them and the client renderer assumes them. + inline constexpr float kTextLabelMaxOutlineWidth = 4.0f; + inline constexpr float kTextLabelMaxShadowBlur = 8.0f; + // A server-owned piece of world-space text, interest-streamed and drawn client-side as a screen // overlay. Mods register the type on both peers. - class TextLabelEntity: public NetworkEntity { + class TextLabelEntity : public NetworkEntity { public: static constexpr const char *kTypeName = "Framework::TextLabel"; @@ -59,6 +63,12 @@ namespace Framework::Networking::Replication::Entities { uint64_t attachNetworkId = 0; glm::vec3 attachOffset = glm::vec3(0.0f); + // Server only: the label's own position and virtual world while it is attached. The server + // moves it with the target, so it streams from there, but peers draw at the target and are + // sent ownPosition instead; detaching puts both back. + glm::vec3 ownPosition = glm::vec3(0.0f); + uint32_t ownVirtualWorld = 0; + // The full look. Colours are 0xAARRGGBB; a colour with zero alpha turns its part off. bool markup = false; // {#RRGGBB[AA]} {#} {b}{/b} {i}{/i}, {{ for a brace TextLabelSizeMode sizeMode = TextLabelSizeMode::Screen; @@ -93,11 +103,18 @@ namespace Framework::Networking::Replication::Entities { ++revision; } + void SerializeTransform(FieldSerializer &fields) override { + fields.Field(Placed(fields)); + fields.Field(velocity); + fields.Field(rotation); + } + void SerializeFields(FieldSerializer &fields) override { // position lives in the NetworkEntity base; serialize it on the reliable channel. - fields.Field(position.x); - fields.Field(position.y); - fields.Field(position.z); + glm::vec3 &placed = Placed(fields); + fields.Field(placed.x); + fields.Field(placed.y); + fields.Field(placed.z); fields.Field(text); fields.Field(color); @@ -143,6 +160,12 @@ namespace Framework::Networking::Replication::Entities { anchor = static_cast(anchorRaw); } } + + private: + // What peers are sent as the label's position: an attached label's own, not its target's. + glm::vec3 &Placed(const FieldSerializer &fields) { + return fields.Writing() && attachNetworkId != 0 ? ownPosition : position; + } }; // Sets the look a preset stands for: the parts DrawWorldText draws for it, the rest off. diff --git a/code/framework/src/scripting/builtins/text_label.cpp b/code/framework/src/scripting/builtins/text_label.cpp index 503eb471f..42beff994 100644 --- a/code/framework/src/scripting/builtins/text_label.cpp +++ b/code/framework/src/scripting/builtins/text_label.cpp @@ -21,19 +21,12 @@ #include #include #include +#include namespace Framework::Scripting::Builtins { namespace { - using Networking::Replication::Entities::TextLabelAlign; - using Networking::Replication::Entities::TextLabelAnchor; - using Networking::Replication::Entities::TextLabelEntity; - using Networking::Replication::Entities::TextLabelSizeMode; - using Networking::Replication::Entities::TextLabelStyle; - - // The world text widget's own limits (world_text_layout.h), which the server can't include. - constexpr double kMaxOutlineWidth = 4.0; - constexpr double kMaxShadowBlur = 8.0; - constexpr double kMaxBlockPixels = 64.0; + // Background padding and corner radius, in pixels. + constexpr double kMaxBlockPixels = 64.0; // Everything an options object can carry, validated in full before any of it is applied, so // a bad key leaves the label as it was. @@ -60,17 +53,17 @@ namespace Framework::Scripting::Builtins { std::optional attachTo; // 0 detaches std::optional offset; std::optional fontSize; - std::optional sizeMode; + std::optional sizeMode; std::optional minSize; std::optional maxSize; std::optional color; std::optional opacity; - std::optional style; + std::optional style; std::optional> outline; // set but empty = turned off std::optional> shadow; std::optional> background; - std::optional align; - std::optional anchor; + std::optional align; + std::optional anchor; std::optional lineSpacing; std::optional maxWidth; std::optional drawDistance; @@ -82,10 +75,13 @@ namespace Framework::Scripting::Builtins { constexpr std::string_view kOptionKeys[] {"text", "markup", "position", "attachTo", "offset", "fontSize", "sizeMode", "minSize", "maxSize", "color", "opacity", "style", "outline", "shadow", "background", "align", "anchor", "lineSpacing", "maxWidth", "drawDistance", "fadeDistance", "occlusion", "virtualWorld"}; - constexpr std::pair kStyles[] {{"none", TextLabelStyle::None}, {"shadow", TextLabelStyle::Shadow}, {"outline", TextLabelStyle::Outline}, {"box", TextLabelStyle::Box}}; - constexpr std::pair kSizeModes[] {{"screen", TextLabelSizeMode::Screen}, {"world", TextLabelSizeMode::World}}; - constexpr std::pair kAligns[] {{"left", TextLabelAlign::Left}, {"center", TextLabelAlign::Center}, {"right", TextLabelAlign::Right}}; - constexpr std::pair kAnchors[] {{"center", TextLabelAnchor::Center}, {"bottom", TextLabelAnchor::Bottom}, {"top", TextLabelAnchor::Top}}; + constexpr std::pair kStyles[] {{"none", Networking::Replication::Entities::TextLabelStyle::None}, {"shadow", Networking::Replication::Entities::TextLabelStyle::Shadow}, + {"outline", Networking::Replication::Entities::TextLabelStyle::Outline}, {"box", Networking::Replication::Entities::TextLabelStyle::Box}}; + constexpr std::pair kSizeModes[] {{"screen", Networking::Replication::Entities::TextLabelSizeMode::Screen}, {"world", Networking::Replication::Entities::TextLabelSizeMode::World}}; + constexpr std::pair kAligns[] {{"left", Networking::Replication::Entities::TextLabelAlign::Left}, {"center", Networking::Replication::Entities::TextLabelAlign::Center}, + {"right", Networking::Replication::Entities::TextLabelAlign::Right}}; + constexpr std::pair kAnchors[] {{"center", Networking::Replication::Entities::TextLabelAnchor::Center}, {"bottom", Networking::Replication::Entities::TextLabelAnchor::Bottom}, + {"top", Networking::Replication::Entities::TextLabelAnchor::Top}}; v8::Local Str(v8::Isolate *isolate, std::string_view text) { return v8::String::NewFromUtf8(isolate, text.data(), v8::NewStringType::kNormal, static_cast(text.size())).ToLocalChecked(); @@ -321,9 +317,9 @@ namespace Framework::Scripting::Builtins { else if (key == "style") { if (value->IsNumber()) { const double raw = value->NumberValue(context).FromMaybe(NAN); - ok = std::isfinite(raw) && raw >= 0.0 && raw <= static_cast(TextLabelStyle::Box) && std::floor(raw) == raw; + ok = std::isfinite(raw) && raw >= 0.0 && raw <= static_cast(Networking::Replication::Entities::TextLabelStyle::Box) && std::floor(raw) == raw; if (ok) { - options.style = static_cast(static_cast(raw)); + options.style = static_cast(static_cast(raw)); } else { Fail(isolate, "style must be 'none', 'shadow', 'outline', 'box' or 0-3"); @@ -342,7 +338,7 @@ namespace Framework::Scripting::Builtins { ok = BlockField(isolate, block, "color", outline.color, [&](auto v) { return ColorValue(isolate, v, "outline.color"); }) && BlockField(isolate, block, "width", outline.width, [&](auto v) { - return Number(isolate, v, "outline.width", 0.0, kMaxOutlineWidth); + return Number(isolate, v, "outline.width", 0.0, Networking::Replication::Entities::kTextLabelMaxOutlineWidth); }); } if (ok) { @@ -362,7 +358,7 @@ namespace Framework::Scripting::Builtins { }) && BlockField(isolate, block, "offsetY", shadow.offsetY, [&](auto v) { return Number(isolate, v, "shadow.offsetY", -32.0, 32.0); }) && BlockField(isolate, block, "blur", shadow.blur, [&](auto v) { - return Number(isolate, v, "shadow.blur", 0.0, kMaxShadowBlur); + return Number(isolate, v, "shadow.blur", 0.0, Networking::Replication::Entities::kTextLabelMaxShadowBlur); }); } if (ok) { @@ -425,7 +421,32 @@ namespace Framework::Scripting::Builtins { return true; } - void ApplyOptions(TextLabel &handle, TextLabelEntity &label, const Options &options) { + // Against the label's current limits, or a new label's when there is none yet. + bool CheckSizes(v8::Isolate *isolate, const Options &options, const Networking::Replication::Entities::TextLabelEntity *label) { + const float minSize = options.minSize.value_or(label ? label->minSize : 0.0f); + const float maxSize = options.maxSize.value_or(label ? label->maxSize : 0.0f); + return maxSize <= 0.0f || minSize <= maxSize || Fail(isolate, "minSize must not exceed maxSize"); + } + + // `handle` is null while the label is still to be created, so it cannot be its own target. + bool CheckTarget(v8::Isolate *isolate, const Options &options, const TextLabel *handle) { + if (!options.attachTo || *options.attachTo == 0) { + return true; + } + auto *replication = CoreModules::GetReplication(); + const bool attachable = handle ? handle->CanAttachTo(*options.attachTo) : replication && replication->GetEntityByNetworkID(*options.attachTo) != nullptr; + return attachable || Fail(isolate, "attachTo must be another live entity"); + } + + // An attached label streams from where its target is, in the target's virtual world. + void Follow(Networking::Replication::Entities::TextLabelEntity &label, const Networking::Replication::NetworkEntity &target) { + label.position = target.position; + if (label.GetVirtualWorld() != target.GetVirtualWorld()) { + label.SetVirtualWorld(target.GetVirtualWorld()); + } + } + + void ApplyOptions(TextLabel &handle, Networking::Replication::Entities::TextLabelEntity &label, const Options &options) { if (options.text) { handle.SetText(*options.text); } @@ -433,13 +454,14 @@ namespace Framework::Scripting::Builtins { label.markup = *options.markup; } if (options.position) { - label.position = *options.position; + (label.attachNetworkId != 0 ? label.ownPosition : label.position) = *options.position; } if (options.attachTo) { if (*options.attachTo == 0) { handle.Detach(); } else { + // Checked by the caller before anything was applied. handle.AttachTo(*options.attachTo, options.offset.value_or(label.attachOffset)); } } @@ -513,7 +535,12 @@ namespace Framework::Scripting::Builtins { label.occlusion = *options.occlusion; } if (options.virtualWorld) { - handle.SetVirtualWorld(*options.virtualWorld); + if (label.attachNetworkId != 0) { + label.ownVirtualWorld = *options.virtualWorld; + } + else { + handle.SetVirtualWorld(*options.virtualWorld); + } } } @@ -528,7 +555,7 @@ namespace Framework::Scripting::Builtins { } // The label as an options object: what update() takes, so one label's look can seed another's. - v8::Local OptionsObject(v8::Isolate *isolate, const TextLabelEntity &label, uint32_t virtualWorld) { + v8::Local OptionsObject(v8::Isolate *isolate, const Networking::Replication::Entities::TextLabelEntity &label, uint32_t virtualWorld) { auto context = isolate->GetCurrentContext(); const auto put = [&](v8::Local object, std::string_view key, v8::Local value) { object->Set(context, Str(isolate, key), value).Check(); @@ -547,7 +574,7 @@ namespace Framework::Scripting::Builtins { auto out = v8::Object::New(isolate); set(out, "text", label.text); set(out, "markup", label.markup); - put(out, "position", vec(label.position)); + put(out, "position", vec(label.attachNetworkId != 0 ? label.ownPosition : label.position)); if (label.attachNetworkId != 0) { set(out, "attachTo", static_cast(label.attachNetworkId)); } @@ -699,16 +726,61 @@ namespace Framework::Scripting::Builtins { } } - void TextLabel::AttachTo(uint64_t networkId, const glm::vec3 &offset) { - if (auto *label = ResolveLabel()) { - label->attachNetworkId = networkId; - label->attachOffset = offset; - label->streaming.alwaysVisible = networkId != 0; + bool TextLabel::CanAttachTo(uint64_t networkId) const { + auto *replication = CoreModules::GetReplication(); + return replication && networkId != 0 && networkId != _id && replication->GetEntityByNetworkID(networkId) != nullptr; + } + + bool TextLabel::AttachTo(uint64_t networkId, const glm::vec3 &offset) { + auto *label = ResolveLabel(); + if (!label || !CanAttachTo(networkId)) { + return false; } + if (label->attachNetworkId == 0) { + label->ownPosition = label->position; + label->ownVirtualWorld = label->GetVirtualWorld(); + } + label->attachNetworkId = networkId; + label->attachOffset = offset; + _attached.insert(_id); + // From where the target is now, rather than from the next tick. + Follow(*label, *CoreModules::GetReplication()->GetEntityByNetworkID(networkId)); + return true; } void TextLabel::Detach() { - AttachTo(0, glm::vec3(0.0f)); + auto *label = ResolveLabel(); + if (!label || label->attachNetworkId == 0) { + return; + } + label->attachNetworkId = 0; + label->attachOffset = glm::vec3(0.0f); + label->position = label->ownPosition; + label->SetVirtualWorld(label->ownVirtualWorld); + _attached.erase(_id); + } + + void TextLabel::FollowTargets(Networking::Replication::ReplicationManager &replication) { + std::vector orphans; + for (auto it = _attached.begin(); it != _attached.end();) { + auto *label = dynamic_cast(replication.GetEntityByNetworkID(*it)); + if (!label || label->attachNetworkId == 0) { + it = _attached.erase(it); + continue; + } + auto *target = replication.GetEntityByNetworkID(label->attachNetworkId); + if (!target) { + orphans.push_back(label); + it = _attached.erase(it); + continue; + } + Follow(*label, *target); + ++it; + } + // A label lives as long as the entity it follows: a name tag must not outstay its player. + for (auto *label : orphans) { + replication.DestroyEntity(label); + } } std::string TextLabel::ToString() const { @@ -786,7 +858,7 @@ namespace Framework::Scripting::Builtins { v8pp::metadata::docs("void", {v8pp::metadata::param("target", "Entity | number", false, "The entity to follow, or its network id."), v8pp::metadata::param("offset", "Vector3 | { x: number; y: number; z: number }", true, "Where to draw relative to it; the game decides the reference point (a player's head, say).")}, - "Follows an entity instead of staying at the label's own position. An attached label streams to every player in its virtual world.")); + "Follows another live entity instead of staying at the label's own position. It streams from where the target is, moves into its virtual world and is destroyed with it.")); cls->prototype_function("getOptions", &TextLabel::JS_GetOptions, v8pp::metadata::docs("TextLabelOptions", {}, "The label's current look and placement, in the shape update() takes.")); return cls; }); @@ -799,8 +871,8 @@ namespace Framework::Scripting::Builtins { }; type.add_property("text", optional("string"), "UTF-8 text, at most 1024 bytes; '\\n' breaks lines."); type.add_property("markup", optional("boolean"), "Parse inline tags: {#RRGGBB} / {#RRGGBBAA} colour, {#} back to `color`, {b}{/b}, {i}{/i}, {{ for a brace. Default false."); - type.add_property("position", optional("Vector3 | { x: number; y: number; z: number }"), "Where the label stands while it is not attached."); - type.add_property("attachTo", optional("Entity | number | null"), "An entity to follow, or its network id; null or 0 detaches."); + type.add_property("position", optional("Vector3 | { x: number; y: number; z: number }"), "Where the label stands while it is not attached, and returns to when detached."); + type.add_property("attachTo", optional("Entity | number | null"), "Another live entity to follow, or its network id; null or 0 detaches. The label is destroyed with it."); type.add_property("offset", optional("Vector3 | { x: number; y: number; z: number }"), "Relative to the attached entity; the game decides the reference point (a player's head, say)."); type.add_property("fontSize", optional("number"), "Pixels, 1-512. Default 16."); type.add_property("sizeMode", optional("\"screen\" | \"world\""), "\"screen\" (default): fontSize pixels at any distance. \"world\": fontSize pixels at one metre on a 1080p view, shrinking with distance."); @@ -819,7 +891,7 @@ namespace Framework::Scripting::Builtins { type.add_property("drawDistance", optional("number"), "World units; the label neither draws nor streams past it. Default 30 metres' worth."); type.add_property("fadeDistance", optional("number"), "World units where fading starts; 0 starts at 75% of drawDistance."); type.add_property("occlusion", optional("boolean"), "Hidden behind world geometry. Default true."); - type.add_property("virtualWorld", optional("number"), "The virtual world it lives in."); + type.add_property("virtualWorld", optional("number"), "The virtual world it lives in while it is not attached, and returns to when detached."); } void TextLabel::Register(v8::Isolate *isolate, v8::Local global) { @@ -856,7 +928,7 @@ namespace Framework::Scripting::Builtins { // Options are checked before anything exists, so a bad one leaves nothing half-made. Options options; const bool withOptions = info.Length() >= 5 && info[4]->IsObject() && !info[4]->IsArray(); - if (withOptions && !ParseOptions(isolate, info[4].As(), _unitsPerMetre, options)) { + if (withOptions && (!ParseOptions(isolate, info[4].As(), _unitsPerMetre, options) || !CheckSizes(isolate, options, nullptr))) { return; } @@ -866,6 +938,10 @@ namespace Framework::Scripting::Builtins { return; } + if (!CheckTarget(isolate, options, nullptr)) { + return; + } + int count = 0; replication->ForEach([&count](LabelEntity *) { ++count; @@ -909,8 +985,7 @@ namespace Framework::Scripting::Builtins { void TextLabel::JS_Update(const v8::FunctionCallbackInfo &info) { v8::Isolate *isolate = info.GetIsolate(); auto *self = v8pp::class_::unwrap_object(isolate, info.This()); - auto *label = self ? self->ResolveLabel() : nullptr; - if (!label) { + if (!self || !self->ResolveLabel()) { return; } if (info.Length() < 1 || !info[0]->IsObject() || info[0]->IsArray()) { @@ -918,7 +993,12 @@ namespace Framework::Scripting::Builtins { return; } Options options; - if (ParseOptions(isolate, info[0].As(), _unitsPerMetre, options)) { + if (!ParseOptions(isolate, info[0].As(), _unitsPerMetre, options)) { + return; + } + // Parsing ran the options' getters, which can destroy the label; look it up only now. + auto *label = self->ResolveLabel(); + if (label && CheckTarget(isolate, options, self) && CheckSizes(isolate, options, label)) { ApplyOptions(*self, *label, options); } } @@ -943,8 +1023,8 @@ namespace Framework::Scripting::Builtins { if (*target == 0) { self->Detach(); } - else { - self->AttachTo(*target, *offset); + else if (!self->AttachTo(*target, *offset)) { + Fail(isolate, "attachTo must be another live entity"); } } @@ -956,7 +1036,7 @@ namespace Framework::Scripting::Builtins { info.GetReturnValue().SetNull(); return; } - info.GetReturnValue().Set(OptionsObject(isolate, *label, self->GetVirtualWorld())); + info.GetReturnValue().Set(OptionsObject(isolate, *label, label->attachNetworkId != 0 ? label->ownVirtualWorld : self->GetVirtualWorld())); } void TextLabel::UnregisterIsolate(v8::Isolate *isolate) { diff --git a/code/framework/src/scripting/builtins/text_label.h b/code/framework/src/scripting/builtins/text_label.h index 566d5768a..9b3f460bf 100644 --- a/code/framework/src/scripting/builtins/text_label.h +++ b/code/framework/src/scripting/builtins/text_label.h @@ -20,10 +20,15 @@ #include #include #include +#include + +namespace Framework::Networking::Replication { + class ReplicationManager; +} // namespace Framework::Networking::Replication namespace Framework::Scripting::Builtins { // Server-side scripting handle for a replicated world-space text label (TextLabelEntity). - class TextLabel final: public Entity { + class TextLabel final : public Entity { public: using LabelEntity = Networking::Replication::Entities::TextLabelEntity; using LabelStyle = Networking::Replication::Entities::TextLabelStyle; @@ -64,10 +69,17 @@ namespace Framework::Scripting::Builtins { uint32_t GetStyle() const; void SetStyle(uint32_t style); - // An attached label streams to every viewer in its virtual world, since its own position no - // longer says where it is drawn. - void AttachTo(uint64_t networkId, const glm::vec3 &offset); + // Follows another live entity; false (and nothing changed) for a missing one or the label + // itself. The label then streams from where the target is and moves into its virtual world, + // and is destroyed with it. + bool AttachTo(uint64_t networkId, const glm::vec3 &offset); + // Back to the label's own position and virtual world. void Detach(); + bool CanAttachTo(uint64_t networkId) const; + + // Server tick, before the interest rebuild: moves attached labels to their targets and + // destroys those whose target is gone. + static void FollowTargets(Networking::Replication::ReplicationManager &replication); std::string ToString() const override; @@ -90,5 +102,7 @@ namespace Framework::Scripting::Builtins { inline static float _unitsPerMetre = 1.0f; inline static ClassCache _classes; + // Labels that may be attached; FollowTargets drops the ones that no longer are. + inline static std::unordered_set _attached; }; } // namespace Framework::Scripting::Builtins diff --git a/code/tests/modules/js_features_ut.h b/code/tests/modules/js_features_ut.h index ab1797213..6dcbdabfa 100644 --- a/code/tests/modules/js_features_ut.h +++ b/code/tests/modules/js_features_ut.h @@ -22,6 +22,8 @@ #include "scripting/resource/resource_manager.h" #include "utils/version.h" +#include "text_label_ut.h" // TextLabelWorld + #include #include @@ -1050,6 +1052,7 @@ MODULE(js_features, { "{ position: 'here' }", "{ drawDistance: 0 }", "{ virtualWorld: 1.5 }", + "{ minSize: 20, maxSize: 10 }", }; for (const char *options : rejected) { STREQUALS(RunJSErrorName(engine, std::string("TextLabel.create(0, 0, 0, 'x', ") + options + ")").c_str(), "TypeError"); @@ -1064,6 +1067,41 @@ MODULE(js_features, { EventsTestHelper::Cleanup(); }); + IT("TextLabel.update survives a getter that destroys the label, and checks targets and sizes", { + EventsTestHelper::Setup(); + TextLabelWorld world; + NodeEngine engine({}); + EQUALS(engine.Init(), ScriptingError::SCRIPTING_NONE); + + v8::Isolate *isolate = engine.GetIsolate(); + { + v8::Locker locker(isolate); + v8::Isolate::Scope isolateScope(isolate); + v8::HandleScope handleScope(isolate); + v8::Local context = engine.GetContext(); + v8::Context::Scope contextScope(context); + v8::Local target = context->Global(); + Builtins::RegisterValueTypes(isolate, target); + Builtins::TextLabel::Register(isolate, target); + } + + // The getter destroys the label and makes another, which may be given its memory: an update + // still holding the old pointer would then write the opacity into the new label. + STREQUALS(RunJSString(engine, "(() => { const l = TextLabel.create(0, 0, 0, 'x'); let fresh; l.update({ get opacity() { l.destroy(); fresh = TextLabel.create(0, 0, 0, 'z'); return 0.25; } }); " + "return fresh.getOptions().opacity + '|' + l.getOptions(); })()") + .c_str(), + "1|null"); + + STREQUALS(RunJSErrorName(engine, "TextLabel.create(0, 0, 0, 'x', { attachTo: 99999 })").c_str(), "TypeError"); + STREQUALS(RunJSErrorName(engine, "const l = TextLabel.create(0, 0, 0, 'x'); l.attachTo(l.id)").c_str(), "TypeError"); + STREQUALS(RunJSErrorName(engine, "const l = TextLabel.create(0, 0, 0, 'x'); l.update({ attachTo: l.id })").c_str(), "TypeError"); + STREQUALS(RunJSErrorName(engine, "const l = TextLabel.create(0, 0, 0, 'x', { maxSize: 10 }); l.update({ minSize: 20 })").c_str(), "TypeError"); + STREQUALS(RunJSErrorName(engine, "const t = TextLabel.create(0, 0, 0, 't'); TextLabel.create(0, 0, 0, 'x', { attachTo: t, minSize: 4, maxSize: 10 })").c_str(), ""); + + engine.Shutdown(); + EventsTestHelper::Cleanup(); + }); + // ======================================== // JS-FACING NAMING // ======================================== diff --git a/code/tests/modules/text_label_ut.h b/code/tests/modules/text_label_ut.h index f1811fee3..59b13536e 100644 --- a/code/tests/modules/text_label_ut.h +++ b/code/tests/modules/text_label_ut.h @@ -8,13 +8,45 @@ #pragma once +#include "core_modules.h" +#include "networking/network_server.h" #include "networking/replication/entities/text_label_entity.h" +#include "networking/replication/entity_registry.h" #include "networking/replication/network_entity.h" +#include "networking/replication/replication_connection.h" +#include "networking/replication/replication_manager.h" +#include "scripting/builtins/text_label.h" #include #include +// A server replication manager on an unstarted peer, published to the scripting builtins. +class TextLabelWorld { + public: + Framework::Networking::NetworkServer peer; + uint32_t targetType = 0; + + TextLabelWorld() { + using namespace Framework::Networking::Replication; + Manager()->Init(&peer, true); + Manager()->ConfigureGrid(100, -4096, 4096); + Manager()->SetInterestUsesEntityRange(Entities::TextLabelEntity::kTypeName, true); + EntityRegistry::Get().Register(Entities::TextLabelEntity::kTypeName); + targetType = EntityRegistry::Get().Register("Test::LabelTarget"); + Framework::CoreModules::SetReplication(Manager()); + } + + ~TextLabelWorld() { + Framework::CoreModules::SetReplication(nullptr); + Manager()->Clear(); + } + + Framework::Networking::Replication::ReplicationManager *Manager() { + return peer.GetReplicationManager(); + } +}; + MODULE(text_label, { using namespace Framework::Networking::Replication; using namespace Framework::Networking::Replication::Entities; @@ -29,6 +61,7 @@ MODULE(text_label, { sent.fadeDistance = 1800.0f; sent.attachNetworkId = 77; sent.attachOffset = glm::vec3(0.0f, 0.0f, 25.0f); + sent.ownPosition = glm::vec3(4.0f, 5.0f, 6.0f); sent.markup = true; sent.sizeMode = TextLabelSizeMode::World; sent.minSize = 8.0f; @@ -60,7 +93,8 @@ MODULE(text_label, { got.SerializeFields(reader); EQUALS(reader.Good(), true); - EQUALS((got.position == sent.position), true); + // Attached: the server's position follows the target; peers get the label's own. + EQUALS((got.position == sent.ownPosition), true); EQUALS((got.text == sent.text), true); UEQUALS(got.color, sent.color); EQUALS((got.fontSize == sent.fontSize), true); @@ -127,4 +161,115 @@ MODULE(text_label, { EQUALS((label.style == TextLabelStyle::Shadow), true); EQUALS((NearestTextLabelStylePreset(label) == TextLabelStyle::Shadow), true); }); + + IT("an attached label's transform carries its own position, not its target's", { + TextLabelEntity sent; + sent.position = glm::vec3(900.0f, 0.0f, 0.0f); + sent.ownPosition = glm::vec3(1.0f, 2.0f, 3.0f); + sent.attachNetworkId = 5; + + MafiaNet::BitStream bs; + FieldSerializer writer(&bs, true); + sent.SerializeTransform(writer); + TextLabelEntity got; + FieldSerializer reader(&bs, false); + got.SerializeTransform(reader); + EQUALS((got.position == sent.ownPosition), true); + + sent.attachNetworkId = 0; + MafiaNet::BitStream detached; + FieldSerializer again(&detached, true); + sent.SerializeTransform(again); + FieldSerializer readAgain(&detached, false); + got.SerializeTransform(readAgain); + EQUALS((got.position == sent.position), true); + }); + + IT("attaching needs another live entity; the label follows it and returns when detached", { + using Framework::Scripting::Builtins::TextLabel; + TextLabelWorld world; + auto *target = world.Manager()->CreateEntity(world.targetType); + auto *label = world.Manager()->CreateEntity(); + target->position = glm::vec3(50.0f, 0.0f, 0.0f); + target->SetVirtualWorld(3); + label->position = glm::vec3(1.0f, 2.0f, 3.0f); + TextLabel handle(label->GetNetworkID()); + + EQUALS(handle.AttachTo(label->GetNetworkID(), glm::vec3(0.0f)), false); + EQUALS(handle.AttachTo(target->GetNetworkID() + 1000, glm::vec3(0.0f)), false); + UEQUALS(label->attachNetworkId, 0u); + + EQUALS(handle.AttachTo(target->GetNetworkID(), glm::vec3(0.0f, 0.0f, 10.0f)), true); + EQUALS((label->position == target->position), true); + UEQUALS(label->GetVirtualWorld(), 3u); + EQUALS(label->streaming.alwaysVisible, false); + + target->position = glm::vec3(60.0f, 0.0f, 0.0f); + target->SetVirtualWorld(4); + TextLabel::FollowTargets(*world.Manager()); + EQUALS((label->position == target->position), true); + UEQUALS(label->GetVirtualWorld(), 4u); + + handle.Detach(); + UEQUALS(label->attachNetworkId, 0u); + EQUALS((label->position == glm::vec3(1.0f, 2.0f, 3.0f)), true); + UEQUALS(label->GetVirtualWorld(), 0u); + }); + + IT("an attached label is destroyed with its target", { + using Framework::Scripting::Builtins::TextLabel; + TextLabelWorld world; + auto *target = world.Manager()->CreateEntity(world.targetType); + auto *label = world.Manager()->CreateEntity(); + const auto labelId = label->GetNetworkID(); + TextLabel handle(labelId); + EQUALS(handle.AttachTo(target->GetNetworkID(), glm::vec3(0.0f)), true); + + world.Manager()->DestroyEntity(target); + TextLabel::FollowTargets(*world.Manager()); + EQUALS((world.Manager()->GetEntityByNetworkID(labelId) == nullptr), true); + EQUALS((handle.ResolveLabel() == nullptr), true); + }); + + IT("an attached label streams only to players near its target", { + using Framework::Scripting::Builtins::TextLabel; + TextLabelWorld world; + auto *manager = world.Manager(); + auto *nearby = manager->CreateEntity(world.targetType); + auto *distant = manager->CreateEntity(world.targetType); + auto *target = manager->CreateEntity(world.targetType); + auto *label = manager->CreateEntity(); + distant->position = glm::vec3(2000.0f, 0.0f, 0.0f); + target->position = glm::vec3(20.0f, 0.0f, 0.0f); + label->position = glm::vec3(-3000.0f, 0.0f, 0.0f); + TextLabel handle(label->GetNetworkID()); + handle.SetDrawDistance(100.0f); + EQUALS(handle.AttachTo(target->GetNetworkID(), glm::vec3(0.0f)), true); + + const MafiaNet::RakNetGUID nearbyGuid(91), distantGuid(92); + manager->SetViewer(MafiaNet::ToPeerGuid(nearbyGuid), nearby); + manager->SetViewer(MafiaNet::ToPeerGuid(distantGuid), distant); + manager->RebuildInterest(); + manager->PushConnection(manager->AllocConnection(MafiaNet::UNASSIGNED_SYSTEM_ADDRESS, nearbyGuid)); + manager->PushConnection(manager->AllocConnection(MafiaNet::UNASSIGNED_SYSTEM_ADDRESS, distantGuid)); + const auto sees = [&](MafiaNet::RakNetGUID guid) { + auto *connection = static_cast(manager->GetConnectionByGUID(guid)); + DataStructures::List create, destroy; + connection->QueryReplicaList(create, destroy); + for (unsigned int i = 0; i < create.Size(); ++i) { + if (create[i] == label) { + return true; + } + } + return false; + }; + EQUALS(sees(nearbyGuid), true); + EQUALS(sees(distantGuid), false); + + target->position = glm::vec3(2010.0f, 0.0f, 0.0f); + TextLabel::FollowTargets(*manager); + manager->RebuildInterest(); + EQUALS(sees(distantGuid), true); + EQUALS(sees(nearbyGuid), false); + }); });