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);
|
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<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) ----
|
// ---- 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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user