311 lines
14 KiB
C#
311 lines
14 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;
|
|
Text _phaseText;
|
|
Text _resourceText;
|
|
Text _locationText;
|
|
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);
|
|
|
|
// Macro-loop HUD (phase + cycle + countdown + location), read before the per-player early-out so it persists pre-spawn.
|
|
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
|
if (_phaseText != null && haveCycle)
|
|
{
|
|
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
|
string detail;
|
|
if (cyc.Phase == CyclePhase.Defend)
|
|
detail = _huskQuery.CalculateEntityCount() + " HUSKS";
|
|
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
|
|
detail = (endTick.TicksSince(nt.ServerTick) / 60) + "s";
|
|
else
|
|
detail = "";
|
|
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "") + " CYCLE " + cyc.CycleNumber;
|
|
_phaseText.color = PhaseColor(cyc.Phase);
|
|
}
|
|
else if (_phaseText != null)
|
|
{
|
|
_phaseText.text = "";
|
|
}
|
|
|
|
if (_locationText != null)
|
|
{
|
|
var cam = Camera.main;
|
|
bool onExpedition = cam != null && cam.transform.position.x > 500f;
|
|
_locationText.text = onExpedition
|
|
? "ON EXPEDITION - return through the gate"
|
|
: "AT BASE" + (haveCycle && cyc.Phase == CyclePhase.Expedition ? " - step into the gate to deploy" : "");
|
|
_locationText.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
|
|
}
|
|
|
|
if (_resourceText != null)
|
|
{
|
|
string res = "";
|
|
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
|
|
{
|
|
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
|
|
int aether = 0, ore = 0, bio = 0;
|
|
for (int i = 0; i < buf.Length; i++)
|
|
{
|
|
var en = buf[i];
|
|
if (en.ItemId == ResourceId.Aether) aether = en.Count;
|
|
else if (en.ItemId == ResourceId.Ore) ore = en.Count;
|
|
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
|
|
}
|
|
res = "AETHER " + aether + " ORE " + ore + " BIO " + bio;
|
|
}
|
|
_resourceText.text = res;
|
|
}
|
|
|
|
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 || haveCycle;
|
|
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);
|
|
|
|
// Cycle phase + number (top-center).
|
|
_phaseText = MakeText("PhaseText", _canvas.transform, "EXPEDITION CYCLE 1", 34, TextAnchor.UpperCenter,
|
|
new Color(0.55f, 0.9f, 1f), font);
|
|
var prt = _phaseText.rectTransform;
|
|
prt.anchorMin = new Vector2(0.5f, 1f); prt.anchorMax = new Vector2(0.5f, 1f); prt.pivot = new Vector2(0.5f, 1f);
|
|
prt.anchoredPosition = new Vector2(0, -24); prt.sizeDelta = new Vector2(600, 50);
|
|
|
|
// Resource ledger counts (top-center, below phase).
|
|
_resourceText = MakeText("ResourceText", _canvas.transform, "", 24, TextAnchor.UpperCenter,
|
|
new Color(0.7f, 0.95f, 0.8f), font);
|
|
var rrt = _resourceText.rectTransform;
|
|
rrt.anchorMin = new Vector2(0.5f, 1f); rrt.anchorMax = new Vector2(0.5f, 1f); rrt.pivot = new Vector2(0.5f, 1f);
|
|
rrt.anchoredPosition = new Vector2(0, -64); rrt.sizeDelta = new Vector2(600, 40);
|
|
|
|
// Location + gate hint (top-center, below resources).
|
|
_locationText = MakeText("LocationText", _canvas.transform, "", 22, TextAnchor.UpperCenter,
|
|
new Color(0.6f, 0.85f, 1f), font);
|
|
var lrt = _locationText.rectTransform;
|
|
lrt.anchorMin = new Vector2(0.5f, 1f); lrt.anchorMax = new Vector2(0.5f, 1f); lrt.pivot = new Vector2(0.5f, 1f);
|
|
lrt.anchoredPosition = new Vector2(0, -96); lrt.sizeDelta = new Vector2(760, 36);
|
|
|
|
// 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;
|
|
}
|
|
|
|
static Color PhaseColor(byte phase)
|
|
{
|
|
switch (phase)
|
|
{
|
|
case CyclePhase.Expedition: return new Color(0.45f, 0.85f, 1f);
|
|
case CyclePhase.Defend: return new Color(1f, 0.5f, 0.3f);
|
|
case CyclePhase.Build: return new Color(0.45f, 0.95f, 0.6f);
|
|
default: return Color.white;
|
|
}
|
|
}
|
|
|
|
static string PhaseLabel(byte phase)
|
|
{
|
|
switch (phase)
|
|
{
|
|
case CyclePhase.Expedition: return "EXPEDITION";
|
|
case CyclePhase.Defend: return "DEFEND";
|
|
case CyclePhase.Build: return "BUILD";
|
|
default: return "";
|
|
}
|
|
}
|
|
}
|
|
}
|