Feel: attack distinctness — per-kind telegraph colours, windup voices, spit light

Every enemy kind now has a unique read on the existing replicated
state (client-only, zero netcode):

- Telegraph COLOURS per EnemyTelegraph.Kind: grunt orange, charger
  crimson (boss shares it - shapes already differ: cone/wedge/ring),
  spitter toxic-green lane, swarmer yellow. Shapes were already
  per-kind; they all rendered the same HDR red.
- Windup VOICES at the onset edge (the existing _prevWindup edge +
  strike-beep distance gate): grunt low thud, charger rising roar,
  spitter wet hiss, swarmer chitter - kinds read by EAR before the
  telegraph ramps.
- Projectile light colour by ownership in DynamicLightSystem: owned
  shots stay player-cyan, un-owned (Spitter spit) glows toxic green
  (EnemyProjectileColor knob).

Verified live: 12 s combat histogram shows zones rendering with
EnemyDanger_K0 (grunt) + EnemyDanger_K1 (charger) materials keyed to
real windups (screenshot); 466/466 EditMode; console clean. Remaining
distinctness depth (per-kind anim clips, creature locomotion) rides
the parallel Blender workstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:17:43 -07:00
parent 4ae022c5ad
commit a335ca3f21
3 changed files with 51 additions and 4 deletions
@@ -19,6 +19,8 @@ namespace ProjectM.Client
[Header("Projectile lights (pooled, follow every live projectile ghost)")] [Header("Projectile lights (pooled, follow every live projectile ghost)")]
[Min(0)] public int MaxProjectileLights = 24; [Min(0)] public int MaxProjectileLights = 24;
public Color ProjectileColor = new Color(0.45f, 0.85f, 1f, 1f); public Color ProjectileColor = new Color(0.45f, 0.85f, 1f, 1f);
[Tooltip("Un-owned (enemy) projectiles — Spitter spit reads toxic green vs the player's cyan.")]
public Color EnemyProjectileColor = new Color(0.55f, 1f, 0.35f, 1f);
[Min(0f)] public float ProjectileIntensity = 2.6f; [Min(0f)] public float ProjectileIntensity = 2.6f;
[Min(0.5f)] public float ProjectileRange = 7f; [Min(0.5f)] public float ProjectileRange = 7f;
@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using ProjectM.Simulation; using ProjectM.Simulation;
using Unity.Entities; using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms; using Unity.Transforms;
using UnityEngine; using UnityEngine;
@@ -74,6 +75,7 @@ namespace ProjectM.Client
Color projColor = cfg != null ? cfg.ProjectileColor : new Color(0.45f, 0.85f, 1f, 1f); Color projColor = cfg != null ? cfg.ProjectileColor : new Color(0.45f, 0.85f, 1f, 1f);
float projIntensity = cfg != null ? cfg.ProjectileIntensity : 2.6f; float projIntensity = cfg != null ? cfg.ProjectileIntensity : 2.6f;
float projRange = cfg != null ? cfg.ProjectileRange : 7f; float projRange = cfg != null ? cfg.ProjectileRange : 7f;
Color enemyProjColor = cfg != null ? cfg.EnemyProjectileColor : new Color(0.55f, 1f, 0.35f, 1f);
_seen.Clear(); _seen.Clear();
foreach (var (lt, entity) in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<Projectile>().WithEntityAccess()) foreach (var (lt, entity) in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<Projectile>().WithEntityAccess())
{ {
@@ -84,7 +86,8 @@ namespace ProjectM.Client
light = Rent(); light = Rent();
_projectileLights[entity] = light; _projectileLights[entity] = light;
} }
light.color = projColor; // Attack-distinctness: owned shots glow the player cyan; un-owned (enemy spit) glows toxic green.
light.color = EntityManager.HasComponent<GhostOwner>(entity) ? projColor : enemyProjColor;
light.intensity = projIntensity; light.intensity = projIntensity;
light.range = projRange; light.range = projRange;
var p = lt.ValueRO.Position; var p = lt.ValueRO.Position;
@@ -35,11 +35,22 @@ namespace ProjectM.Client
readonly Dictionary<Entity, uint> _prevWindup = new(); // self-detect the windup-onset edge (was the core _cache.Windup) readonly Dictionary<Entity, uint> _prevWindup = new(); // self-detect the windup-onset edge (was the core _cache.Windup)
readonly HashSet<Entity> _enemySeen = new(); // ALL enemies this frame (prunes _pulseStart/_strikeBeeped/_prevWindup) readonly HashSet<Entity> _enemySeen = new(); // ALL enemies this frame (prunes _pulseStart/_strikeBeeped/_prevWindup)
AudioClip _strikeBeepClip; // near-impact "dodge NOW" beep 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; Entity _localPlayer = Entity.Null;
protected override void OnCreate() protected override void OnCreate()
{ {
_strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // near-impact beep _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() protected override void OnStartRunning()
@@ -48,13 +59,32 @@ namespace ProjectM.Client
_fxRoot = new GameObject("~EnemyDangerFX").transform; _fxRoot = new GameObject("~EnemyDangerFX").transform;
_dangerMat = MakeParticleMaterial(); _dangerMat = MakeParticleMaterial();
_dangerMat.name = "EnemyDanger"; _dangerMat.name = "EnemyDanger";
_dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha) _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() protected override void OnDestroy()
{ {
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
if (_dangerMat != null) Object.Destroy(_dangerMat); 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) 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); } if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
} }
@@ -107,7 +137,18 @@ namespace ProjectM.Client
// transition of WindUpUntilTick arms the anticipation scale-pulse (Feature C). Requires a prior 0 // 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). // 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); bool hadPrev = _prevWindup.TryGetValue(entity, out var pw);
if (until != 0u && hadPrev && pw == 0u) _pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime; 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; _prevWindup[entity] = until;
// Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit). // Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit).
@@ -163,7 +204,8 @@ namespace ProjectM.Client
go.transform.SetParent(_fxRoot, false); go.transform.SetParent(_fxRoot, false);
go.AddComponent<MeshFilter>().sharedMesh = new Mesh { name = "EnemyDanger" }; go.AddComponent<MeshFilter>().sharedMesh = new Mesh { name = "EnemyDanger" };
var mr = go.AddComponent<MeshRenderer>(); var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _dangerMat; 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.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false; mr.receiveShadows = false;
_dangerZones[entity] = go; _dangerZones[entity] = go;