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 new file mode 100644 index 000000000..3c5f2945f --- /dev/null +++ b/code/framework/src/external/imgui/widgets/world_text_layout.h @@ -0,0 +1,386 @@ +/* + * 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); + } + + constexpr float kItalicShear = 0.2f; + const float boldOffset = WorldTextBoldOffset(text.bakeSize) * scale; + const ImU32 baseColor = WorldTextModulateAlpha(WorldTextArgb(look.color), alpha); + + // 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; + } + 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; + } + 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; + } + }; + + // 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); + if (bound) { + 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..90b999489 --- /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) || tag[0] != '#') { + 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 ((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/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..82f9bac6c --- /dev/null +++ b/code/framework/src/integrations/client/ui/text_label_imgui.h @@ -0,0 +1,55 @@ +/* + * 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 +#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. + + 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 ::Framework::Networking::Replication::Entities::TextLabelEntity &label) { + ::Framework::External::ImGUI::Widgets::WorldTextLook look; + look.color = label.color; + 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 = std::clamp(label.shadowBlur, 0.0f, ::Framework::Networking::Replication::Entities::kTextLabelMaxShadowBlur); + 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 ::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 ::Framework::Networking::Replication::Entities::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/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 99f36b35b..9303754f6 100644 --- a/code/framework/src/networking/replication/entities/text_label_entity.h +++ b/code/framework/src/networking/replication/entities/text_label_entity.h @@ -22,8 +22,30 @@ 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, + }; + + // 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 (External::ImGUI::Widgets::DrawWorldText). Mods register the type on both peers. + // overlay. Mods register the type on both peers. class TextLabelEntity : public NetworkEntity { public: static constexpr const char *kTypeName = "Framework::TextLabel"; @@ -34,17 +56,65 @@ 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); + // 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; + 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 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); @@ -60,9 +130,69 @@ 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); } } + + 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. + 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..42beff994 100644 --- a/code/framework/src/scripting/builtins/text_label.cpp +++ b/code/framework/src/scripting/builtins/text_label.cpp @@ -10,13 +10,639 @@ #include "../engine_helpers.h" #include "../scripting_catalog.h" #include "color.h" +#include "vector3.h" #include +#include +#include #include +#include +#include #include +#include +#include namespace Framework::Scripting::Builtins { + namespace { + // 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. + 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", 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(); + } + + 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(Networking::Replication::Entities::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, Networking::Replication::Entities::kTextLabelMaxOutlineWidth); + }); + } + 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, Networking::Replication::Entities::kTextLabelMaxShadowBlur); + }); + } + 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; + } + + // 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); + } + if (options.markup) { + label.markup = *options.markup; + } + if (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)); + } + } + 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) { + if (label.attachNetworkId != 0) { + label.ownVirtualWorld = *options.virtualWorld; + } + else { + 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 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(); + }; + 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.attachNetworkId != 0 ? label.ownPosition : 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 +655,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 +684,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 +693,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 +721,68 @@ 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)); } } } + 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() { + 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 { std::ostringstream ss; ss << "TextLabel{ id: " << _id << ", text: \"" << GetText() << "\" }"; @@ -113,68 +800,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.")); - return *cls; + 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 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; + }); + } + + 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, 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."); + 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 while it is not attached, and returns to when detached."); } 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 +908,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 +921,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) || !CheckSizes(isolate, options, nullptr))) { return; } @@ -208,6 +938,10 @@ namespace Framework::Scripting::Builtins { return; } + if (!CheckTarget(isolate, options, nullptr)) { + return; + } + int count = 0; replication->ForEach([&count](LabelEntity *) { ++count; @@ -224,25 +958,88 @@ 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()); + if (!self || !self->ResolveLabel()) { + 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)) { + 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); + } + } + + 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 if (!self->AttachTo(*target, *offset)) { + Fail(isolate, "attachTo must be another live entity"); + } + } + + 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, label->attachNetworkId != 0 ? label->ownVirtualWorld : 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..9b3f460bf 100644 --- a/code/framework/src/scripting/builtins/text_label.h +++ b/code/framework/src/scripting/builtins/text_label.h @@ -20,6 +20,11 @@ #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). @@ -28,10 +33,20 @@ namespace Framework::Scripting::Builtins { 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 +69,18 @@ namespace Framework::Scripting::Builtins { uint32_t GetStyle() const; void SetStyle(uint32_t style); + // 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; void Destroy(); @@ -65,9 +92,17 @@ 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; + // 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/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..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" @@ -46,9 +47,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 +78,8 @@ 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); @@ -107,6 +112,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/js_features_ut.h b/code/tests/modules/js_features_ut.h index 0834c7526..6dcbdabfa 100644 --- a/code/tests/modules/js_features_ut.h +++ b/code/tests/modules/js_features_ut.h @@ -17,10 +17,13 @@ #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" +#include "text_label_ut.h" // TextLabelWorld + #include #include @@ -1016,6 +1019,89 @@ 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' }", + "{ color: '1234567' }", + "{ outline: { color: 'x00ff00' } }", + "{ outline: { width: 9 } }", + "{ shadow: 'soft' }", + "{ sizeMode: 'huge' }", + "{ style: 7 }", + "{ attachTo: -1 }", + "{ 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"); + } + + 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(); + }); + + 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 new file mode 100644 index 000000000..59b13536e --- /dev/null +++ b/code/tests/modules/text_label_ut.h @@ -0,0 +1,275 @@ +/* + * 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 "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; + + 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.ownPosition = glm::vec3(4.0f, 5.0f, 6.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); + + // 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); + 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); + }); + + 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); + }); +}); 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..b5551dbfb --- /dev/null +++ b/code/tests/modules/world_text_ut.h @@ -0,0 +1,94 @@ +/* + * 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("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; + 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); + }); +});