Slice 1: combat readability + HUD declutter (DR-038)
Four playtest do-now wins: - Enemy health bars: pooled world-space Canvas, on-damage-sticky + fade, always-on <25% HP (CombatFeedbackSystem; no new replication). - Telegraph fix: new baked client-safe EnemyTelegraph sizes the danger-cone ramp per enemy (0->1 ending at impact, fixes the Charger plateau); windup 18->22; a windup scale-pulse. - Build-mode toggle: BuildPaletteState.PaletteOpen hides the palette by default, Tab / gamepad-Y toggles, with a discovery chip (HudSystem/BuildSendSystem). - Charger committed-lunge tell: [GhostEnabledBit] IsLunging derived once/tick from LungeState (the Dead idiom); the danger cone persists through the lunge. 345/345 EditMode (+3 IsLunging derive tests); Play-validated: ghost-hash change did not break the handshake, bake correct (telegraph on all enemies, IsLunging baked-disabled on the Charger, replicated to client), no runtime errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ namespace ProjectM.Client
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class CombatFeedbackSystem : SystemBase
|
||||
{
|
||||
struct FxCache { public float Hp; public float3 Pos; public bool IsEnemy; public uint Windup; }
|
||||
struct FxCache { public float Hp; public float MaxHp; public float3 Pos; public bool IsEnemy; public uint Windup; }
|
||||
|
||||
readonly Dictionary<Entity, FxCache> _cache = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
@@ -64,6 +64,20 @@ namespace ProjectM.Client
|
||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||
readonly HashSet<Entity> _dangerSeen = new();
|
||||
readonly List<Entity> _dangerStale = new();
|
||||
// ---- Enemy health bars (Slice 1, Feature B) — one pooled world-space Canvas per live Husk ----
|
||||
struct HealthBarEntry { public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; public float ShowTimer; public bool Visible; }
|
||||
const int HealthBarPoolLimit = 24;
|
||||
const float HealthBarShowDuration = 3f;
|
||||
const float HealthBarFadeDuration = 0.5f;
|
||||
const float HealthBarAlwaysOnThreshold = 0.25f;
|
||||
const float HealthBarWorldYOffset = 2.3f;
|
||||
readonly Dictionary<Entity, HealthBarEntry> _healthBars = new();
|
||||
readonly List<Entity> _barStale = new();
|
||||
readonly List<Entity> _barKeys = new();
|
||||
Material _barBgMat, _barFillMat;
|
||||
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
|
||||
readonly Dictionary<Entity, float> _pulseStart = new();
|
||||
|
||||
AudioClip _hitClip;
|
||||
AudioClip _deathClip;
|
||||
AudioClip _fireClip;
|
||||
@@ -109,6 +123,10 @@ namespace ProjectM.Client
|
||||
_dangerMat = MakeParticleMaterial();
|
||||
_dangerMat.name = "EnemyDanger";
|
||||
_dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha)
|
||||
// Health-bar materials (UI/Default = always-included URP-compatible UI shader; per-instance Image.color carries alpha).
|
||||
Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default");
|
||||
_barBgMat = new Material(uiShader) { name = "HealthBarBg" };
|
||||
_barFillMat = new Material(uiShader) { name = "HealthBarFill" };
|
||||
|
||||
for (int i = 0; i < NumberPoolSize; i++)
|
||||
_numbers.Add(CreateNumber());
|
||||
@@ -121,8 +139,12 @@ namespace ProjectM.Client
|
||||
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
||||
if (_slashMat != null) Object.Destroy(_slashMat);
|
||||
if (_dangerMat != null) Object.Destroy(_dangerMat);
|
||||
if (_barBgMat != null) Object.Destroy(_barBgMat);
|
||||
if (_barFillMat != null) Object.Destroy(_barFillMat);
|
||||
foreach (var kv in _dangerZones)
|
||||
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
||||
foreach (var kv in _healthBars)
|
||||
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
@@ -139,6 +161,8 @@ namespace ProjectM.Client
|
||||
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
||||
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
|
||||
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
|
||||
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
|
||||
EntityManager.CompleteDependencyBeforeRO<IsLunging>();
|
||||
|
||||
// Resolve the local player (for hit colouring + fire feedback).
|
||||
_localPlayer = Entity.Null;
|
||||
@@ -181,6 +205,7 @@ namespace ProjectM.Client
|
||||
// Attack telegraph: the wind-up just began -> warn the player ~0.3s before the strike lands.
|
||||
Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6);
|
||||
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
|
||||
_pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime; // Feature C: scale-pulse onset
|
||||
}
|
||||
|
||||
// Local hit feedback is SUPPRESSED while the local i-frame window is active: the server
|
||||
@@ -192,6 +217,7 @@ namespace ProjectM.Client
|
||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||
if (isEnemy) ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
|
||||
}
|
||||
|
||||
// Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing
|
||||
@@ -213,7 +239,7 @@ namespace ProjectM.Client
|
||||
}
|
||||
}
|
||||
|
||||
_cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy, Windup = windup };
|
||||
_cache[entity] = new FxCache { Hp = cur, MaxHp = health.ValueRO.Max, Pos = p, IsEnemy = isEnemy, Windup = windup };
|
||||
}
|
||||
|
||||
// Prune despawned ghosts. A Husk that vanished was killed -> death VFX at its last position.
|
||||
@@ -307,6 +333,7 @@ namespace ProjectM.Client
|
||||
AnimateNumbers(dt, cam);
|
||||
UpdateSlash(dt);
|
||||
UpdateEnemyDanger();
|
||||
UpdateHealthBars(dt, cam, localPos);
|
||||
}
|
||||
|
||||
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
|
||||
@@ -663,16 +690,40 @@ namespace ProjectM.Client
|
||||
_dangerSeen.Clear();
|
||||
if (serverTick.IsValid)
|
||||
{
|
||||
foreach (var (xf, stats, windup, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>>()
|
||||
foreach (var (xf, stats, windup, tele, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>, RefRO<EnemyTelegraph>>()
|
||||
.WithAll<EnemyTag>().WithEntityAccess())
|
||||
{
|
||||
// Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit).
|
||||
bool lunging = SystemAPI.HasComponent<IsLunging>(entity) && SystemAPI.IsComponentEnabled<IsLunging>(entity);
|
||||
uint until = windup.ValueRO.WindUpUntilTick;
|
||||
if (until == 0u) continue;
|
||||
var untilTick = new Unity.NetCode.NetworkTick(until);
|
||||
if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed
|
||||
int remaining = untilTick.TicksSince(serverTick);
|
||||
float intensity = math.saturate(1f - remaining / 22f); // ~0 at windup start, ~1 as the strike lands
|
||||
if (until == 0u && !lunging) continue;
|
||||
|
||||
float intensity;
|
||||
if (lunging)
|
||||
{
|
||||
intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears
|
||||
}
|
||||
else
|
||||
{
|
||||
var untilTick = new Unity.NetCode.NetworkTick(until);
|
||||
if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed
|
||||
int remaining = untilTick.TicksSince(serverTick);
|
||||
// Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for
|
||||
// any windup length (fixes the Charger plateauing early under the old hard-coded 22).
|
||||
float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
|
||||
intensity = math.saturate(1f - remaining / windupDur);
|
||||
}
|
||||
|
||||
// Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost).
|
||||
float pulse = 0f;
|
||||
if (_pulseStart.TryGetValue(entity, out var t0))
|
||||
{
|
||||
float age = (float)SystemAPI.Time.ElapsedTime - t0;
|
||||
const float PulseLife = 0.18f;
|
||||
if (age < PulseLife) pulse = (1f - age / PulseLife) * 0.35f;
|
||||
else _pulseStart.Remove(entity);
|
||||
}
|
||||
|
||||
_dangerSeen.Add(entity);
|
||||
if (!_dangerZones.TryGetValue(entity, out var go) || go == null)
|
||||
@@ -686,12 +737,14 @@ namespace ProjectM.Client
|
||||
mr.receiveShadows = false;
|
||||
_dangerZones[entity] = go;
|
||||
}
|
||||
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, math.max(1f, stats.ValueRO.AttackRange + 0.6f), 0.7f, intensity);
|
||||
float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f);
|
||||
if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel
|
||||
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
|
||||
float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation);
|
||||
var tr = go.transform;
|
||||
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f;
|
||||
tr.rotation = Quaternion.LookRotation(new Vector3(fwd.x, 0f, fwd.y), Vector3.up);
|
||||
tr.localScale = Vector3.one * (0.92f + 0.12f * intensity);
|
||||
tr.localScale = Vector3.one * (0.92f + 0.12f * intensity + pulse);
|
||||
}
|
||||
}
|
||||
if (_dangerZones.Count != _dangerSeen.Count)
|
||||
@@ -703,11 +756,120 @@ namespace ProjectM.Client
|
||||
var g = _dangerZones[_dangerStale[i]];
|
||||
if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
|
||||
_dangerZones.Remove(_dangerStale[i]);
|
||||
_pulseStart.Remove(_dangerStale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
|
||||
// ---- Enemy Health Bars (Slice 1, Feature B) — pooled world-space Canvas, on-damage sticky + fade ----
|
||||
|
||||
void ShowHealthBar(Entity entity)
|
||||
{
|
||||
if (!_healthBars.TryGetValue(entity, out var entry) || entry.CanvasGo == null)
|
||||
entry = CreateHealthBar(entity);
|
||||
entry.ShowTimer = HealthBarShowDuration;
|
||||
if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; }
|
||||
_healthBars[entity] = entry; // struct — must re-assign
|
||||
}
|
||||
|
||||
HealthBarEntry CreateHealthBar(Entity entity)
|
||||
{
|
||||
var go = new GameObject("EnemyHPBar");
|
||||
if (_fxRoot != null) go.transform.SetParent(_fxRoot, false);
|
||||
var canvas = go.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.WorldSpace;
|
||||
canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
rt.sizeDelta = new Vector2(1.2f, 0.14f);
|
||||
|
||||
var bgGo = new GameObject("Bg");
|
||||
bgGo.transform.SetParent(go.transform, false);
|
||||
var bgRt = bgGo.AddComponent<RectTransform>();
|
||||
bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one;
|
||||
bgRt.offsetMin = bgRt.offsetMax = Vector2.zero;
|
||||
var bgImg = bgGo.AddComponent<UnityEngine.UI.Image>();
|
||||
bgImg.material = _barBgMat;
|
||||
bgImg.color = new Color(0.05f, 0.05f, 0.06f, 0.82f);
|
||||
|
||||
var fillGo = new GameObject("Fill");
|
||||
fillGo.transform.SetParent(go.transform, false);
|
||||
var fillRt = fillGo.AddComponent<RectTransform>();
|
||||
fillRt.anchorMin = Vector2.zero; fillRt.anchorMax = Vector2.one;
|
||||
fillRt.offsetMin = new Vector2(0.02f, 0.02f);
|
||||
fillRt.offsetMax = new Vector2(-0.02f, -0.02f);
|
||||
var fillImg = fillGo.AddComponent<UnityEngine.UI.Image>();
|
||||
fillImg.material = _barFillMat;
|
||||
fillImg.color = new Color(0.88f, 0.22f, 0.14f, 1f);
|
||||
fillImg.type = UnityEngine.UI.Image.Type.Filled;
|
||||
fillImg.fillMethod = UnityEngine.UI.Image.FillMethod.Horizontal;
|
||||
fillImg.fillOrigin = 0; // left
|
||||
fillImg.fillAmount = 1f;
|
||||
|
||||
go.SetActive(false);
|
||||
var entry = new HealthBarEntry { CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false };
|
||||
_healthBars[entity] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Per-frame: prune dead bars (reusing the main loop's _seen set), pool-cap by distance, billboard + fade.
|
||||
void UpdateHealthBars(float dt, Camera cam, float3 localPlayerPos)
|
||||
{
|
||||
if (_healthBars.Count > 0)
|
||||
{
|
||||
_barStale.Clear();
|
||||
foreach (var kv in _healthBars)
|
||||
if (!_seen.Contains(kv.Key)) _barStale.Add(kv.Key);
|
||||
for (int i = 0; i < _barStale.Count; i++)
|
||||
{
|
||||
var e2 = _barStale[i];
|
||||
if (_healthBars[e2].CanvasGo != null) Object.Destroy(_healthBars[e2].CanvasGo);
|
||||
_healthBars.Remove(e2);
|
||||
}
|
||||
}
|
||||
if (_healthBars.Count == 0) return;
|
||||
|
||||
bool capBars = _localPlayer != Entity.Null && _healthBars.Count > HealthBarPoolLimit;
|
||||
_barKeys.Clear();
|
||||
foreach (var k in _healthBars.Keys) _barKeys.Add(k);
|
||||
for (int i = 0; i < _barKeys.Count; i++)
|
||||
{
|
||||
var key = _barKeys[i];
|
||||
var entry = _healthBars[key];
|
||||
if (entry.CanvasGo == null) continue;
|
||||
if (!_cache.TryGetValue(key, out var fc)) continue;
|
||||
|
||||
float frac = fc.MaxHp > 0f ? math.saturate(fc.Hp / fc.MaxHp) : 1f;
|
||||
bool alwaysOn = frac < HealthBarAlwaysOnThreshold;
|
||||
|
||||
if (capBars && math.lengthsq(fc.Pos - localPlayerPos) > FeelConfig.HealthBarMaxDistSq)
|
||||
{
|
||||
if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
|
||||
_healthBars[key] = entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!alwaysOn) entry.ShowTimer -= dt;
|
||||
bool shouldShow = alwaysOn || entry.ShowTimer > -HealthBarFadeDuration;
|
||||
if (shouldShow)
|
||||
{
|
||||
if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; }
|
||||
if (cam != null)
|
||||
{
|
||||
entry.CanvasGo.transform.position = (Vector3)fc.Pos + Vector3.up * HealthBarWorldYOffset;
|
||||
entry.CanvasGo.transform.rotation = cam.transform.rotation; // billboard
|
||||
}
|
||||
float alpha = (!alwaysOn && entry.ShowTimer < 0f)
|
||||
? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f;
|
||||
if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.fillAmount = frac; }
|
||||
if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; }
|
||||
}
|
||||
else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
|
||||
|
||||
_healthBars[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
|
||||
{
|
||||
const int seg = 14;
|
||||
|
||||
Reference in New Issue
Block a user