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:
2026-06-17 12:48:08 -07:00
parent 5292940f9d
commit f3eccec524
12 changed files with 360 additions and 24 deletions
@@ -21,6 +21,11 @@ namespace ProjectM.Authoring
{ {
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<LungeState>(entity); AddComponent<LungeState>(entity);
// Slice 1 (Feature D): the replicated mid-lunge cue, baked DISABLED (a Charger spawns not-lunging).
// EnemyAISystem derives the bit each tick from LungeState.UntilTick (visiting disabled entities via
// .WithPresent<IsLunging>()). Adding this [GhostEnabledBit] changes the Charger ghost hash -> RE-BAKE.
AddComponent<IsLunging>(entity);
SetComponentEnabled<IsLunging>(entity, false);
} }
} }
} }
@@ -52,6 +52,16 @@ namespace ProjectM.Authoring
AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 }); AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 });
AddComponent<KnockbackState>(entity); // server-only recoil state (zero = not knocked) AddComponent<KnockbackState>(entity); // server-only recoil state (zero = not knocked)
AddComponent<AttackWindup>(entity); // replicated telegraph signal (zero = not winding up) AddComponent<AttackWindup>(entity); // replicated telegraph signal (zero = not winding up)
// Slice 1 (Feature C): client-safe baked telegraph metadata. EnemyBaker is the SOLE writer of
// EnemyTelegraph even on a Charger (the prefab composes both authorings on one entity); reading the
// sibling ChargerAuthoring here avoids a double-AddComponent. WindupTicks = the client danger-ramp
// denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only).
bool isCharger = GetComponent<ChargerAuthoring>() != null;
AddComponent(entity, new EnemyTelegraph
{
WindupTicks = (byte)(isCharger ? 30 : Tuning.AttackWindupTicks),
IsCharger = (byte)(isCharger ? 1 : 0),
});
} }
} }
} }
@@ -8,22 +8,34 @@ namespace ProjectM.Client
/// </summary> /// </summary>
public static class BuildPaletteState public static class BuildPaletteState
{ {
/// <summary>Selected structure type (StructureType.*); 0 = none / build mode off.</summary> /// <summary>Selected structure type (StructureType.*); 0 = none / no slot selected.</summary>
public static byte Selected; public static byte Selected;
/// <summary>Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R.</summary> /// <summary>Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R.</summary>
public static byte Direction; public static byte Direction;
/// <summary>True while a buildable is selected (build mode active).</summary> /// <summary>True while the build PALETTE panel is open (toggled by Tab / gamepad Y). Slice 1 HUD declutter:
/// the palette is hidden by default; this gates its visibility, orthogonal to <see cref="Active"/>.</summary>
public static bool PaletteOpen;
/// <summary>True while a buildable SLOT is selected (placement is armed). The palette must also be open.</summary>
public static bool Active => Selected != 0; public static bool Active => Selected != 0;
/// <summary>Select a type (or 0 to leave build mode), resetting the pending conveyor facing.</summary> /// <summary>Toggle the palette panel open/closed; closing also cancels any active slot selection.</summary>
public static void Select(byte type) { Selected = type; Direction = 0; } public static void TogglePalette()
{
PaletteOpen = !PaletteOpen;
if (!PaletteOpen) { Selected = 0; Direction = 0; }
}
/// <summary>Leave build mode.</summary> /// <summary>Select a type (or 0 to deselect), resetting the pending conveyor facing; auto-opens the palette
public static void Clear() { Selected = 0; Direction = 0; } /// so a slot click never leaves the panel hidden.</summary>
public static void Select(byte type) { Selected = type; Direction = 0; if (type != 0) PaletteOpen = true; }
/// <summary>Cancel the current selection and close the palette.</summary>
public static void Clear() { Selected = 0; Direction = 0; PaletteOpen = false; }
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() { Selected = 0; Direction = 0; } static void ResetStatics() { Selected = 0; Direction = 0; PaletteOpen = false; }
} }
} }
@@ -91,8 +91,16 @@ namespace ProjectM.Client
HandleBuildMode(connection); HandleBuildMode(connection);
// Hotkey fallback (suppressed while the palette build mode is active). // --- Build-palette toggle (Tab / gamepad Y): Slice 1 HUD declutter — the palette is hidden by default ---
var keyboard = UnityEngine.InputSystem.Keyboard.current; var keyboard = UnityEngine.InputSystem.Keyboard.current;
var gamepad = UnityEngine.InputSystem.Gamepad.current;
bool togglePressed =
(keyboard != null && keyboard.tabKey.wasPressedThisFrame) ||
(gamepad != null && gamepad.buttonNorth.wasPressedThisFrame);
if (togglePressed && !PauseMenuController.Open)
BuildPaletteState.TogglePalette();
// Hotkey fallback (suppressed while the palette build mode is active).
if (keyboard != null && !BuildPaletteState.Active) if (keyboard != null && !BuildPaletteState.Active)
{ {
if (keyboard.leftBracketKey.wasPressedThisFrame) if (keyboard.leftBracketKey.wasPressedThisFrame)
@@ -35,7 +35,7 @@ namespace ProjectM.Client
[UpdateInGroup(typeof(PresentationSystemGroup))] [UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class CombatFeedbackSystem : SystemBase 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 Dictionary<Entity, FxCache> _cache = new();
readonly HashSet<Entity> _seen = new(); readonly HashSet<Entity> _seen = new();
@@ -64,6 +64,20 @@ namespace ProjectM.Client
readonly Dictionary<Entity, GameObject> _dangerZones = new(); readonly Dictionary<Entity, GameObject> _dangerZones = new();
readonly HashSet<Entity> _dangerSeen = new(); readonly HashSet<Entity> _dangerSeen = new();
readonly List<Entity> _dangerStale = 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 _hitClip;
AudioClip _deathClip; AudioClip _deathClip;
AudioClip _fireClip; AudioClip _fireClip;
@@ -109,6 +123,10 @@ namespace ProjectM.Client
_dangerMat = MakeParticleMaterial(); _dangerMat = MakeParticleMaterial();
_dangerMat.name = "EnemyDanger"; _dangerMat.name = "EnemyDanger";
_dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha) _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++) for (int i = 0; i < NumberPoolSize; i++)
_numbers.Add(CreateNumber()); _numbers.Add(CreateNumber());
@@ -121,8 +139,12 @@ namespace ProjectM.Client
if (_slashMesh != null) Object.Destroy(_slashMesh); if (_slashMesh != null) Object.Destroy(_slashMesh);
if (_slashMat != null) Object.Destroy(_slashMat); if (_slashMat != null) Object.Destroy(_slashMat);
if (_dangerMat != null) Object.Destroy(_dangerMat); if (_dangerMat != null) Object.Destroy(_dangerMat);
if (_barBgMat != null) Object.Destroy(_barBgMat);
if (_barFillMat != null) Object.Destroy(_barFillMat);
foreach (var kv in _dangerZones) 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); } 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() protected override void OnUpdate()
@@ -139,6 +161,8 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<DashCooldown>(); EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>(); EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
EntityManager.CompleteDependencyBeforeRO<EnemyStats>(); EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
EntityManager.CompleteDependencyBeforeRO<IsLunging>();
// Resolve the local player (for hit colouring + fire feedback). // Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null; _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. // 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); Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6);
PlayClip(_telegraphClip, (Vector3)p, 0.5f); 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 // 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); PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote); PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); 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 // 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. // 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); AnimateNumbers(dt, cam);
UpdateSlash(dt); UpdateSlash(dt);
UpdateEnemyDanger(); UpdateEnemyDanger();
UpdateHealthBars(dt, cam, localPos);
} }
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ---- // ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
@@ -663,16 +690,40 @@ namespace ProjectM.Client
_dangerSeen.Clear(); _dangerSeen.Clear();
if (serverTick.IsValid) if (serverTick.IsValid)
{ {
foreach (var (xf, stats, windup, entity) in foreach (var (xf, stats, windup, tele, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>>() SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>, RefRO<EnemyTelegraph>>()
.WithAll<EnemyTag>().WithEntityAccess()) .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; uint until = windup.ValueRO.WindUpUntilTick;
if (until == 0u) continue; if (until == 0u && !lunging) continue;
var untilTick = new Unity.NetCode.NetworkTick(until);
if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed float intensity;
int remaining = untilTick.TicksSince(serverTick); if (lunging)
float intensity = math.saturate(1f - remaining / 22f); // ~0 at windup start, ~1 as the strike lands {
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); _dangerSeen.Add(entity);
if (!_dangerZones.TryGetValue(entity, out var go) || go == null) if (!_dangerZones.TryGetValue(entity, out var go) || go == null)
@@ -686,12 +737,14 @@ namespace ProjectM.Client
mr.receiveShadows = false; mr.receiveShadows = false;
_dangerZones[entity] = go; _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); float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation);
var tr = go.transform; var tr = go.transform;
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f; tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f;
tr.rotation = Quaternion.LookRotation(new Vector3(fwd.x, 0f, fwd.y), Vector3.up); 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) if (_dangerZones.Count != _dangerSeen.Count)
@@ -703,11 +756,120 @@ namespace ProjectM.Client
var g = _dangerZones[_dangerStale[i]]; 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); } 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]); _dangerZones.Remove(_dangerStale[i]);
_pulseStart.Remove(_dangerStale[i]);
} }
} }
} }
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`. // 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) static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
{ {
const int seg = 14; const int seg = 14;
@@ -74,6 +74,10 @@ namespace ProjectM.Client
/// <summary>Tether line width (world units).</summary> /// <summary>Tether line width (world units).</summary>
public static float LockOnLineWidth; public static float LockOnLineWidth;
// ---- Feature B: enemy health bars (Slice 1) ----
/// <summary>Squared world-distance beyond which a bar is hidden when the pool cap is reached (default 400 = 20m).</summary>
public static float HealthBarMaxDistSq;
// ---- Feature 5 (MC-1): dash juice ---- // ---- Feature 5 (MC-1): dash juice ----
/// <summary>Camera shake on the local player's dash start.</summary> /// <summary>Camera shake on the local player's dash start.</summary>
public static float DashShake; public static float DashShake;
@@ -124,6 +128,9 @@ namespace ProjectM.Client
LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f); LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f);
LockOnLineWidth = 0.05f; LockOnLineWidth = 0.05f;
// Feature B health bars (Slice 1)
HealthBarMaxDistSq = 400f; // 20 m radius
// Feature 5 dash (MC-1) // Feature 5 dash (MC-1)
DashShake = 0.18f; DashShake = 0.18f;
DashFovKick = 1.2f; DashFovKick = 1.2f;
@@ -75,7 +75,7 @@ namespace ProjectM.Client
Label _aetherNum, _oreNum, _bioNum, _chargeNum; Label _aetherNum, _oreNum, _bioNum, _chargeNum;
// build palette + hints // build palette + hints
VisualElement _paletteRow, _hintBar, _facingArrow; VisualElement _paletteRow, _hintBar, _facingArrow, _buildDiscoveryChip;
bool _paletteBuilt, _hintBuilt, _hintConveyor; bool _paletteBuilt, _hintBuilt, _hintConveyor;
byte _hintScheme = 255; byte _hintScheme = 255;
readonly Dictionary<byte, PaletteItem> _palette = new(); readonly Dictionary<byte, PaletteItem> _palette = new();
@@ -295,7 +295,8 @@ namespace ProjectM.Client
// ---- Build palette + control hints (bottom-center) ---- // ---- Build palette + control hints (bottom-center) ----
UpdatePalette(aether, ore, bio, onExpedition); UpdatePalette(aether, ore, bio, onExpedition);
bool buildActive = BuildPaletteState.Active && !onExpedition && _paletteBuilt; bool paletteOpen = BuildPaletteState.PaletteOpen && !onExpedition && _paletteBuilt;
bool buildActive = paletteOpen && BuildPaletteState.Active;
if (buildActive) if (buildActive)
{ {
byte scheme = AimPresentation.Scheme; byte scheme = AimPresentation.Scheme;
@@ -309,6 +310,9 @@ namespace ProjectM.Client
{ {
_hintBar.style.display = DisplayStyle.None; _hintBar.style.display = DisplayStyle.None;
} }
// Build-mode discovery chip: a subtle "Tab/Y — BUILD" hint when the palette is hidden at base (Slice 1).
_buildDiscoveryChip.style.display = (!onExpedition && !BuildPaletteState.PaletteOpen)
? DisplayStyle.Flex : DisplayStyle.None;
// ---- Per-player vitals ---- // ---- Per-player vitals ----
bool found = false; bool found = false;
@@ -453,7 +457,8 @@ namespace ProjectM.Client
} }
if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; } if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; }
_paletteRow.style.display = onExpedition ? DisplayStyle.None : DisplayStyle.Flex; bool showPalette = !onExpedition && BuildPaletteState.PaletteOpen;
_paletteRow.style.display = showPalette ? DisplayStyle.Flex : DisplayStyle.None;
foreach (var kv in _palette) foreach (var kv in _palette)
{ {
var item = kv.Value; var item = kv.Value;
@@ -591,6 +596,7 @@ namespace ProjectM.Client
BuildResources(root); BuildResources(root);
BuildPaletteRow(root); BuildPaletteRow(root);
BuildHintBar(root); BuildHintBar(root);
BuildDiscoveryChip(root);
BuildDowned(root); BuildDowned(root);
BuildInventory(root); BuildInventory(root);
BuildRunBanner(root); BuildRunBanner(root);
@@ -844,6 +850,27 @@ namespace ProjectM.Client
_hintBar.style.display = DisplayStyle.None; _hintBar.style.display = DisplayStyle.None;
root.Add(_hintBar); root.Add(_hintBar);
} }
void BuildDiscoveryChip(VisualElement root)
{
// Slice 1 HUD declutter: a subtle bottom-center chip teaching the build-mode toggle, shown only while
// the palette is CLOSED at base. The glyph uses the text fallback ("Tab"/"Y") — no HudTheme sprite needed.
bool pad = AimPresentation.Scheme == InputSchemeId.Gamepad;
_buildDiscoveryChip = new VisualElement();
_buildDiscoveryChip.style.position = Position.Absolute;
_buildDiscoveryChip.style.bottom = 28; _buildDiscoveryChip.style.left = 0; _buildDiscoveryChip.style.right = 0;
_buildDiscoveryChip.style.flexDirection = FlexDirection.Row;
_buildDiscoveryChip.style.justifyContent = Justify.Center;
_buildDiscoveryChip.style.alignItems = Align.Center;
_buildDiscoveryChip.pickingMode = PickingMode.Ignore;
_buildDiscoveryChip.style.opacity = 0.6f;
_buildDiscoveryChip.Add(HudUi.Glyph(null, pad ? "Y" : "Tab", 26));
var lbl = HudUi.Text("BUILD", 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5;
_buildDiscoveryChip.Add(lbl);
_buildDiscoveryChip.style.display = DisplayStyle.None;
root.Add(_buildDiscoveryChip);
}
void BuildDowned(VisualElement root) void BuildDowned(VisualElement root)
{ {
@@ -322,6 +322,16 @@ namespace ProjectM.Server
} }
} }
// Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState
// (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a
// Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables).
foreach (var (lunge, isLunging) in
SystemAPI.Query<RefRO<LungeState>, EnabledRefRW<IsLunging>>()
.WithAll<EnemyTag>().WithPresent<IsLunging>())
{
isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick
}
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>()) if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick; SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
@@ -16,4 +16,20 @@ namespace ProjectM.Simulation
/// <summary>Server tick the wind-up completes + the strike lands (0 = not winding up; scheduled via TickUtil.NonZero).</summary> /// <summary>Server tick the wind-up completes + the strike lands (0 = not winding up; scheduled via TickUtil.NonZero).</summary>
[GhostField] public uint WindUpUntilTick; [GhostField] public uint WindUpUntilTick;
} }
/// <summary>Client-safe BAKED telegraph metadata (NOT a [GhostField] — baked values are identical on
/// both worlds, so the client reads it directly to size the danger-cone ramp + pick the Charger look).
/// WindupTicks = the variant's wind-up lead in ticks (the client danger-ramp denominator: Grunt =
/// Tuning.AttackWindupTicks, Charger = ChargerWindupTicks); IsCharger distinguishes the variant since
/// LungeState is server-only. Both stored as byte (no enum on a client-read path — honours the Burst
/// byte-not-enum convention); never changes at runtime, so a missed snapshot is irrelevant. Baked by
/// EnemyAuthoring.EnemyBaker (the SOLE writer — reads sibling ChargerAuthoring to set the Charger values).</summary>
public struct EnemyTelegraph : IComponentData
{
/// <summary>Per-variant wind-up DURATION in ticks (the client danger-ramp denominator).</summary>
public byte WindupTicks;
/// <summary>0 = Grunt-style; 1 = Charger (committed-lunge tell).</summary>
public byte IsCharger;
}
} }
@@ -1,5 +1,6 @@
using Unity.Entities; using Unity.Entities;
using Unity.Mathematics; using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation namespace ProjectM.Simulation
{ {
@@ -30,4 +31,19 @@ namespace ProjectM.Simulation
/// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary> /// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary>
public uint StaggerUntilTick; public uint StaggerUntilTick;
} }
/// <summary>
/// REPLICATED enableable MID-LUNGE flag on a Charger (Slice 1, Feature D). ENABLED for exactly the ticks a
/// Charger is committed to its locked-direction lunge (<see cref="LungeState.UntilTick"/> active), DISABLED
/// otherwise. The ONLY replicated Charger surface beyond the stock LocalTransform — a <c>[GhostEnabledBit]</c>,
/// NOT a [GhostField], because the client needs only on/off: the lunge HEADING is already carried by the
/// replicated LocalTransform.Rotation (EnemyAISystem writes LookRotationSafe(lungeDir) each lunge tick), so the
/// client indicator derives direction via AnimParamMath.PlanarForward like the danger cone already does. Fixes
/// the cue VANISHING at commit (AttackWindup zeroes on commit, so a windup-gated cone disappears exactly when
/// the danger is realest): this bit STAYS on through the committed travel. Server-derived once per tick from
/// LungeState.UntilTick in EnemyAISystem (the sole LungeState writer); BAKE DISABLED (a Charger spawns
/// not-lunging) + visit via .WithPresent&lt;IsLunging&gt;() to write the bit while disabled (the Dead idiom).
/// </summary>
[GhostEnabledBit]
public struct IsLunging : IComponentData, IEnableableComponent { }
} }
+3 -2
View File
@@ -48,8 +48,9 @@ namespace ProjectM.Simulation
// ---- Husk attack telegraph (EnemyAISystem 2-phase strike; client cue in CombatFeedbackSystem) ---- // ---- Husk attack telegraph (EnemyAISystem 2-phase strike; client cue in CombatFeedbackSystem) ----
/// <summary>Wind-up ticks before a Husk strike lands (~0.3s @ 60 ticks/sec). 0/1 = near-instant (legacy behaviour).</summary> /// <summary>Wind-up ticks before a Husk strike lands (~0.37s @ 60 ticks/sec) — sized for a fair tell
public const int AttackWindupTicks = 18; /// under interp lag (>= ~250ms reaction + interp buffer; Slice 1 readability). 0/1 = near-instant (legacy).</summary>
public const int AttackWindupTicks = 22;
// ---- Production / automation (M7: Harvester/Conveyor/Fabricator) ---- // ---- Production / automation (M7: Harvester/Conveyor/Fabricator) ----
@@ -56,6 +56,8 @@ namespace ProjectM.Tests
em.AddComponentData(e, new KnockbackState()); em.AddComponentData(e, new KnockbackState());
em.AddComponentData(e, new AttackWindup()); em.AddComponentData(e, new AttackWindup());
em.AddComponentData(e, new LungeState()); em.AddComponentData(e, new LungeState());
em.AddComponent<IsLunging>(e);
em.SetComponentEnabled<IsLunging>(e, false); // baked DISABLED on the real Charger (spawns not-lunging)
em.AddComponent<EnemyTag>(e); em.AddComponent<EnemyTag>(e);
return e; return e;
} }
@@ -124,5 +126,65 @@ namespace ProjectM.Tests
"The recoiling Charger moved along its knockback direction (-X), not its lunge direction."); "The recoiling Charger moved along its knockback direction (-X), not its lunge direction.");
} }
} }
[Test]
public void Commit_Enables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingCommit", 200);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(3, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick -> commit
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger), "Charger spawns not-lunging (baked DISABLED).");
group.Update(); // tick 200: commit the lunge
Assert.AreNotEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: the lunge committed.");
Assert.IsTrue(em.IsComponentEnabled<IsLunging>(charger),
"The replicated mid-lunge cue is ENABLED while a committed lunge is live (.WithPresent visits the disabled entity to write the bit).");
}
}
[Test]
public void Whiff_Disables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingWhiff", 206);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(-10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring
em.SetComponentEnabled<IsLunging>(charger, true); // was mid-lunge
group.Update(); // tick 206 > 205 -> overshoot whiff clears the lunge
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: the whiffed lunge cleared.");
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger), "The cue clears the tick the lunge ends (whiff).");
}
}
[Test]
public void Knockback_Disables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingKnockback", 305);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 });
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 });
em.SetComponentEnabled<IsLunging>(charger, true); // mid-lunge before the knockback
group.Update(); // tick 305: knockback cancels the lunge (UntilTick -> 0) via the mid-body continue path
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: knockback cancelled the lunge.");
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger),
"The cue clears when knockback cancels the lunge (covers the mid-body continue exit path).");
}
}
} }
} }