diff --git a/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs index 2647da75f..6c0b588a6 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/ChargerAuthoring.cs @@ -21,6 +21,11 @@ namespace ProjectM.Authoring { var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); AddComponent(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()). Adding this [GhostEnabledBit] changes the Charger ghost hash -> RE-BAKE. + AddComponent(entity); + SetComponentEnabled(entity, false); } } } diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs index a5f985baa..72a104744 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs @@ -52,6 +52,16 @@ namespace ProjectM.Authoring AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 }); AddComponent(entity); // server-only recoil state (zero = not knocked) AddComponent(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() != null; + AddComponent(entity, new EnemyTelegraph + { + WindupTicks = (byte)(isCharger ? 30 : Tuning.AttackWindupTicks), + IsCharger = (byte)(isCharger ? 1 : 0), + }); } } } diff --git a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs index ebfedf5b8..951b3617d 100644 --- a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs +++ b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs @@ -8,22 +8,34 @@ namespace ProjectM.Client /// public static class BuildPaletteState { - /// Selected structure type (StructureType.*); 0 = none / build mode off. + /// Selected structure type (StructureType.*); 0 = none / no slot selected. public static byte Selected; /// Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R. public static byte Direction; - /// True while a buildable is selected (build mode active). + /// 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 . + public static bool PaletteOpen; + + /// True while a buildable SLOT is selected (placement is armed). The palette must also be open. public static bool Active => Selected != 0; - /// Select a type (or 0 to leave build mode), resetting the pending conveyor facing. - public static void Select(byte type) { Selected = type; Direction = 0; } + /// Toggle the palette panel open/closed; closing also cancels any active slot selection. + public static void TogglePalette() + { + PaletteOpen = !PaletteOpen; + if (!PaletteOpen) { Selected = 0; Direction = 0; } + } - /// Leave build mode. - public static void Clear() { Selected = 0; Direction = 0; } + /// 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. + public static void Select(byte type) { Selected = type; Direction = 0; if (type != 0) PaletteOpen = true; } + + /// Cancel the current selection and close the palette. + 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; } } } diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs index 63757be67..1d88a73d1 100644 --- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs +++ b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs @@ -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) diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 6c547b04b..f6a8c39db 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -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 _cache = new(); readonly HashSet _seen = new(); @@ -64,6 +64,20 @@ namespace ProjectM.Client readonly Dictionary _dangerZones = new(); readonly HashSet _dangerSeen = new(); readonly List _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 _healthBars = new(); + readonly List _barStale = new(); + readonly List _barKeys = new(); + Material _barBgMat, _barFillMat; + // Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone. + readonly Dictionary _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(); 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(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); // 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, RefRO>() + foreach (var (xf, stats, windup, tele, entity) in + SystemAPI.Query, RefRO, RefRO, RefRO>() .WithAll().WithEntityAccess()) { + // Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit). + bool lunging = SystemAPI.HasComponent(entity) && SystemAPI.IsComponentEnabled(entity); uint until = windup.ValueRO.WindUpUntilTick; - if (until == 0u) continue; - var untilTick = new Unity.NetCode.NetworkTick(until); - if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed - int remaining = untilTick.TicksSince(serverTick); - float intensity = math.saturate(1f - remaining / 22f); // ~0 at windup start, ~1 as the strike lands + if (until == 0u && !lunging) continue; + + float intensity; + if (lunging) + { + intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears + } + else + { + var untilTick = new Unity.NetCode.NetworkTick(until); + if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed + int remaining = untilTick.TicksSince(serverTick); + // Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for + // any windup length (fixes the Charger plateauing early under the old hard-coded 22). + float windupDur = math.max(1f, tele.ValueRO.WindupTicks); + intensity = math.saturate(1f - remaining / windupDur); + } + + // Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost). + float pulse = 0f; + if (_pulseStart.TryGetValue(entity, out var t0)) + { + float age = (float)SystemAPI.Time.ElapsedTime - t0; + const float PulseLife = 0.18f; + if (age < PulseLife) pulse = (1f - age / PulseLife) * 0.35f; + else _pulseStart.Remove(entity); + } _dangerSeen.Add(entity); if (!_dangerZones.TryGetValue(entity, out var go) || go == null) @@ -686,12 +737,14 @@ namespace ProjectM.Client mr.receiveShadows = false; _dangerZones[entity] = go; } - BuildDangerMesh(go.GetComponent().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().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(); 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.renderMode = RenderMode.WorldSpace; + canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry + var rt = go.GetComponent(); + rt.sizeDelta = new Vector2(1.2f, 0.14f); + + var bgGo = new GameObject("Bg"); + bgGo.transform.SetParent(go.transform, false); + var bgRt = bgGo.AddComponent(); + bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one; + bgRt.offsetMin = bgRt.offsetMax = Vector2.zero; + var bgImg = bgGo.AddComponent(); + 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(); + 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(); + 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; diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs index 050064b9f..b96b8793d 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs @@ -74,6 +74,10 @@ namespace ProjectM.Client /// Tether line width (world units). public static float LockOnLineWidth; + // ---- Feature B: enemy health bars (Slice 1) ---- + /// Squared world-distance beyond which a bar is hidden when the pool cap is reached (default 400 = 20m). + public static float HealthBarMaxDistSq; + // ---- Feature 5 (MC-1): dash juice ---- /// Camera shake on the local player's dash start. 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; diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 050a525c4..82c3ec863 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -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 _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) { diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 9be6b0bed..8a72a5afc 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -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, EnabledRefRW>() + .WithAll().WithPresent()) + { + isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick + } + if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton()) SystemAPI.GetSingletonRW().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick; diff --git a/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs index 0a27ed8ae..400364f9c 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs @@ -16,4 +16,20 @@ namespace ProjectM.Simulation /// Server tick the wind-up completes + the strike lands (0 = not winding up; scheduled via TickUtil.NonZero). [GhostField] public uint WindUpUntilTick; } + + /// 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). + public struct EnemyTelegraph : IComponentData + { + /// Per-variant wind-up DURATION in ticks (the client danger-ramp denominator). + public byte WindupTicks; + + /// 0 = Grunt-style; 1 = Charger (committed-lunge tell). + public byte IsCharger; + } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs b/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs index a0fb679ef..8148fc771 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/LungeState.cs @@ -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. public uint StaggerUntilTick; } + + /// + /// 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 ( active), DISABLED + /// otherwise. The ONLY replicated Charger surface beyond the stock LocalTransform — a [GhostEnabledBit], + /// 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). + /// + [GhostEnabledBit] + public struct IsLunging : IComponentData, IEnableableComponent { } } diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 3dbb2c865..0a118b5f2 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -48,8 +48,9 @@ namespace ProjectM.Simulation // ---- Husk attack telegraph (EnemyAISystem 2-phase strike; client cue in CombatFeedbackSystem) ---- - /// Wind-up ticks before a Husk strike lands (~0.3s @ 60 ticks/sec). 0/1 = near-instant (legacy behaviour). - public const int AttackWindupTicks = 18; + /// 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). + public const int AttackWindupTicks = 22; // ---- Production / automation (M7: Harvester/Conveyor/Fabricator) ---- diff --git a/Assets/_Project/Tests/EditMode/ChargerTests.cs b/Assets/_Project/Tests/EditMode/ChargerTests.cs index d430e5c01..346fc4dbd 100644 --- a/Assets/_Project/Tests/EditMode/ChargerTests.cs +++ b/Assets/_Project/Tests/EditMode/ChargerTests.cs @@ -56,6 +56,8 @@ namespace ProjectM.Tests em.AddComponentData(e, new KnockbackState()); em.AddComponentData(e, new AttackWindup()); em.AddComponentData(e, new LungeState()); + em.AddComponent(e); + em.SetComponentEnabled(e, false); // baked DISABLED on the real Charger (spawns not-lunging) em.AddComponent(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(charger), "Charger spawns not-lunging (baked DISABLED)."); + + group.Update(); // tick 200: commit the lunge + + Assert.AreNotEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: the lunge committed."); + Assert.IsTrue(em.IsComponentEnabled(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(charger, true); // was mid-lunge + + group.Update(); // tick 206 > 205 -> overshoot whiff clears the lunge + + Assert.AreEqual(0u, em.GetComponentData(charger).UntilTick, "Sanity: the whiffed lunge cleared."); + Assert.IsFalse(em.IsComponentEnabled(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(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(charger).UntilTick, "Sanity: knockback cancelled the lunge."); + Assert.IsFalse(em.IsComponentEnabled(charger), + "The cue clears when knockback cancels the lunge (covers the mid-body continue exit path)."); + } + } } }