From 913fc45538ef814cf77d768eec4a24c862d7512b Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 10 Jun 2026 23:42:45 -0700 Subject: [PATCH] Combat: enemy attack telegraph - ground danger cones (MC-4 clarity) CombatFeedbackSystem.UpdateEnemyDanger paints a red ground danger cone at each enemy while its AttackWindup counts down -- oriented along the enemy facing, sized to EnemyStats.AttackRange, brightening + scaling as the strike nears (intensity = 1 - ticksRemaining/22) so the player reads WHERE + WHEN to dodge. Client-only observe-only; one pooled mesh per winding-up enemy, pruned each frame. Play-verified (14-enemy wave, 8 telegraphs at once, zero errors). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Presentation/CombatFeedbackSystem.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index cb13555ac..62b23262d 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -60,6 +60,10 @@ namespace ProjectM.Client Color _slashTint; float _slashAge, _slashLife; bool _slashActive; + Material _dangerMat; + readonly Dictionary _dangerZones = new(); + readonly HashSet _dangerSeen = new(); + readonly List _dangerStale = new(); AudioClip _hitClip; AudioClip _deathClip; AudioClip _fireClip; @@ -102,6 +106,9 @@ namespace ProjectM.Client _dashFx = MakeBurst("DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256); _swingFx = MakeBurst("MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256); BuildSlash(); + _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) for (int i = 0; i < NumberPoolSize; i++) _numbers.Add(CreateNumber()); @@ -113,6 +120,9 @@ namespace ProjectM.Client Object.Destroy(_fxRoot.gameObject); if (_slashMesh != null) Object.Destroy(_slashMesh); if (_slashMat != null) Object.Destroy(_slashMat); + if (_dangerMat != null) Object.Destroy(_dangerMat); + foreach (var kv in _dangerZones) + if (kv.Value != null) { var mf = kv.Value.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); } } protected override void OnUpdate() @@ -128,6 +138,7 @@ namespace ProjectM.Client EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); // Resolve the local player (for hit colouring + fire feedback). _localPlayer = Entity.Null; @@ -292,6 +303,7 @@ namespace ProjectM.Client PruneVfx(); AnimateNumbers(dt, cam); UpdateSlash(dt); + UpdateEnemyDanger(); } // ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ---- @@ -638,6 +650,83 @@ namespace ProjectM.Client _slashMr.transform.localScale = Vector3.one * (1f + u * 0.12f); } + // Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger + // cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE + + // WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame. + void UpdateEnemyDanger() + { + if (_fxRoot == null || _dangerMat == null) return; + Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; + _dangerSeen.Clear(); + if (serverTick.IsValid) + { + foreach (var (xf, stats, windup, entity) in + SystemAPI.Query, RefRO, RefRO>() + .WithAll().WithEntityAccess()) + { + 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 + + _dangerSeen.Add(entity); + if (!_dangerZones.TryGetValue(entity, out var go) || go == null) + { + go = new GameObject("EnemyDanger"); + go.transform.SetParent(_fxRoot, false); + go.AddComponent().sharedMesh = new Mesh { name = "EnemyDanger" }; + var mr = go.AddComponent(); + mr.sharedMaterial = _dangerMat; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + _dangerZones[entity] = go; + } + BuildDangerMesh(go.GetComponent().sharedMesh, math.max(1f, stats.ValueRO.AttackRange + 0.6f), 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); + } + } + if (_dangerZones.Count != _dangerSeen.Count) + { + _dangerStale.Clear(); + foreach (var kv in _dangerZones) if (!_dangerSeen.Contains(kv.Key)) _dangerStale.Add(kv.Key); + for (int i = 0; i < _dangerStale.Count; i++) + { + 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]); + } + } + } + + // Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`. + static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity) + { + const int seg = 14; + var verts = new Vector3[seg + 2]; + var cols = new Color[seg + 2]; + var uvs = new Vector2[seg + 2]; + var tris = new int[seg * 3]; + float aCenter = 0.18f + 0.62f * intensity; + verts[0] = Vector3.zero; cols[0] = new Color(1f, 1f, 1f, aCenter); uvs[0] = new Vector2(0.5f, 0.5f); + for (int i = 0; i <= seg; i++) + { + float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg); + verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range); + cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f); + uvs[i + 1] = new Vector2(0.5f, 0.5f); + } + for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; } + mesh.Clear(); + mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris; + mesh.RecalculateBounds(); + } + static void EmitAt(ParticleSystem ps, Vector3 pos, int count) { if (ps == null) return;