This commit is contained in:
2026-06-07 13:28:25 -07:00
parent 0df0b45163
commit 25b53cb062
25 changed files with 1573 additions and 125 deletions
@@ -0,0 +1,128 @@
using ProjectM.Simulation;
using UnityEngine;
using UnityEngine.TextCore.Text;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Curated Synty sci-fi-soldier HUD skin — serialized <see cref="Sprite"/> + <see cref="Font"/> references
/// harvested from the InterfaceSciFiSoldierHUD / InterfaceCore packs and authored into
/// <c>Assets/_Project/Resources/HudTheme.asset</c>. Those source sprites/fonts live under
/// <c>Assets/Synty/…</c> (NOT a Resources folder), so a serialized asset like this is the build-safe way to
/// pull a curated subset into the player build (the dependency walker follows the refs). Loaded null-safe via
/// <see cref="Get"/> (mirrors <see cref="MenuUi.LoadPanelSettings"/>); EVERY consumer must null-check the theme
/// AND each field and fall back to the flat-color HUD, so a missing asset/ref never breaks (or magenta-s) the
/// HUD. Fonts are applied as cached SDF font definitions: a <see cref="FontAsset"/> is built once per font from
/// the serialized <see cref="Font"/> (crisp at any size, supports outlines) and re-built per play session (the
/// static cache is reset on play-enter, like the project's other static presentation bridges).
/// </summary>
[CreateAssetMenu(menuName = "ProjectM/HUD Theme", fileName = "HudTheme")]
public class HudTheme : ScriptableObject
{
[Header("Fonts")]
public Font DisplayFont; // Orbitron-ExtraBold — numerics + phase words (the HUD's authoritative voice)
public Font BodyFont; // Exo 2.0 SemiBold — labels
public Font BodyLightFont; // Exo 2.0 Regular — captions / hints
[Header("Panels & meters")]
public Sprite PanelBox; // 9-sliced card background (tinted per cluster)
public Sprite BarTrack; // bar fill skin
public Sprite Vignette; // radial edge gradient (low-HP / hurt flash / downed)
public Sprite Glow; // soft glow accent (selection / pulse)
public Sprite PipActive; // filled hex pip (goal meter)
public Sprite PipInactive; // empty hex pip
[Header("Status / info icons")]
public Sprite HealthIcon;
public Sprite ShieldIcon;
public Sprite ThreatIcon;
public Sprite GoalIcon;
public Sprite CooldownIcon;
public Sprite LocationBaseIcon;
public Sprite LocationExpeditionIcon;
[Header("Resource icons")]
public Sprite AetherIcon;
public Sprite OreIcon;
public Sprite BioIcon;
[Header("Structure icons")]
public Sprite TurretIcon;
public Sprite WallIcon;
public Sprite PylonIcon;
public Sprite HarvesterIcon;
public Sprite FabricatorIcon;
public Sprite ConveyorIcon;
[Header("Build-mode control glyphs")]
public Sprite KbmPlace; // LMB
public Sprite KbmCancel; // RMB
public Sprite PadPlace; // gamepad A / south
public Sprite PadCancel; // gamepad B / east
public Sprite PadRotate; // gamepad LB
public Sprite PadExit; // gamepad Menu / start
// ---- null-safe load + cache (mirrors MenuUi.LoadPanelSettings Resources idiom) ----
static HudTheme _cached;
static bool _tried;
/// <summary>The loaded theme, or null if the asset is missing. Callers MUST null-check this and each field.</summary>
public static HudTheme Get()
{
if (_tried) return _cached;
_tried = true;
_cached = Resources.Load<HudTheme>("HudTheme");
return _cached;
}
/// <summary>Icon for a <see cref="StructureType"/> byte (null → caller falls back to the structure name text).</summary>
public Sprite StructureIcon(byte type)
{
switch (type)
{
case StructureType.Turret: return TurretIcon;
case StructureType.Wall: return WallIcon;
case StructureType.Pylon: return PylonIcon;
case StructureType.Harvester: return HarvesterIcon;
case StructureType.Fabricator: return FabricatorIcon;
case StructureType.Conveyor: return ConveyorIcon;
default: return null;
}
}
// ---- cached SDF font definitions (one FontAsset per font, built once, reset per play session) ----
static FontAsset _displayFa, _bodyFa, _bodyLightFa;
static bool _displayTried, _bodyTried, _bodyLightTried;
/// <summary>Apply the display font (Orbitron) to a style, if available. No-op otherwise (stock font).</summary>
public void ApplyDisplay(IStyle style) => Apply(style, DisplayFont, ref _displayFa, ref _displayTried);
/// <summary>Apply the body font (Exo 2.0 SemiBold) to a style, if available.</summary>
public void ApplyBody(IStyle style) => Apply(style, BodyFont, ref _bodyFa, ref _bodyTried);
/// <summary>Apply the light body font (Exo 2.0 Regular) to a style, if available.</summary>
public void ApplyBodyLight(IStyle style) => Apply(style, BodyLightFont, ref _bodyLightFa, ref _bodyLightTried);
static void Apply(IStyle style, Font font, ref FontAsset fa, ref bool tried)
{
if (!tried)
{
tried = true;
if (font != null) fa = FontAsset.CreateFontAsset(font); // dynamic SDF atlas, built once per session
}
if (fa != null)
style.unityFontDefinition = new StyleFontDefinition(FontDefinition.FromSDFFont(fa));
}
// Fast-enter-playmode keeps statics alive across sessions; the runtime FontAssets are destroyed on play-exit,
// so a stale cache would reference a destroyed atlas. Reset everything so it re-loads/re-builds per session.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
_cached = null; _tried = false;
_displayFa = _bodyFa = _bodyLightFa = null;
_displayTried = _bodyTried = _bodyLightTried = false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b0ba3a2a48e57547b89018d8aba1134
+136 -28
View File
@@ -4,41 +4,39 @@ using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// UI Toolkit factories for the in-game HUD — a thin extension of <see cref="MenuUi"/>'s Aether palette so the
/// HUD reads in the same visual language as the menu / pause / settings screens. Bars are a dark rounded track
/// + a percent-width fill; labels use the shared text weights/colours. Every element is built
/// <c>pickingMode = Ignore</c> by default so the HUD never eats clicks meant for the game world (only the
/// interactive build-palette buttons opt back into picking).
/// UI Toolkit factories for the in-game HUD — a thin extension of <see cref="MenuUi"/>'s Aether palette,
/// now skinned with the curated Synty sci-fi-soldier kit (<see cref="HudTheme"/>). Panels are 9-sliced Synty
/// boxes tinted into the Aether palette; bars are a skinned track + a percent-width fill; numerics use the
/// Orbitron display font and labels the Exo 2.0 body font. EVERYTHING is null-safe: if <see cref="HudTheme"/>
/// (or a given sprite/font) is missing, each factory falls back to the original flat-colour look, so the HUD
/// is shippable with or without the theme asset. Every element is <c>pickingMode = Ignore</c> by default so
/// the HUD never eats clicks meant for the game world (only the interactive build-palette slots opt back in).
/// </summary>
public static class HudUi
{
public static readonly Color Track = new(0f, 0f, 0f, 0.55f);
/// <summary>A dark rounded bar track with a percent-width fill child (returned via <paramref name="fill"/>).</summary>
public static VisualElement Bar(float width, float height, Color fillColor, out VisualElement fill)
{
var track = new VisualElement();
track.style.width = width;
track.style.height = height;
track.style.backgroundColor = Track;
track.style.paddingLeft = 2; track.style.paddingRight = 2;
track.style.paddingTop = 2; track.style.paddingBottom = 2;
track.style.flexDirection = FlexDirection.Row;
track.pickingMode = PickingMode.Ignore;
MenuUi.Round(track, 4);
// ---- text (Orbitron display vs Exo body, theme-driven with a bold fallback) ----
fill = new VisualElement();
fill.style.height = Length.Percent(100);
fill.style.width = Length.Percent(100);
fill.style.backgroundColor = fillColor;
fill.pickingMode = PickingMode.Ignore;
MenuUi.Round(fill, 3);
track.Add(fill);
return track;
/// <summary>A body label (Exo 2.0 when themed) — labels, captions, hints.</summary>
public static Label Text(string text, int size, Color color, TextAnchor align)
{
var l = MakeLabel(text, size, color, align);
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBody(l.style);
return l;
}
/// <summary>A bold HUD label (non-interactive).</summary>
public static Label Text(string text, int size, Color color, TextAnchor align)
/// <summary>A display label (Orbitron when themed) — numerics + phase words, the authoritative HUD voice.</summary>
public static Label Display(string text, int size, Color color, TextAnchor align)
{
var l = MakeLabel(text, size, color, align);
var theme = HudTheme.Get();
if (theme != null) theme.ApplyDisplay(l.style);
return l;
}
static Label MakeLabel(string text, int size, Color color, TextAnchor align)
{
var l = new Label(text);
l.style.fontSize = size;
@@ -49,13 +47,54 @@ namespace ProjectM.Client
return l;
}
// ---- bars ----
/// <summary>A dark rounded bar track with a percent-width fill child (returned via <paramref name="fill"/>).</summary>
public static VisualElement Bar(float width, float height, Color fillColor, out VisualElement fill)
{
var theme = HudTheme.Get();
var trackSpr = theme != null ? theme.BarTrack : null;
var track = new VisualElement();
track.style.width = width;
track.style.height = height;
track.style.paddingLeft = 2; track.style.paddingRight = 2;
track.style.paddingTop = 2; track.style.paddingBottom = 2;
track.style.flexDirection = FlexDirection.Row;
track.pickingMode = PickingMode.Ignore;
fill = new VisualElement();
fill.style.height = Length.Percent(100);
fill.style.width = Length.Percent(100);
fill.style.backgroundColor = fillColor;
fill.pickingMode = PickingMode.Ignore;
MenuUi.Round(fill, 3);
if (trackSpr != null)
{
// Bar_Angled ships an 80/0 border → UITK 9-slices it horizontally from the art; no style override.
track.style.backgroundImage = new StyleBackground(Background.FromSprite(trackSpr));
track.style.unityBackgroundImageTintColor = new Color(0.04f, 0.06f, 0.09f, 0.92f);
}
else
{
track.style.backgroundColor = Track;
MenuUi.Round(track, 4);
}
track.Add(fill);
return track;
}
/// <summary>Set a fill's width to a 0..1 fraction of its track.</summary>
public static void SetFill(VisualElement fill, float frac)
{
if (fill != null) fill.style.width = Length.Percent(Mathf.Clamp01(frac) * 100f);
}
/// <summary>A semi-transparent rounded panel for grouping a cluster of HUD elements.</summary>
// ---- panels / icons / glyphs ----
/// <summary>A grouping container (no background). Transparent, click-through.</summary>
public static VisualElement Group(Align items = Align.FlexStart)
{
var g = new VisualElement();
@@ -64,5 +103,74 @@ namespace ProjectM.Client
return g;
}
/// <summary>
/// A 9-sliced Synty panel tinted into the Aether palette; falls back to a flat translucent rounded panel
/// when the theme/sprite is missing. <paramref name="tint"/> multiplies the (light-grey) skin, so a dark
/// tint reads as panel-dark while preserving the printed bevels.
/// </summary>
public static VisualElement Panel(Color tint)
{
var p = new VisualElement();
p.pickingMode = PickingMode.Ignore;
var theme = HudTheme.Get();
var box = theme != null ? theme.PanelBox : null;
if (box != null)
{
p.style.backgroundImage = new StyleBackground(Background.FromSprite(box));
p.style.unityBackgroundImageTintColor = tint;
// 9-slice uses the sprite's AUTHORED border (Box_Glass ships 25px); no style override → no
// "borders overridden by style slices" log, and the art's intended corners are preserved.
}
else
{
p.style.backgroundColor = tint;
MenuUi.Round(p, 8);
MenuUi.Border(p, new Color(1f, 1f, 1f, 0.08f), 1);
}
return p;
}
/// <summary>A fixed-size icon element backed by a Synty sprite (tinted). Returns an empty element if null.</summary>
public static VisualElement Icon(Sprite sprite, float size, Color tint)
{
var e = new VisualElement();
e.style.width = size; e.style.height = size;
e.style.flexShrink = 0;
e.pickingMode = PickingMode.Ignore;
if (sprite != null)
{
e.style.backgroundImage = new StyleBackground(Background.FromSprite(sprite));
e.style.unityBackgroundImageTintColor = tint;
e.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain));
}
return e;
}
/// <summary>True when the theme is loaded and a sprite is present (callers choose icon vs text fallback).</summary>
/// <summary>
/// An input-prompt chip: the Synty key/button glyph (left untinted to keep its printed face) when present,
/// else a bordered text keycap with <paramref name="fallbackText"/> (e.g. "R", "Esc").
/// </summary>
public static VisualElement Glyph(Sprite sprite, string fallbackText, float size)
{
if (sprite != null) return Icon(sprite, size, Color.white);
var cap = new VisualElement();
cap.style.minWidth = size; cap.style.height = size;
cap.style.paddingLeft = 7; cap.style.paddingRight = 7;
cap.style.alignItems = Align.Center; cap.style.justifyContent = Justify.Center;
cap.style.backgroundColor = new Color(0.14f, 0.17f, 0.22f, 0.95f);
cap.style.flexShrink = 0;
cap.pickingMode = PickingMode.Ignore;
MenuUi.Round(cap, 4);
MenuUi.Border(cap, new Color(1f, 1f, 1f, 0.25f), 1);
cap.Add(Text(fallbackText, Mathf.Max(10, (int)(size * 0.5f)), MenuUi.TextCol, TextAnchor.MiddleCenter));
return cap;
}
/// <summary>Apply a uniform 9-slice (horizontal / vertical source-texture px) to a skinned element.</summary>
}
}
@@ -0,0 +1,45 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Pure HUD presentation math (unit-tested, no ECS / no UnityEngine.Object). Drives the low-health screen
/// vignette + the transient hurt-flash so the damage feedback is deterministic and testable. Used by the
/// client-only observe-only <see cref="HudSystem"/> in PresentationSystemGroup; the flash decay runs on the
/// wall-frame <c>SystemAPI.Time.DeltaTime</c>, which is correct in a presentation system (the dt-trap only
/// applies to plain simulation systems).
/// </summary>
public static class HudVisualMath
{
/// <summary>Below this health fraction the low-health vignette starts to ramp in.</summary>
public const float LowHealthThreshold = 0.35f;
/// <summary>Maximum steady vignette opacity at 0 HP.</summary>
public const float MaxVignetteOpacity = 0.55f;
/// <summary>Opacity boost added by a fresh hit (a hurt flash), decaying back to the steady vignette.</summary>
public const float HurtFlashKick = 0.40f;
/// <summary>Seconds for a full hurt-flash kick to decay to zero.</summary>
public const float HurtFlashDuration = 0.40f;
/// <summary>0 at/above the low-health threshold, ramping to 1 as the health fraction falls to 0.</summary>
public static float VignetteIntensity(float healthFrac)
{
float f = Mathf.Clamp01(healthFrac);
if (f >= LowHealthThreshold) return 0f;
return Mathf.Clamp01((LowHealthThreshold - f) / LowHealthThreshold);
}
/// <summary>Steady low-health vignette opacity (no flash) from the current health fraction.</summary>
public static float VignetteOpacity(float healthFrac) => VignetteIntensity(healthFrac) * MaxVignetteOpacity;
/// <summary>Decay an active hurt-flash boost toward 0 over <paramref name="dt"/> seconds (clamped, never negative).</summary>
public static float DecayFlash(float flash, float dt)
=> Mathf.Max(0f, flash - (HurtFlashKick / HurtFlashDuration) * Mathf.Max(0f, dt));
/// <summary>Final overlay opacity: the steady low-health vignette plus the current flash boost, clamped to 1.</summary>
public static float CombinedOpacity(float healthFrac, float flash)
=> Mathf.Clamp01(VignetteOpacity(healthFrac) + Mathf.Clamp01(flash));
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01204e46e6631634fa1007f4b3640035
@@ -66,6 +66,8 @@ namespace ProjectM.Client
l.style.color = Accent;
l.style.unityTextAlign = TextAnchor.MiddleCenter;
l.style.marginBottom = 16;
var theme = HudTheme.Get();
if (theme != null) theme.ApplyDisplay(l.style);
return l;
}
@@ -76,6 +78,8 @@ namespace ProjectM.Client
l.style.color = SubCol;
l.style.unityTextAlign = TextAnchor.MiddleCenter;
l.style.marginTop = 8; l.style.marginBottom = 6;
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBodyLight(l.style);
return l;
}
@@ -90,6 +94,8 @@ namespace ProjectM.Client
b.style.unityFontStyleAndWeight = FontStyle.Bold;
Round(b, 6);
Border(b, new Color(0f, 0f, 0f, 0f), 0);
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBody(b.style);
return b;
}