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:
@@ -21,6 +21,11 @@ namespace ProjectM.Authoring
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
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<KnockbackState>(entity); // server-only recoil state (zero = not knocked)
|
||||
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>
|
||||
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;
|
||||
|
||||
/// <summary>Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Select a type (or 0 to leave build mode), resetting the pending conveyor facing.</summary>
|
||||
public static void Select(byte type) { Selected = type; Direction = 0; }
|
||||
/// <summary>Toggle the palette panel open/closed; closing also cancels any active slot selection.</summary>
|
||||
public static void TogglePalette()
|
||||
{
|
||||
PaletteOpen = !PaletteOpen;
|
||||
if (!PaletteOpen) { Selected = 0; Direction = 0; }
|
||||
}
|
||||
|
||||
/// <summary>Leave build mode.</summary>
|
||||
public static void Clear() { Selected = 0; Direction = 0; }
|
||||
/// <summary>Select a type (or 0 to deselect), resetting the pending conveyor facing; auto-opens the palette
|
||||
/// 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)]
|
||||
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);
|
||||
|
||||
// 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 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.leftBracketKey.wasPressedThisFrame)
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
float intensity = math.saturate(1f - remaining / 22f); // ~0 at windup start, ~1 as the strike lands
|
||||
// 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;
|
||||
|
||||
@@ -74,6 +74,10 @@ namespace ProjectM.Client
|
||||
/// <summary>Tether line width (world units).</summary>
|
||||
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 ----
|
||||
/// <summary>Camera shake on the local player's dash start.</summary>
|
||||
public static float DashShake;
|
||||
@@ -124,6 +128,9 @@ namespace ProjectM.Client
|
||||
LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f);
|
||||
LockOnLineWidth = 0.05f;
|
||||
|
||||
// Feature B health bars (Slice 1)
|
||||
HealthBarMaxDistSq = 400f; // 20 m radius
|
||||
|
||||
// Feature 5 dash (MC-1)
|
||||
DashShake = 0.18f;
|
||||
DashFovKick = 1.2f;
|
||||
|
||||
@@ -75,7 +75,7 @@ namespace ProjectM.Client
|
||||
Label _aetherNum, _oreNum, _bioNum, _chargeNum;
|
||||
|
||||
// build palette + hints
|
||||
VisualElement _paletteRow, _hintBar, _facingArrow;
|
||||
VisualElement _paletteRow, _hintBar, _facingArrow, _buildDiscoveryChip;
|
||||
bool _paletteBuilt, _hintBuilt, _hintConveyor;
|
||||
byte _hintScheme = 255;
|
||||
readonly Dictionary<byte, PaletteItem> _palette = new();
|
||||
@@ -295,7 +295,8 @@ namespace ProjectM.Client
|
||||
|
||||
// ---- Build palette + control hints (bottom-center) ----
|
||||
UpdatePalette(aether, ore, bio, onExpedition);
|
||||
bool buildActive = BuildPaletteState.Active && !onExpedition && _paletteBuilt;
|
||||
bool paletteOpen = BuildPaletteState.PaletteOpen && !onExpedition && _paletteBuilt;
|
||||
bool buildActive = paletteOpen && BuildPaletteState.Active;
|
||||
if (buildActive)
|
||||
{
|
||||
byte scheme = AimPresentation.Scheme;
|
||||
@@ -309,6 +310,9 @@ namespace ProjectM.Client
|
||||
{
|
||||
_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 ----
|
||||
bool found = false;
|
||||
@@ -453,7 +457,8 @@ namespace ProjectM.Client
|
||||
}
|
||||
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)
|
||||
{
|
||||
var item = kv.Value;
|
||||
@@ -591,6 +596,7 @@ namespace ProjectM.Client
|
||||
BuildResources(root);
|
||||
BuildPaletteRow(root);
|
||||
BuildHintBar(root);
|
||||
BuildDiscoveryChip(root);
|
||||
BuildDowned(root);
|
||||
BuildInventory(root);
|
||||
BuildRunBanner(root);
|
||||
@@ -844,6 +850,27 @@ namespace ProjectM.Client
|
||||
_hintBar.style.display = DisplayStyle.None;
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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>())
|
||||
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>
|
||||
[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.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
@@ -30,4 +31,19 @@ namespace ProjectM.Simulation
|
||||
/// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary>
|
||||
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<IsLunging>() to write the bit while disabled (the Dead idiom).
|
||||
/// </summary>
|
||||
[GhostEnabledBit]
|
||||
public struct IsLunging : IComponentData, IEnableableComponent { }
|
||||
}
|
||||
|
||||
@@ -48,8 +48,9 @@ namespace ProjectM.Simulation
|
||||
|
||||
// ---- 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>
|
||||
public const int AttackWindupTicks = 18;
|
||||
/// <summary>Wind-up ticks before a Husk strike lands (~0.37s @ 60 ticks/sec) — sized for a fair tell
|
||||
/// 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) ----
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ namespace ProjectM.Tests
|
||||
em.AddComponentData(e, new KnockbackState());
|
||||
em.AddComponentData(e, new AttackWindup());
|
||||
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);
|
||||
return e;
|
||||
}
|
||||
@@ -124,5 +126,65 @@ namespace ProjectM.Tests
|
||||
"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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user