Files
Project-M/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
T
kronic e362aaeb43 Import art/VFX asset packs + game-feel systems; normalize texture extensions to lowercase for LFS
Add BefourStudios SciFi environment packs, Gabriel Aguiar VFX, and the
ShaderCrew Toon Shader embedded packages, plus combat/enemy/wave/death
gameplay systems and supporting vault docs/screenshots.

Rename 11 vendor textures from uppercase .PNG/.HDR to lowercase so the
case-sensitive Git LFS filters (*.png/*.hdr) match on case-sensitive
filesystems (Linux CI, case-sensitive macOS), not just locally where
core.ignorecase=true masks the gap. Each .meta moved with its asset so
GUID references are preserved. All ~1000 binaries tracked via LFS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:50:43 -07:00

216 lines
9.5 KiB
C#

using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
using UnityEngine.UI;
namespace ProjectM.Client
{
/// <summary>
/// Client-only screen HUD. A managed presentation SystemBase (<see cref="PresentationSystemGroup"/>) that builds
/// a uGUI overlay canvas in code and drives it from the LOCAL player ghost each frame: a health bar
/// (Health / EffectiveCharacterStats.MaxHealth), an ability-cooldown bar (AbilityCooldown vs NetworkTime
/// ServerTick + EffectiveAbilityStats.CooldownTicks), a live Husk threat count, and a DOWNED/RESPAWNING overlay
/// (the derived <see cref="Dead"/> gate). Presentation only — no simulation, client world only. Bars are
/// RawImages over <c>Texture2D.whiteTexture</c> (always available; the fill width is the RectTransform's
/// anchorMax.x), so the HUD needs no sprite assets — only a resolved builtin font for the labels.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class HudSystem : SystemBase
{
Canvas _canvas;
RectTransform _healthFill;
RectTransform _cooldownFill;
Text _healthText;
Text _threatText;
GameObject _respawnOverlay;
EntityQuery _huskQuery;
protected override void OnCreate()
{
_huskQuery = GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
protected override void OnStartRunning()
{
if (_canvas == null) BuildHud();
}
protected override void OnDestroy()
{
if (_canvas != null) Object.Destroy(_canvas.gameObject);
}
protected override void OnUpdate()
{
if (_canvas == null) return;
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
bool found = false;
float hp = 0f, maxHp = 1f, cdFrac = 1f;
bool dead = false, shielded = false;
foreach (var (health, effChar, effAbility, cd, invuln, entity) in
SystemAPI.Query<RefRO<Health>, RefRO<EffectiveCharacterStats>, RefRO<EffectiveAbilityStats>,
RefRO<AbilityCooldown>, RefRO<RespawnInvuln>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
{
found = true;
hp = health.ValueRO.Current;
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled<Dead>(entity);
// Cooldown fraction via wrap-safe NetworkTick compare (raw uint subtraction is unsafe across
// tick wraparound — the project convention, mirroring AbilityFireSystem/EnemyAISystem).
uint nextFire = cd.ValueRO.NextFireTick;
int cdTicks = effAbility.ValueRO.CooldownTicks;
var nextTick = new NetworkTick(nextFire);
cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick))
? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks)
: 1f;
uint invulnUntil = invuln.ValueRO.UntilTick;
var invulnTick = new NetworkTick(invulnUntil);
shielded = haveTick && invulnUntil != 0 && invulnTick.IsValid && invulnTick.IsNewerThan(nt.ServerTick);
break;
}
_canvas.enabled = found;
if (!found) return;
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
SetFill(_healthFill, frac);
var hc = _healthFill.GetComponent<RawImage>();
if (hc != null)
hc.color = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
if (_healthText != null)
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : "");
SetFill(_cooldownFill, cdFrac);
if (_threatText != null)
_threatText.text = "HUSKS " + _huskQuery.CalculateEntityCount();
_respawnOverlay.SetActive(dead);
}
static void SetFill(RectTransform fill, float frac)
{
if (fill == null) return;
var max = fill.anchorMax;
max.x = Mathf.Clamp01(frac);
fill.anchorMax = max;
}
// ---- uGUI construction (code-built; no prefab/sprite assets) ----
void BuildHud()
{
var go = new GameObject("~HUD");
_canvas = go.AddComponent<Canvas>();
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
_canvas.sortingOrder = 100;
var scaler = go.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
go.AddComponent<GraphicRaycaster>();
var font = GetFont();
// Health bar (bottom-left).
var hpBg = MakeBar("HealthBg", _canvas.transform, new Color(0f, 0f, 0f, 0.6f),
new Vector2(40, 46), new Vector2(440, 40));
_healthFill = MakeFill("HealthFill", hpBg, new Color(0.25f, 0.9f, 0.5f));
_healthText = MakeText("HealthText", hpBg, "100 / 100", 24, TextAnchor.MiddleCenter, Color.white, font);
// Cooldown bar (just above health).
var cdBg = MakeBar("CooldownBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f),
new Vector2(40, 92), new Vector2(440, 14));
_cooldownFill = MakeFill("CooldownFill", cdBg, new Color(0.4f, 0.8f, 1f));
// Threat count (top-right).
_threatText = MakeText("ThreatText", _canvas.transform, "HUSKS 0", 30, TextAnchor.UpperRight,
new Color(1f, 0.62f, 0.4f), font);
var trt = _threatText.rectTransform;
trt.anchorMin = new Vector2(1, 1); trt.anchorMax = new Vector2(1, 1); trt.pivot = new Vector2(1, 1);
trt.anchoredPosition = new Vector2(-40, -30); trt.sizeDelta = new Vector2(380, 50);
// Downed / respawning overlay (full screen, toggled by Dead).
_respawnOverlay = new GameObject("RespawnOverlay", typeof(RectTransform));
_respawnOverlay.transform.SetParent(_canvas.transform, false);
var ov = _respawnOverlay.AddComponent<RawImage>();
ov.texture = Texture2D.whiteTexture;
ov.color = new Color(0.35f, 0f, 0f, 0.35f);
ov.raycastTarget = false;
Stretch((RectTransform)_respawnOverlay.transform);
var rtext = MakeText("RespawnText", _respawnOverlay.transform, "DOWNED - RESPAWNING", 56,
TextAnchor.MiddleCenter, new Color(1f, 0.45f, 0.4f), font);
Stretch(rtext.rectTransform);
_respawnOverlay.SetActive(false);
}
static RectTransform MakeBar(string name, Transform parent, Color color, Vector2 anchoredPos, Vector2 size)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var img = go.AddComponent<RawImage>();
img.texture = Texture2D.whiteTexture;
img.color = color;
img.raycastTarget = false;
var rt = (RectTransform)go.transform;
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.zero; rt.pivot = Vector2.zero;
rt.anchoredPosition = anchoredPos; rt.sizeDelta = size;
return rt;
}
static RectTransform MakeFill(string name, RectTransform parent, Color color)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var img = go.AddComponent<RawImage>();
img.texture = Texture2D.whiteTexture;
img.color = color;
img.raycastTarget = false;
var rt = (RectTransform)go.transform;
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.pivot = Vector2.zero;
rt.offsetMin = new Vector2(3, 3); rt.offsetMax = new Vector2(-3, -3);
return rt;
}
static Text MakeText(string name, Transform parent, string text, int size, TextAnchor anchor, Color color, Font font)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var t = go.AddComponent<Text>();
t.text = text;
t.font = font;
t.fontSize = size;
t.alignment = anchor;
t.color = color;
t.horizontalOverflow = HorizontalWrapMode.Overflow;
t.verticalOverflow = VerticalWrapMode.Overflow;
t.raycastTarget = false;
Stretch(t.rectTransform);
return t;
}
static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
}
static Font GetFont()
{
Font f = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
if (f == null) f = Resources.GetBuiltinResource<Font>("Arial.ttf");
if (f == null) f = Font.CreateDynamicFontFromOSFont(new[] { "Arial", "Liberation Sans", "DejaVu Sans" }, 28);
return f;
}
}
}