UI
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user