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) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 23:42:45 -07:00
parent 1b07a6b07f
commit 913fc45538
@@ -60,6 +60,10 @@ namespace ProjectM.Client
Color _slashTint;
float _slashAge, _slashLife;
bool _slashActive;
Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new();
readonly HashSet<Entity> _dangerSeen = new();
readonly List<Entity> _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<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
}
protected override void OnUpdate()
@@ -128,6 +138,7 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<DashState>();
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
// 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<NetworkTime>(out var nt) ? nt.ServerTick : default;
_dangerSeen.Clear();
if (serverTick.IsValid)
{
foreach (var (xf, stats, windup, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>>()
.WithAll<EnemyTag>().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<MeshFilter>().sharedMesh = new Mesh { name = "EnemyDanger" };
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _dangerMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
_dangerZones[entity] = go;
}
BuildDangerMesh(go.GetComponent<MeshFilter>().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<MeshFilter>(); 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;