using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; using static ProjectM.Client.FeedbackFx; namespace ProjectM.Client { /// /// MC-3/MC-4/A7 — client-only enemy attack TELEGRAPHS. Observe-only presentation in /// that reads replicated state and never mutates the sim. While an enemy's /// counts down (or a Charger is mid-lunge) it paints a red ground danger shape in the /// enemy's facing — a melee cone, a Spitter aim LANE, or the boss's radial SLAM ring / lunge wedge — brightening + /// scaling as the strike nears so the player reads WHERE and WHEN to dodge. Also plays a near-impact "dodge NOW" /// strike beep once per windup for enemies near the local player. SELF-DETECTS the windup-onset edge via its own /// _prevWindup map (a 0 -> nonzero WindUpUntilTick transition arms the anticipation scale-pulse — /// this was formerly written by CombatFeedbackSystem's health-scan loop). One pooled mesh per winding-up enemy, /// pruned each frame; the tracking maps are pruned against a full enemy-seen set. Extracted from CombatFeedbackSystem; /// owns its own FX-root + danger material + beep clip. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class EnemyDangerTelegraphSystem : SystemBase { Transform _fxRoot; Material _dangerMat; readonly Dictionary _dangerZones = new(); readonly HashSet _dangerSeen = new(); // telegraph-ACTIVE enemies (zone lifecycle: a zone vanishes when its enemy stops winding up) readonly List _dangerStale = new(); // scratch list reused by every prune readonly Dictionary _pulseStart = new(); // per-enemy windup-onset time (anticipation scale-pulse) readonly Dictionary _strikeBeeped = new(); // entity -> the WindUpUntilTick it last beeped for (once/windup) readonly Dictionary _prevWindup = new(); // self-detect the windup-onset edge (was the core _cache.Windup) readonly HashSet _enemySeen = new(); // ALL enemies this frame (prunes _pulseStart/_strikeBeeped/_prevWindup) AudioClip _strikeBeepClip; // near-impact "dodge NOW" beep AudioClip[] _kindGrowls; // per-kind windup voice (attack distinctness) Material[] _kindMats; // per-kind telegraph colours (attack distinctness) Entity _localPlayer = Entity.Null; protected override void OnCreate() { _strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // near-impact beep // Attack-distinctness: per-kind windup VOICES so kinds read by EAR at onset (0 grunt thud, // 1 charger rising roar, 2 spitter wet hiss, 3 swarmer chitter). _kindGrowls = new AudioClip[] { MakeClip("growl_grunt", 180f, 110f, 0.14f, 0.30f, noise: false), MakeClip("growl_charger", 90f, 420f, 0.32f, 0.34f, noise: false), MakeClip("growl_spitter", 1900f, 500f, 0.18f, 0.26f, noise: true), MakeClip("growl_swarmer", 1500f, 2100f, 0.07f, 0.22f, noise: false), }; } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~EnemyDangerFX").transform; _dangerMat = MakeParticleMaterial(); _dangerMat.name = "EnemyDanger"; _dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red fallback (per-zone intensity in vertex alpha) // Attack-distinctness: per-kind telegraph COLORS (0 grunt orange, 1 charger crimson, // 2 spitter toxic green, 3 swarmer yellow; the boss shares the charger crimson — shapes differ). _kindMats = new Material[4]; var kindColors = new Color[] { new Color(3.2f, 0.85f, 0.15f, 1f), new Color(3.2f, 0.18f, 0.12f, 1f), new Color(0.55f, 2.9f, 0.35f, 1f), new Color(2.9f, 2.3f, 0.22f, 1f), }; for (int i = 0; i < 4; i++) { _kindMats[i] = MakeParticleMaterial(); _kindMats[i].name = "EnemyDanger_K" + i; _kindMats[i].color = kindColors[i]; } } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); if (_dangerMat != null) Object.Destroy(_dangerMat); if (_kindMats != null) for (int i = 0; i < _kindMats.Length; i++) if (_kindMats[i] != null) Object.Destroy(_kindMats[i]); 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() { if (_fxRoot == null || _dangerMat == null) return; // Predicted/physics jobs writing these must finish before this main-thread read. EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); // Local player (strike-beep proximity gate). _localPlayer = Entity.Null; float3 localPos = default; foreach (var (xf, entity) in SystemAPI.Query>() .WithAll().WithEntityAccess()) { _localPlayer = entity; localPos = xf.ValueRO.Position; } UpdateEnemyDanger(localPos); } // 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(float3 localPos) { if (_fxRoot == null || _dangerMat == null) return; Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; _dangerSeen.Clear(); _enemySeen.Clear(); if (serverTick.IsValid) { foreach (var (xf, stats, windup, tele, entity) in SystemAPI.Query, RefRO, RefRO, RefRO>() .WithAll().WithEntityAccess()) { _enemySeen.Add(entity); uint until = windup.ValueRO.WindUpUntilTick; // Self-detect the windup-onset edge (formerly written by the core's health-scan loop): a 0 -> nonzero // transition of WindUpUntilTick arms the anticipation scale-pulse (Feature C). Requires a prior 0 // record so a mid-windup relevancy re-entry doesn't spuriously pulse (matches the old prev.Windup==0). bool hadPrev = _prevWindup.TryGetValue(entity, out var pw); if (until != 0u && hadPrev && pw == 0u) { _pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime; // Attack-distinctness: the per-kind windup VOICE at onset (same gate family as the strike beep). if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && _kindGrowls != null && math.distancesq(xf.ValueRO.Position, localPos) <= FeelConfig.StrikeBeepMaxDistSq) { byte gk = tele.ValueRO.Kind; if (gk < _kindGrowls.Length && _kindGrowls[gk] != null) PlayClip(_kindGrowls[gk], (Vector3)xf.ValueRO.Position, 0.22f); } } _prevWindup[entity] = until; 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); // Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for // any windup length. float windupDur = math.max(1f, tele.ValueRO.WindupTicks); float intensity = math.saturate(1f - remaining / windupDur); {; // Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to // enemies near the local player (the danger cone already proves it's winding up to strike). if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks && (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until)) { float3 ep = xf.ValueRO.Position; if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq) { PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume); _strikeBeeped[entity] = until; } } } // 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) { go = new GameObject("EnemyDanger"); go.transform.SetParent(_fxRoot, false); go.AddComponent().sharedMesh = new Mesh { name = "EnemyDanger" }; var mr = go.AddComponent(); mr.sharedMaterial = _kindMats != null && tele.ValueRO.Kind < _kindMats.Length && _kindMats[tele.ValueRO.Kind] != null ? _kindMats[tele.ValueRO.Kind] : _dangerMat; // per-kind telegraph colour (kind is fixed per entity) mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; _dangerZones[entity] = go; } float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f); // 2026-08-07 audit purge: the boss radial-slam ring, the boss lunge wedge and the Spitter aim // lane are gone with BossState / IsLunging / SpitterState. One enemy kind, one melee wedge. 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 + pulse); } } // Zone lifecycle: a zone vanishes the moment its enemy stops winding up (not in _dangerSeen) or despawns. 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]); } } // Prune the tracking maps against the FULL enemy-seen set (a despawned enemy drops its pulse/beep/windup state). PruneTracking(_pulseStart); PruneTracking(_strikeBeeped); PruneTracking(_prevWindup); } // Remove entries whose enemy wasn't seen this frame (keyed on the full enemy-seen set); reuses _dangerStale. void PruneTracking(Dictionary dict) { if (dict.Count == 0) return; _dangerStale.Clear(); foreach (var kv in dict) if (!_enemySeen.Contains(kv.Key)) _dangerStale.Add(kv.Key); for (int i = 0; i < _dangerStale.Count; i++) dict.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(); } // MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha // ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is // already rotated to the enemy facing, so +Z is "toward the locked target". static void BuildLaneMesh(Mesh mesh, float length, float halfWidth, float intensity) { float a = 0.18f + 0.62f * intensity; var verts = new Vector3[4] { new Vector3(-halfWidth, 0f, 0.2f), new Vector3( halfWidth, 0f, 0.2f), new Vector3(-halfWidth, 0f, length), new Vector3( halfWidth, 0f, length), }; var cols = new Color[4] { new Color(1f, 1f, 1f, a), new Color(1f, 1f, 1f, a), new Color(1f, 1f, 1f, a * 0.12f), new Color(1f, 1f, 1f, a * 0.12f), }; var uvs = new Vector2[4] { new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f) }; var tris = new int[6] { 0, 2, 1, 1, 2, 3 }; mesh.Clear(); mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris; mesh.RecalculateBounds(); } } }