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(); canvas.renderMode = RenderMode.WorldSpace; canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry var rt = go.GetComponent(); rt.sizeDelta = new Vector2(1.2f, 0.14f); var bgGo = new GameObject("Bg"); bgGo.transform.SetParent(go.transform, false); var bgRt = bgGo.AddComponent(); bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one; bgRt.offsetMin = bgRt.offsetMax = Vector2.zero; var bgImg = bgGo.AddComponent(); bgImg.material = _barBgMat; bgImg.color = new Color(0.05f, 0.05f, 0.06f, 0.82f); var fillGo = new GameObject("Fill"); fillGo.transform.SetParent(go.transform, false); var fillRt = fillGo.AddComponent(); fillRt.anchorMin = Vector2.zero; fillRt.anchorMax = Vector2.one; fillRt.offsetMin = new Vector2(0.02f, 0.02f); fillRt.offsetMax = new Vector2(-0.02f, -0.02f); var fillImg = fillGo.AddComponent(); fillImg.material = _barFillMat; fillImg.color = new Color(0.88f, 0.22f, 0.14f, 1f); fillImg.type = UnityEngine.UI.Image.Type.Filled; fillImg.fillMethod = UnityEngine.UI.Image.FillMethod.Horizontal; fillImg.fillOrigin = 0; // left fillImg.fillAmount = 1f; go.SetActive(false); var entry = new HealthBarEntry { CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false }; _healthBars[entity] = entry; return entry; } // Per-frame: prune dead bars (reusing the main loop's _seen set), pool-cap by distance, billboard + fade. void UpdateHealthBars(float dt, Camera cam, float3 localPlayerPos) { if (_healthBars.Count > 0) { _barStale.Clear(); foreach (var kv in _healthBars) if (!_seen.Contains(kv.Key)) _barStale.Add(kv.Key); for (int i = 0; i < _barStale.Count; i++) { var e2 = _barStale[i]; if (_healthBars[e2].CanvasGo != null) Object.Destroy(_healthBars[e2].CanvasGo); _healthBars.Remove(e2); } } if (_healthBars.Count == 0) return; bool capBars = _localPlayer != Entity.Null && _healthBars.Count > HealthBarPoolLimit; _barKeys.Clear(); foreach (var k in _healthBars.Keys) _barKeys.Add(k); for (int i = 0; i < _barKeys.Count; i++) { var key = _barKeys[i]; var entry = _healthBars[key]; if (entry.CanvasGo == null) continue; if (!_cache.TryGetValue(key, out var fc)) continue; float frac = fc.MaxHp > 0f ? math.saturate(fc.Hp / fc.MaxHp) : 1f; bool alwaysOn = frac < HealthBarAlwaysOnThreshold; if (capBars && math.lengthsq(fc.Pos - localPlayerPos) > FeelConfig.HealthBarMaxDistSq) { if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } _healthBars[key] = entry; continue; } if (!alwaysOn) entry.ShowTimer -= dt; bool shouldShow = alwaysOn || entry.ShowTimer > -HealthBarFadeDuration; if (shouldShow) { if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; } if (cam != null) { entry.CanvasGo.transform.position = (Vector3)fc.Pos + Vector3.up * HealthBarWorldYOffset; entry.CanvasGo.transform.rotation = cam.transform.rotation; // billboard } float alpha = (!alwaysOn && entry.ShowTimer < 0f) ? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f; if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.fillAmount = frac; } if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; } } else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } _healthBars[key] = entry; } } 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; ps.transform.position = pos; ps.Emit(count); } static void PlayClip(AudioClip clip, Vector3 pos, float vol) { if (clip == null) return; AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); } } }