Das G3-HUD ist abgenommen; der Meilenstein U3 ist damit fertig. - Prüfung (Port von verify_g3, Teile A–H plus I): 718 Prüfungen ohne Fehler. Teil I prüft die Feld-Darstellung um das HUD (U3-Zugabe aus dem Motivvergleich): Aufstellungsflächen, Grotteneingang, ruhender Boss, Namensschilder, Eigentümer-Ring, Ancient-Balken. - Feld um das HUD: Namensschilder für Helden (mit Stufe), Angriffs- champions und den eingesetzten Boss („für Blau“), Eigentümer-Ring, Ancient-Balken bei Schaden, ruhende Schlundkrone auf dem Podest, eigener Grotteneingang, Aufstellungsflächen als eigene Objekte. - Schriften korrigiert: Die Referenz deklariert Cantarell 560/700–760 und Cinzel 700, wendet die Gewichte aber nie an (variation_opentype mit dem String-Schlüssel „wght“ wird von Godot ignoriert). G3 zeigt Regular 400, der Port ebenso — Cinzel-Regular statt Bold, keine Kontur zur Aufdickung. Laufweite aus spacing_glyph umgerechnet; dunkler Saum als Schatten statt TextCore-Kontur. - Werkzeuge: HudViewsTests (14 G3-Motive), HudPlaytest (Spieltest über die echte Oberfläche per Befehlsdateien), ui_ms_per_frame im LuneBenchmark. Layout folgt den echten Panelhöhen, Kamera startet wie Godot _ready auf der eigenen Lane. - Tests: EditMode 23/23, PlayMode 13/13, Kerntests 402/402, Verify 718/0. Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5). - Bilder: 14 Motive bei 1920×1080, Auflösungen 1152×720 bis 2560×1440, Prüf-Schlüsselbilder, Spieltest-Screenshots. - Leistung (macOS-Player mit HUD, 116 Einheiten): 169 fps Übersicht, 218 fps Mid nah; HUD-Skripte 0,090 bzw. 0,078 ms je Bild. - Dokumentation: U3-UI (U3D, Schriftbefund), PORT-QA (U3), PORT-STATUS (U3 fertig, U4 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
12 KiB
C#
264 lines
12 KiB
C#
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using UnityEngine.TextCore.Text;
|
||
using UnityEngine.UIElements;
|
||
using TextElement = UnityEngine.UIElements.TextElement;
|
||
|
||
namespace Luneraria.UI
|
||
{
|
||
/// <summary>
|
||
/// Font roles of the HUD as lune_ui_theme.gd declares them: Cantarell 560, Cantarell 700–760
|
||
/// and Cinzel 700. Godot never applied those weights: its FontVariation used the String key
|
||
/// "wght", which Godot ignores (measured in 4.7.2: the same advances at 400, 560 and 700; only
|
||
/// the integer tag changes them). G3 therefore shows both fonts in their default instance,
|
||
/// Regular 400, and so does the port. The roles stay apart so that U5 can give them weights.
|
||
/// </summary>
|
||
public enum LuneFace
|
||
{
|
||
/// <summary>Cantarell (declared 560).</summary>
|
||
Text,
|
||
/// <summary>Cantarell (declared 700–760).</summary>
|
||
Bold,
|
||
/// <summary>Cinzel (declared 700).</summary>
|
||
Display,
|
||
}
|
||
|
||
/// <summary>A Godot label variation: face, size, colour, glyph spacing, optional outline.</summary>
|
||
public sealed class LuneTextStyle
|
||
{
|
||
public LuneFace Face;
|
||
public float Size;
|
||
public Color Color;
|
||
public float Spacing;
|
||
/// <summary>Godot outline_size (black, alpha .55) for titles, the wave and gold; 0 = none.</summary>
|
||
public float Outline;
|
||
|
||
public LuneTextStyle(LuneFace face, float size, Color color, float spacing = 0f, float outline = 0f)
|
||
{
|
||
Face = face;
|
||
Size = Mathf.Max(LuneUiTheme.MinFont, size);
|
||
Color = color;
|
||
Spacing = spacing;
|
||
Outline = outline;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Factory and text styles of the HUD (Godot LuneUiTheme.label/button and the label variations
|
||
/// of lune_ui_theme.gd). Components build their elements here, so sizes, faces and colours
|
||
/// stay consistent. Fonts come from Settings/LuneUi.asset (LuneUi.Assets, set by the HUD).
|
||
/// </summary>
|
||
public static class LuneUi
|
||
{
|
||
public static LuneUiAssets Assets;
|
||
|
||
static readonly Color Title = LuneUiTheme.Hex("f4e6c4");
|
||
public static readonly Dictionary<string, LuneTextStyle> Styles = new Dictionary<string, LuneTextStyle>
|
||
{
|
||
{ "HudText", new LuneTextStyle(LuneFace.Text, 15, LuneUiTheme.Text) },
|
||
{ "HudMuted", new LuneTextStyle(LuneFace.Text, 14, LuneUiTheme.Muted) },
|
||
{ "HudCaps", new LuneTextStyle(LuneFace.Bold, 14, LuneUiTheme.BronzeLight, 2f) },
|
||
{ "HudBadge", new LuneTextStyle(LuneFace.Bold, 14, LuneUiTheme.Text, 1f) },
|
||
{ "HudLane", new LuneTextStyle(LuneFace.Display, 16, Title, 1f) },
|
||
{ "HudValue", new LuneTextStyle(LuneFace.Bold, 17, LuneUiTheme.Bright) },
|
||
{ "HudPrimary", new LuneTextStyle(LuneFace.Bold, 16, LuneUiTheme.Ink, 1f) },
|
||
{ "HudGold", new LuneTextStyle(LuneFace.Bold, 24, LuneUiTheme.Gold, 0f, 4f) },
|
||
{ "HudName", new LuneTextStyle(LuneFace.Display, 18, Title, 1f) },
|
||
{ "HudTitle", new LuneTextStyle(LuneFace.Display, 24, Title, 1f, 4f) },
|
||
{ "HudWave", new LuneTextStyle(LuneFace.Display, 30, Title, 3f, 4f) },
|
||
// Menu theme (game-over panel, options).
|
||
{ "TitleLabel", new LuneTextStyle(LuneFace.Display, 78, Title, 2f, 6f) },
|
||
{ "PrimaryButton", new LuneTextStyle(LuneFace.Display, 22, LuneUiTheme.Ink, 2f) },
|
||
{ "HeadingLabel", new LuneTextStyle(LuneFace.Display, 34, Title, 1f) },
|
||
{ "SectionLabel", new LuneTextStyle(LuneFace.Display, 16, LuneUiTheme.BronzeLight, 3f) },
|
||
{ "BodyLabel", new LuneTextStyle(LuneFace.Text, 20, LuneUiTheme.Parchment) },
|
||
{ "MutedLabel", new LuneTextStyle(LuneFace.Text, 16, LuneUiTheme.Hex("a9a293")) },
|
||
{ "EyebrowLabel", new LuneTextStyle(LuneFace.Bold, 14, LuneUiTheme.BronzeLight, 2f) },
|
||
{ "CardFactLabel", new LuneTextStyle(LuneFace.Text, 17, LuneUiTheme.Hex("d8cfbc")) },
|
||
};
|
||
|
||
/// <summary>The atlas of a role: Cinzel Regular for Display, Cantarell Regular otherwise (see LuneFace).</summary>
|
||
public static FontAsset Font(LuneFace face)
|
||
{
|
||
if (Assets == null)
|
||
return null;
|
||
return face == LuneFace.Display ? Assets.Display : Assets.Text;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Godot's spacing_glyph (pixels per glyph) as UI Toolkit letter spacing, which counts in
|
||
/// hundredths of the font size (measured: 10 widens each glyph by 0.1 em).
|
||
/// </summary>
|
||
public static float LetterSpacing(float pixels, float fontSize)
|
||
{
|
||
return fontSize > 0f ? pixels * 100f / fontSize : 0f;
|
||
}
|
||
|
||
/// <summary>Applies a text style; size 0 keeps the style's size.</summary>
|
||
public static void Apply(TextElement element, string variation, float size = 0f)
|
||
{
|
||
if (!Styles.TryGetValue(variation, out LuneTextStyle style))
|
||
style = Styles["HudText"];
|
||
FontAsset font = Font(style.Face);
|
||
if (font != null)
|
||
element.style.unityFontDefinition = FontDefinition.FromSDFFont(font);
|
||
float fontSize = size > 0f ? Mathf.Max(LuneUiTheme.MinFont, size) : style.Size;
|
||
element.style.fontSize = fontSize;
|
||
element.style.letterSpacing = LetterSpacing(style.Spacing, fontSize);
|
||
element.style.textShadow = new TextShadow { offset = new Vector2(0f, 1f), blurRadius = 1f, color = new Color(0f, 0f, 0f, .55f) };
|
||
SetColor(element, style.Color, style);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Text colour. Titles, the wave and gold get Godot's dark rim (outline_size, black .55) as
|
||
/// a soft shadow: a TextCore outline straddles the glyph edge and eats the thin strokes of
|
||
/// the regular faces, Godot's outline only grows outwards.
|
||
/// </summary>
|
||
public static void SetColor(TextElement element, Color color, LuneTextStyle style = null)
|
||
{
|
||
element.style.color = color;
|
||
if (style == null)
|
||
style = element.userData as LuneTextStyle;
|
||
if (style == null)
|
||
return;
|
||
element.userData = style;
|
||
element.style.unityTextOutlineWidth = 0f;
|
||
if (style.Outline > 0f)
|
||
element.style.textShadow = new TextShadow { offset = new Vector2(0f, 1f), blurRadius = 1f + style.Outline * .5f, color = new Color(0f, 0f, 0f, .7f) };
|
||
}
|
||
|
||
public static Label Label(string text, string variation = "HudText", VisualElement parent = null, float size = 0f)
|
||
{
|
||
var label = new Label(text ?? "");
|
||
label.pickingMode = PickingMode.Ignore;
|
||
Zero(label);
|
||
label.style.whiteSpace = WhiteSpace.NoWrap;
|
||
Apply(label, variation, size);
|
||
parent?.Add(label);
|
||
return label;
|
||
}
|
||
|
||
/// <summary>Godot clip_text with OVERRUN_TRIM_ELLIPSIS: one line, cut with "…".</summary>
|
||
public static Label Clip(Label label)
|
||
{
|
||
label.style.overflow = Overflow.Hidden;
|
||
label.style.textOverflow = TextOverflow.Ellipsis;
|
||
label.style.whiteSpace = WhiteSpace.NoWrap;
|
||
label.style.flexShrink = 1f;
|
||
label.style.minWidth = 0f;
|
||
return label;
|
||
}
|
||
|
||
/// <summary>Godot AUTOWRAP_WORD_SMART.</summary>
|
||
public static Label Wrap(Label label)
|
||
{
|
||
label.style.whiteSpace = WhiteSpace.Normal;
|
||
label.style.flexShrink = 1f;
|
||
label.style.minWidth = 0f;
|
||
return label;
|
||
}
|
||
|
||
public static void Zero(VisualElement element)
|
||
{
|
||
element.style.marginLeft = 0f;
|
||
element.style.marginRight = 0f;
|
||
element.style.marginTop = 0f;
|
||
element.style.marginBottom = 0f;
|
||
element.style.paddingLeft = 0f;
|
||
element.style.paddingRight = 0f;
|
||
element.style.paddingTop = 0f;
|
||
element.style.paddingBottom = 0f;
|
||
}
|
||
|
||
/// <summary>Godot HBox/VBoxContainer with a separation; ignores the mouse like Godot's boxes in the HUD.</summary>
|
||
public static VisualElement Box(bool vertical, float separation, VisualElement parent = null)
|
||
{
|
||
var box = new LuneBox(vertical, separation);
|
||
parent?.Add(box);
|
||
return box;
|
||
}
|
||
|
||
public static VisualElement Spacer(VisualElement parent)
|
||
{
|
||
var spacer = new VisualElement { pickingMode = PickingMode.Ignore };
|
||
spacer.style.flexGrow = 1f;
|
||
parent.Add(spacer);
|
||
return spacer;
|
||
}
|
||
|
||
public static LuneGlyphElement Glyph(string glyph, Color color, float edge = 18f, VisualElement parent = null)
|
||
{
|
||
var element = new LuneGlyphElement(glyph, color, edge);
|
||
parent?.Add(element);
|
||
return element;
|
||
}
|
||
|
||
/// <summary>Godot `visible`: hidden elements take no space; the parent box closes the gap.</summary>
|
||
public static void Show(VisualElement element, bool visible)
|
||
{
|
||
DisplayStyle display = visible ? DisplayStyle.Flex : DisplayStyle.None;
|
||
if (element.style.display == display)
|
||
return;
|
||
element.style.display = display;
|
||
(element.parent as LuneBox)?.Space();
|
||
}
|
||
|
||
public static bool Visible(VisualElement element)
|
||
{
|
||
return element.resolvedStyle.display != DisplayStyle.None && element.style.display != DisplayStyle.None;
|
||
}
|
||
|
||
public static string Upper(string text)
|
||
{
|
||
return (text ?? "").ToUpperInvariant();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Box container with Godot's separation: the gap goes between children, not around them.
|
||
/// Children added later get the gap too; hidden children (display none) leave no gap.
|
||
/// </summary>
|
||
public sealed class LuneBox : VisualElement
|
||
{
|
||
public readonly bool Vertical;
|
||
public float Separation;
|
||
|
||
public LuneBox(bool vertical, float separation)
|
||
{
|
||
Vertical = vertical;
|
||
Separation = separation;
|
||
pickingMode = PickingMode.Ignore;
|
||
style.flexDirection = vertical ? FlexDirection.Column : FlexDirection.Row;
|
||
if (!vertical)
|
||
style.alignItems = Align.Center;
|
||
RegisterCallback<GeometryChangedEvent>(_ => Space());
|
||
RegisterCallback<AttachToPanelEvent>(_ => Space());
|
||
}
|
||
|
||
/// <summary>Applies the gap before every visible child except the first.</summary>
|
||
public void Space()
|
||
{
|
||
bool first = true;
|
||
foreach (VisualElement child in Children())
|
||
{
|
||
float gap = first ? 0f : Separation;
|
||
if (child.style.display == DisplayStyle.None)
|
||
continue;
|
||
// Only real changes: setting a margin dirties the layout even with the same value.
|
||
if (Vertical)
|
||
{
|
||
StyleLength current = child.style.marginTop;
|
||
if (current.keyword != StyleKeyword.Undefined || Mathf.Abs(current.value.value - gap) > .01f)
|
||
child.style.marginTop = gap;
|
||
}
|
||
else
|
||
{
|
||
StyleLength current = child.style.marginLeft;
|
||
if (current.keyword != StyleKeyword.Undefined || Mathf.Abs(current.value.value - gap) > .01f)
|
||
child.style.marginLeft = gap;
|
||
}
|
||
first = false;
|
||
}
|
||
}
|
||
}
|
||
}
|