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:
@@ -60,6 +60,10 @@ namespace ProjectM.Client
|
|||||||
Color _slashTint;
|
Color _slashTint;
|
||||||
float _slashAge, _slashLife;
|
float _slashAge, _slashLife;
|
||||||
bool _slashActive;
|
bool _slashActive;
|
||||||
|
Material _dangerMat;
|
||||||
|
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||||
|
readonly HashSet<Entity> _dangerSeen = new();
|
||||||
|
readonly List<Entity> _dangerStale = new();
|
||||||
AudioClip _hitClip;
|
AudioClip _hitClip;
|
||||||
AudioClip _deathClip;
|
AudioClip _deathClip;
|
||||||
AudioClip _fireClip;
|
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);
|
_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);
|
_swingFx = MakeBurst("MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256);
|
||||||
BuildSlash();
|
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++)
|
for (int i = 0; i < NumberPoolSize; i++)
|
||||||
_numbers.Add(CreateNumber());
|
_numbers.Add(CreateNumber());
|
||||||
@@ -113,6 +120,9 @@ namespace ProjectM.Client
|
|||||||
Object.Destroy(_fxRoot.gameObject);
|
Object.Destroy(_fxRoot.gameObject);
|
||||||
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);
|
||||||
|
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()
|
protected override void OnUpdate()
|
||||||
@@ -128,6 +138,7 @@ namespace ProjectM.Client
|
|||||||
EntityManager.CompleteDependencyBeforeRO<DashState>();
|
EntityManager.CompleteDependencyBeforeRO<DashState>();
|
||||||
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
||||||
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
|
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
|
||||||
|
|
||||||
// Resolve the local player (for hit colouring + fire feedback).
|
// Resolve the local player (for hit colouring + fire feedback).
|
||||||
_localPlayer = Entity.Null;
|
_localPlayer = Entity.Null;
|
||||||
@@ -292,6 +303,7 @@ namespace ProjectM.Client
|
|||||||
PruneVfx();
|
PruneVfx();
|
||||||
AnimateNumbers(dt, cam);
|
AnimateNumbers(dt, cam);
|
||||||
UpdateSlash(dt);
|
UpdateSlash(dt);
|
||||||
|
UpdateEnemyDanger();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
|
// ---- 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);
|
_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)
|
static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
|
||||||
{
|
{
|
||||||
if (ps == null) return;
|
if (ps == null) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user