using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
///
/// Client-only combat JUICE. A managed presentation system (SystemBase, main thread, NO Burst) in the
/// that REACTS to replicated state — it never runs simulation. Each frame
/// it edge-detects every damageable ghost's replicated : a decrease spawns a floating
/// damage number + a hit-spark burst + a hit SFX + camera shake; a Husk despawn (server-authoritative death)
/// spawns a death burst + death SFX; the local player crossing to 0 HP does the same. A local-player ability
/// fire (AbilityCooldown advancing) spawns a muzzle flash + zap. Everything derives from already-replicated
/// state, so it is correct without touching the prediction loop, and it lives only in the client world so the
/// server never instantiates GameObjects.
///
/// VFX prefer authored GabrielAguiar Shuriken prefabs supplied by (muzzle / hit /
/// death + a projectile-following trail); each hook falls back to a procedural particle burst when no prefab
/// is assigned, so the slice still runs asset-free. Spawned VFX are stripped to particles only
/// () — GA "projectile" prefabs ship a Rigidbody + collider + mover that would
/// otherwise self-propel and spawn secondary effects. SFX remain procedural.
///
///
/// Per-entity last Health + position + isEnemy are cached in a managed dictionary (Entity is a stable client
/// key for a ghost's lifetime); stale keys are pruned each frame (a pruned Husk = a kill → death VFX at its
/// last position). Never destroys a ghost from the client — GhostDespawnSystem owns that off the snapshot
/// protocol; we only OBSERVE.
///
///
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class CombatFeedbackSystem : SystemBase
{
struct FxCache { public float Hp; public float MaxHp; public float3 Pos; public bool IsEnemy; public uint Windup; }
readonly Dictionary _cache = new();
readonly HashSet _seen = new();
readonly List _stale = new();
readonly List _numbers = new();
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
readonly List _activeVfx = new();
readonly Dictionary _projTrails = new();
readonly HashSet _projSeen = new();
readonly List _projStale = new();
Transform _fxRoot;
ParticleSystem _hitFx;
ParticleSystem _deathFx;
ParticleSystem _muzzleFx;
ParticleSystem _dashFx;
ParticleSystem _swingFx;
Mesh _slashMesh;
MeshRenderer _slashMr;
Material _slashMat;
Color _slashTint;
float _slashAge, _slashLife;
bool _slashActive;
Material _dangerMat;
readonly Dictionary _dangerZones = new();
readonly HashSet _dangerSeen = new();
readonly List _dangerStale = new();
// ---- Enemy health bars (Slice 1, Feature B) — one pooled world-space Canvas per live Husk ----
struct HealthBarEntry { public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; public float ShowTimer; public bool Visible; }
const int HealthBarPoolLimit = 24;
const float HealthBarShowDuration = 3f;
const float HealthBarFadeDuration = 0.5f;
const float HealthBarAlwaysOnThreshold = 0.25f;
const float HealthBarWorldYOffset = 2.3f;
readonly Dictionary _healthBars = new();
readonly List _barStale = new();
readonly List _barKeys = new();
Material _barBgMat, _barFillMat;
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
readonly Dictionary _pulseStart = new();
AudioClip _hitClip;
AudioClip _deathClip;
AudioClip _fireClip;
AudioClip _telegraphClip;
AudioClip _dashClip;
AudioClip _swingClip;
Entity _localPlayer = Entity.Null;
uint _lastLocalFireTick;
bool _fireTickInit;
uint _lastLocalDashTick;
bool _dashTickInit;
uint _lastLocalSwingTick;
bool _swingTickInit;
const int NumberPoolSize = 32;
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
struct TimedVfx { public GameObject Go; public double Kill; }
protected override void OnCreate()
{
_hitClip = MakeClip("husk_hit", 640f, 180f, 0.10f, 0.5f, noise: true);
_deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false);
_fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false);
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~CombatFeedbackFX").transform;
var mat = MakeParticleMaterial();
_hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256);
_deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512);
_muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
_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)
// Health-bar materials (UI/Default = always-included URP-compatible UI shader; per-instance Image.color carries alpha).
Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default");
_barBgMat = new Material(uiShader) { name = "HealthBarBg" };
_barFillMat = new Material(uiShader) { name = "HealthBarFill" };
for (int i = 0; i < NumberPoolSize; i++)
_numbers.Add(CreateNumber());
}
protected override void OnDestroy()
{
if (_fxRoot != null)
Object.Destroy(_fxRoot.gameObject);
if (_slashMesh != null) Object.Destroy(_slashMesh);
if (_slashMat != null) Object.Destroy(_slashMat);
if (_dangerMat != null) Object.Destroy(_dangerMat);
if (_barBgMat != null) Object.Destroy(_barBgMat);
if (_barFillMat != null) Object.Destroy(_barFillMat);
foreach (var kv in _dangerZones)
if (kv.Value != null) { var mf = kv.Value.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
foreach (var kv in _healthBars)
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo);
}
protected override void OnUpdate()
{
float dt = SystemAPI.Time.DeltaTime;
var cam = Camera.main;
var cfg = VFXConfig.Instance;
// Make sure predicted/physics jobs writing these are done before this main-thread read.
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
// Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null;
float3 localPos = default;
foreach (var (xf, entity) in SystemAPI.Query>()
.WithAll().WithEntityAccess())
{
_localPlayer = entity;
localPos = xf.ValueRO.Position;
}
// Client-derived dash window of the LOCAL player (DashSystem runs in the client prediction loop
// too): drives the i-frame shimmer + the hit-feedback suppression below. Observe-only.
bool localIFrameActive = false;
if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer)
&& SystemAPI.TryGetSingleton(out var dashNetTime) && dashNetTime.ServerTick.IsValid)
{
var localDash = EntityManager.GetComponentData(_localPlayer);
localIFrameActive = localDash.IFrameUntilTick != 0u
&& new NetworkTick(localDash.IFrameUntilTick).IsNewerThan(dashNetTime.ServerTick);
}
// Edge-detect Health on every damageable ghost (players + Husks).
_seen.Clear();
foreach (var (health, xf, entity) in
SystemAPI.Query, RefRO>().WithEntityAccess())
{
_seen.Add(entity);
float cur = health.ValueRO.Current;
float3 p = xf.ValueRO.Position;
bool isEnemy = SystemAPI.HasComponent(entity);
uint windup = isEnemy && SystemAPI.HasComponent(entity) ? SystemAPI.GetComponent(entity).WindUpUntilTick : 0u;
bool isLocalPlayer = entity == _localPlayer;
bool isStructure = SystemAPI.HasComponent(entity); // EB-1: suppress combat cues -> StructureFeedbackSystem
if (_cache.TryGetValue(entity, out var prev))
{
if (isEnemy && windup != 0 && prev.Windup == 0)
{
// Attack telegraph: the wind-up just began -> warn the player ~0.3s before the strike lands.
Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6);
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
_pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime; // Feature C: scale-pulse onset
}
// Local hit feedback is SUPPRESSED while the local i-frame window is active: the server
// negates the hit; any transient Health dip is reconciliation flicker, not a real hit.
if (cur < prev.Hp - 0.001f && !isStructure && !(isLocalPlayer && localIFrameActive && FeelConfig.DashHitSuppress))
{
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isEnemy) ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
}
// Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing
// mechanic exists, so a 0 -> positive edge is unambiguously a respawn (observer-only).
if (isLocalPlayer && FeelConfig.RespawnShimmerEnabled && cur > prev.Hp + 0.001f && prev.Hp <= 0f)
{
Burst(_muzzleFx, null, (Vector3)p + Vector3.up * 0.6f, FeelConfig.RespawnShimmerBurst);
PrototypeCameraRig.AddShake(FeelConfig.RespawnShimmerShake);
}
// Player death (players don't despawn — they respawn; Husk death is handled on prune).
// EB-1: structures (not EnemyTag) would otherwise fire the HUMAN player-death cue here; their
// damage/death is routed entirely through StructureFeedbackSystem (gated by !isStructure).
if (!isEnemy && !isStructure && cur <= 0f && prev.Hp > 0f)
{
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount);
PlayClip(_deathClip, (Vector3)p, 0.7f);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake);
}
}
_cache[entity] = new FxCache { Hp = cur, MaxHp = health.ValueRO.Max, Pos = p, IsEnemy = isEnemy, Windup = windup };
}
// Prune despawned ghosts. A Husk that vanished was killed -> death VFX at its last position.
if (_cache.Count != _seen.Count)
{
_stale.Clear();
foreach (var kv in _cache)
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++)
{
var c = _cache[_stale[i]];
if (c.IsEnemy)
{
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale)));
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
}
_cache.Remove(_stale[i]);
}
}
// Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot.
// Raw uint inequality is intentional here: only the edge (a new shot) matters and the worst case
// of a tick wrap is a single dropped/duplicated muzzle flash — purely cosmetic, never the sim.
if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer))
{
uint nextFire = EntityManager.GetComponentData(_localPlayer).NextFireTick;
if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick)
{
Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
PlayClip(_fireClip, (Vector3)localPos, 0.5f);
}
_lastLocalFireTick = nextFire;
_fireTickInit = true;
}
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
// (replicated [GhostField], predicted both sides; raw uint edge like the muzzle flash — cosmetic
// only). Whoosh + afterimage burst + camera punch on start, shimmer trail while i-frames last.
if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer))
{
uint nextDash = EntityManager.GetComponentData(_localPlayer).NextTick;
if (_dashTickInit && nextDash != 0 && nextDash != _lastLocalDashTick)
{
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.6f, FeelConfig.DashBurstCount);
PlayClip(_dashClip, (Vector3)localPos, FeelConfig.DashSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.DashShake);
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick, FeelConfig.HitStopDurationMs);
}
_lastLocalDashTick = nextDash;
_dashTickInit = true;
if (localIFrameActive) // i-frame shimmer trail while the local window is active
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.7f, FeelConfig.DashShimmerPerFrame);
}
// Local-player melee swing feedback (MC-4): MeleeCombo.SwingStartTick advances once per swing (owner-predicted
// [GhostField]; raw uint edge like the muzzle/dash, cosmetic only). Whoosh + arc burst + a small camera
// nudge ahead of the player; the burst scales with the combo step so the finisher visibly pops.
if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer))
{
var mc = EntityManager.GetComponentData(_localPlayer);
if (_swingTickInit && mc.SwingStartTick != 0 && mc.SwingStartTick != _lastLocalSwingTick)
{
int step = math.max(1, (int)mc.Step);
Vector3 face = Vector3.forward;
if (EntityManager.HasComponent(_localPlayer))
{
var d = EntityManager.GetComponentData(_localPlayer).Direction;
if (math.lengthsq(d) > 1e-6f) face = new Vector3(d.x, 0f, d.y).normalized;
}
EmitAt(_swingFx, (Vector3)localPos + Vector3.up * 0.9f + face * 0.8f, 6 + (step - 1) * 5);
PlayClip(_swingClip, (Vector3)localPos, 0.45f);
PrototypeCameraRig.AddShake(0.04f * step);
int comboLen = SystemAPI.TryGetSingleton(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
bool finisher = step >= comboLen;
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, finisher); // the arc IS the range telegraph (MC-4 visual clarity)
if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
}
_lastLocalSwingTick = mc.SwingStartTick;
_swingTickInit = true;
}
UpdateProjectileTrails(cfg);
PruneVfx();
AnimateNumbers(dt, cam);
UpdateSlash(dt);
UpdateEnemyDanger();
UpdateHealthBars(dt, cam, localPos);
}
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
static GameObject PlayerDeathPrefab(VFXConfig cfg)
{
if (cfg == null) return null;
return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath;
}
void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count)
{
if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity);
else EmitAt(fallback, pos, count);
}
void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot)
{
if (prefab == null || _fxRoot == null) return;
if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC
var go = Object.Instantiate(prefab, pos, rot, _fxRoot);
go.transform.position = pos;
StripCosmetic(go);
var systems = go.GetComponentsInChildren();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) });
}
void PruneVfx()
{
double now = SystemAPI.Time.ElapsedTime;
for (int i = _activeVfx.Count - 1; i >= 0; i--)
{
if (now < _activeVfx[i].Kill) continue;
if (_activeVfx[i].Go != null) Object.Destroy(_activeVfx[i].Go);
_activeVfx.RemoveAt(i);
}
}
// A looping trail prefab follows each in-flight projectile ghost; destroyed when it despawns.
void UpdateProjectileTrails(VFXConfig cfg)
{
if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null)
{
// Config cleared mid-run: drop any orphaned trails so they don't linger.
if (_projTrails.Count > 0)
{
foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value);
_projTrails.Clear();
}
return;
}
_projSeen.Clear();
foreach (var (xf, entity) in
SystemAPI.Query>().WithAll().WithEntityAccess())
{
_projSeen.Add(entity);
Vector3 wp = (Vector3)xf.ValueRO.Position;
if (_projTrails.TryGetValue(entity, out var trail))
{
if (trail != null) trail.transform.position = wp;
}
else
{
var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot);
StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only
var systems = go.GetComponentsInChildren();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_projTrails[entity] = go;
}
}
if (_projTrails.Count == _projSeen.Count) return;
_projStale.Clear();
foreach (var kv in _projTrails)
if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key);
for (int i = 0; i < _projStale.Count; i++)
{
if (_projTrails[_projStale[i]] != null) Object.Destroy(_projTrails[_projStale[i]]);
_projTrails.Remove(_projStale[i]);
}
}
// Cosmetic VFX must be particles only. GA demo "projectile" prefabs ship a non-kinematic Rigidbody,
// a solid collider, and a mover (ProjectileMoveScript) that self-propels and spawns secondary muzzle/hit
// effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks.
static void StripCosmetic(GameObject go)
{
foreach (var rb in go.GetComponentsInChildren(true)) Object.Destroy(rb);
foreach (var col in go.GetComponentsInChildren(true)) Object.Destroy(col);
foreach (var mb in go.GetComponentsInChildren(true))
{
if (mb == null) continue;
string n = mb.GetType().Name;
// Disable (not destroy) BEFORE Start runs so the mover's Start-spawned muzzle never fires.
if (n.IndexOf("Projectile", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
n.IndexOf("Move", System.StringComparison.OrdinalIgnoreCase) >= 0)
mb.enabled = false;
}
}
// Real effect duration from the longest child ParticleSystem (clamped), so we don't force-kill early
// or hold a finished GameObject around on a blanket TTL.
static double VfxLifetime(GameObject go)
{
float longest = 0f;
foreach (var ps in go.GetComponentsInChildren(true))
{
var main = ps.main;
float d = main.duration + main.startLifetime.constantMax;
if (d > longest) longest = d;
}
return Mathf.Clamp(longest, 1f, 6f);
}
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
class FloatingNumber
{
public TextMesh Tm;
public Transform Tr;
public float Age;
public float Life;
public Vector3 Vel;
public Color BaseColor;
public bool Active;
}
FloatingNumber CreateNumber()
{
var go = new GameObject("DamageNumber");
go.transform.SetParent(_fxRoot, false);
var tm = go.AddComponent();
tm.characterSize = 0.12f;
tm.fontSize = 64;
tm.anchor = TextAnchor.MiddleCenter;
tm.alignment = TextAlignment.Center;
tm.color = Color.white;
tm.fontStyle = FontStyle.Bold;
go.SetActive(false);
return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false };
}
void SpawnNumber(float amount, Vector3 worldPos, bool isLocalPlayer, Camera cam)
{
FloatingNumber fn = null;
for (int i = 0; i < _numbers.Count; i++)
if (!_numbers[i].Active) { fn = _numbers[i]; break; }
if (fn == null) return; // pool exhausted this frame: drop (cheap)
fn.Active = true;
fn.Age = 0f;
fn.Life = 0.7f;
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
fn.Tm.color = fn.BaseColor;
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
fn.Vel = new Vector3(0f, 2.2f, 0f);
fn.Tr.gameObject.SetActive(true);
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
}
void AnimateNumbers(float dt, Camera cam)
{
for (int i = 0; i < _numbers.Count; i++)
{
var fn = _numbers[i];
if (!fn.Active) continue;
fn.Age += dt;
if (fn.Age >= fn.Life)
{
fn.Active = false;
fn.Tr.gameObject.SetActive(false);
continue;
}
fn.Vel.y -= 3.5f * dt; // ease the rise
fn.Tr.position += fn.Vel * dt;
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
var c = fn.BaseColor;
c.a = 1f - (fn.Age / fn.Life);
fn.Tm.color = c;
}
}
// ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ----
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise)
{
const int rate = 44100;
int len = Mathf.Max(16, (int)(dur * rate));
var clip = AudioClip.Create(name, len, 1, rate, false);
var data = new float[len];
var rng = new System.Random(name.Length * 9973 + 7);
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t = i / (float)len;
float env = Mathf.Exp(-5f * t);
float freq = Mathf.Lerp(f0, f1, t);
phase += 2f * Mathf.PI * freq / rate;
float s = noise ? (float)(rng.NextDouble() * 2.0 - 1.0) : Mathf.Sin(phase);
data[i] = s * env * vol;
}
clip.SetData(data, 0);
return clip;
}
static Material MakeParticleMaterial()
{
// Sprites/Default is an always-included, transparent, vertex-coloured shader — reliable for
// billboarded sparks; HDR start colours still push past the bloom threshold (Stage 5 look pass).
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
return new Material(sh) { name = "CombatFeedbackParticle" };
}
ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max)
{
var go = new GameObject(name);
go.transform.SetParent(_fxRoot, false);
var ps = go.AddComponent();
var main = ps.main;
main.loop = false;
main.playOnAwake = false;
// duration unused: manual Emit bursts with emission disabled
main.startLifetime = life;
main.startSpeed = speed;
main.startSize = size;
main.startColor = color;
main.maxParticles = max;
main.gravityModifier = 0f;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.enabled = false; // we Emit(count) manually
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.06f;
var colOverLife = ps.colorOverLifetime;
colOverLife.enabled = true;
var grad = new Gradient();
grad.SetKeys(
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
var sizeOverLife = ps.sizeOverLifetime;
sizeOverLife.enabled = true;
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.2f));
var renderer = ps.GetComponent();
renderer.material = mat;
renderer.renderMode = ParticleSystemRenderMode.Billboard;
return ps;
}
void BuildSlash()
{
var go = new GameObject("MeleeSlashArc");
go.transform.SetParent(_fxRoot, false);
_slashMesh = new Mesh { name = "MeleeSlashArc" };
var mf = go.AddComponent();
mf.sharedMesh = _slashMesh;
_slashMr = go.AddComponent();
_slashMat = MakeParticleMaterial();
_slashMat.name = "MeleeSlashArc";
_slashMr.sharedMaterial = _slashMat;
_slashMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_slashMr.receiveShadows = false;
_slashMr.enabled = false;
}
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
void BuildSlashMesh(float range, float halfAngle)
{
const int seg = 16;
float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * 0.45f;
var verts = new Vector3[(seg + 1) * 2];
var cols = new Color[(seg + 1) * 2];
var uvs = new Vector2[(seg + 1) * 2];
var tris = new int[seg * 6];
for (int i = 0; i <= seg; i++)
{
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
float across = 1f - Mathf.Abs(i / (float)seg * 2f - 1f); // 0 at edges, 1 at centre
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.4f + 0.6f * across)); // inner brighter
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
uvs[i * 2] = new Vector2(0.5f, 0.5f);
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
}
for (int i = 0; i < seg; i++)
{
int b = i * 2;
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
}
_slashMesh.Clear();
_slashMesh.vertices = verts;
_slashMesh.colors = cols;
_slashMesh.uv = uvs;
_slashMesh.triangles = tris;
_slashMesh.RecalculateBounds();
}
// Flash a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS the
// range telegraph (MC-4 visual clarity): the player sees exactly how far + how wide the cleave reaches.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, bool finisher)
{
if (_slashMr == null || _slashMat == null) return;
BuildSlashMesh(range, halfAngle);
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
var tr = _slashMr.transform;
tr.position = pos + Vector3.up * 0.12f;
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
tr.localScale = Vector3.one;
_slashTint = finisher ? new Color(3.2f, 2.3f, 0.7f) : new Color(1.6f, 2.4f, 3.2f); // finisher warm / light cool (HDR -> bloom)
_slashLife = finisher ? 0.26f : 0.17f;
_slashAge = 0f;
_slashActive = true;
_slashMat.color = _slashTint;
_slashMr.enabled = true;
}
void UpdateSlash(float dt)
{
if (!_slashActive || _slashMr == null) return;
_slashAge += dt;
float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
var c = _slashTint; c.a = 1f - u; _slashMat.color = c;
_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, tele, entity) in
SystemAPI.Query, RefRO, RefRO, RefRO>()
.WithAll().WithEntityAccess())
{
// Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit).
bool lunging = SystemAPI.HasComponent(entity) && SystemAPI.IsComponentEnabled(entity);
uint until = windup.ValueRO.WindUpUntilTick;
if (until == 0u && !lunging) continue;
float intensity;
if (lunging)
{
intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears
}
else
{
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 (fixes the Charger plateauing early under the old hard-coded 22).
float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
intensity = math.saturate(1f - remaining / windupDur);
}
// 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 = _dangerMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
_dangerZones[entity] = go;
}
float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f);
if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel
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);
}
}
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]);
_pulseStart.Remove(_dangerStale[i]);
}
}
}
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
// ---- Enemy Health Bars (Slice 1, Feature B) — pooled world-space Canvas, on-damage sticky + fade ----
void ShowHealthBar(Entity entity)
{
if (!_healthBars.TryGetValue(entity, out var entry) || entry.CanvasGo == null)
entry = CreateHealthBar(entity);
entry.ShowTimer = HealthBarShowDuration;
if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; }
_healthBars[entity] = entry; // struct — must re-assign
}
HealthBarEntry CreateHealthBar(Entity entity)
{
var go = new GameObject("EnemyHPBar");
if (_fxRoot != null) go.transform.SetParent(_fxRoot, false);
var canvas = go.AddComponent