using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; using static ProjectM.Client.FeedbackFx; 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(); bool _scanPrimed; // Phase 1: first health-scan completed -> new cache entries are true spawns, not the connect flood 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; float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes) Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height) // Track B: BuildSlashInto used to allocate four arrays (~1.7 KB) on EVERY call, and it runs twice a // frame for the local arc + smear plus once per live remote swing. The segment count is a compile-time // constant, so the sizes never vary — fill these in place instead. UVs/triangles are argument-independent // (built once, guarded by _arcStaticsBuilt) and are uploaded to each mesh only on its first fill. const int ArcSeg = 16; readonly Vector3[] _arcVerts = new Vector3[(ArcSeg + 1) * 2]; readonly Color[] _arcCols = new Color[(ArcSeg + 1) * 2]; readonly Vector2[] _arcUvs = new Vector2[(ArcSeg + 1) * 2]; readonly int[] _arcTris = new int[ArcSeg * 6]; bool _arcStaticsBuilt; uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none) int _pendingConnectStep; uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none) float _pendingConeRange; // cone reach/half latched at the fire edge (folded socket stats) float _pendingConeHalf; float _allyFxScale = 1f; // 07-21 G4: saturation-derived ally-FX degradation (1 = full loudness) #if UNITY_EDITOR float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on int _stressBeat; #endif // Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote // player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr). class RemoteSlash { public GameObject Go; public Mesh Mesh; public MeshRenderer Mr; public Material Mat; public float Age, Life, Range, Half; public int SweepSign; public Color Tint; public bool Active; public uint LastSwingTick; public bool Init; } readonly Dictionary _remoteSlashes = new(); readonly HashSet _remoteSeen = new(); readonly List _remoteStale = new(); AudioClip _hitClip; AudioClip _deathClip; AudioClip _fireClip; AudioClip _telegraphClip; AudioClip _dashClip; AudioClip _swingClip; AudioClip _meleeConnectClip; // combat feel pass: connect thunk readonly AudioClip[] _footstepClips = new AudioClip[3]; // 07-15: heavy underwater thud variants (random pick per step) ParticleSystem _stepFx; // 07-15: silt puff per footstep ParticleSystem _bubbleFx; float _bubbleTimer; // 07-16: dome bubble exhaust (+1 per footstep, loose sync) Light _lampLight; // 07-16e: the shoulder lamp CASTS — a warm spot beam riding the body yaw Light _suitGlow; // 08-07 A0: a warm pool ON the diver (the lamp above points AWAY) Vector3 _lastFootPos; float _footDistAccum; float _footStepGap; bool _footInit; // stride-distance footsteps (local player) Entity _localPlayer = Entity.Null; uint[] _lastSocketFire = new uint[SocketId.Count]; // LANTERN per-socket fire-edge cache (was _lastLocalFireTick) bool _socketFireInit; 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 EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal) // Track B: a pooled VFX instance. Component arrays are cached PER INSTANCE — component references are // instance-scoped, so arrays captured off the prefab ASSET would drive the asset, not the clone. class VfxInstance { public GameObject Go; public Transform Tr; public ParticleSystem[] Systems; public TrailRenderer[] Trails; public GameObject Prefab; // the stack this instance returns to; never re-read from VFXConfig public bool Rented; // at-most-once guard: a double Return would alias one instance to two callers } struct TimedVfx { public VfxInstance Inst; public double Kill; } // Per-prefab pool of inactive instances, plus the two values that ARE legitimately per-prefab. Instance // fields, never static: the Kill deadlines come from the per-world SystemAPI.Time.ElapsedTime, which // restarts at 0 for each session world. readonly Dictionary> _vfxPool = new(); readonly Dictionary _vfxLifetime = new(); readonly Dictionary _vfxPrefabScale = new(); Transform _vfxFillRoot; // INACTIVE parent: instances fill here so Awake/Start never run const int VfxPerPrefabRetain = 10; // retained inactive instances per prefab; destroy beyond it protected override void OnCreate() { // 07-21 G4 (review wf_9757d214): remote-player census — solo (0 remotes) forces _allyFxScale = 1. _remotePlayersQuery = SystemAPI.QueryBuilder().WithAll().WithDisabled().Build(); _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); _meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect // 07-15 underwater feel: three deep muffled thud variants (noise burst, low sweep, long decay) — // a random pick + volume jitter per step reads as weight (PlayClipAtPoint has no pitch control). _footstepClips[0] = MakeClip("step_a", 95f, 38f, 0.13f, 0.5f, noise: true, decay: 7f); _footstepClips[1] = MakeClip("step_b", 108f, 42f, 0.12f, 0.5f, noise: true, decay: 7f); _footstepClips[2] = MakeClip("step_c", 120f, 45f, 0.11f, 0.5f, noise: true, decay: 7f); } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~CombatFeedbackFX").transform; var mat = MakeParticleMaterial(); _hitFx = MakeBurst(_fxRoot, "HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256); _deathFx = MakeBurst(_fxRoot, "DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512); _muzzleFx = MakeBurst(_fxRoot, "Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128); _dashFx = MakeBurst(_fxRoot, "DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256); _swingFx = MakeBurst(_fxRoot, "MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256); // 07-15: silt puff per footstep — dark, slow, hangs briefly (underwater weight read; dark = no bloom). _stepFx = MakeBurst(_fxRoot, "SiltPuff", mat, new Color(0.06f, 0.10f, 0.11f, 0.85f), 0.20f, 0.8f, 1.1f, 128, gravity: 0.03f, shapeRadius: 0.18f, sizeTail: 1.5f); // 07-16: bubble exhaust — tiny pale spheres that RISE (negative gravity) and drift from the dome. _bubbleFx = MakeBurst(_fxRoot, "Bubbles", mat, new Color(0.75f, 0.9f, 1.0f, 0.85f), 0.055f, 0.25f, 2.4f, 128, gravity: -0.45f, shapeRadius: 0.06f, sizeTail: 0.35f); BuildSlash(); for (int i = 0; i < NumberPoolSize; i++) _numbers.Add(CreateNumber()); } protected override void OnDestroy() { // The SFX ring is DontDestroyOnLoad (it must outlive LoadScene(Single)), so a world teardown // would otherwise bleed in-flight combat cues straight into the main menu. OneShotAudioPool.SilenceAll(); if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); if (_slashMesh != null) Object.Destroy(_slashMesh); if (_slashMat != null) Object.Destroy(_slashMat); if (_smearMesh != null) Object.Destroy(_smearMesh); if (_smearMat != null) Object.Destroy(_smearMat); // See FeedbackFx.DestroyClip: an AudioClip.Create'd clip is not owned by _fxRoot, so all ten leaked // on every client-world teardown. The four sibling clip-owning systems do the same in their OnDestroy. FeedbackFx.DestroyClip(ref _hitClip); FeedbackFx.DestroyClip(ref _deathClip); FeedbackFx.DestroyClip(ref _fireClip); FeedbackFx.DestroyClip(ref _telegraphClip); FeedbackFx.DestroyClip(ref _dashClip); FeedbackFx.DestroyClip(ref _swingClip); FeedbackFx.DestroyClip(ref _meleeConnectClip); for (int i = 0; i < _footstepClips.Length; i++) FeedbackFx.DestroyClip(ref _footstepClips[i]); foreach (var kv in _remoteSlashes) { if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh); if (kv.Value.Mat != null) Object.Destroy(kv.Value.Mat); if (kv.Value.Go != null) Object.Destroy(kv.Value.Go); } } 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(); // 07-15: local FX read the fire direction // 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; const bool isStructure = false; // structures deleted 2026-08-07 (audit purge) 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); } // 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 * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); if (isEnemy) { // MC-3: net-new player-dealt-hit camera punch — scales with the bite size // (saturate(delta / RefDamage)) so a chip reads soft and a heavy connect snaps. // Camera-only hit-stop (NEVER Time.timeScale); keys on the enemy Health-decrease edge. float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage)); PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs); // Hit-flash: a bright body-scaled puff in FeelConfig.HitFlashColor — the staple "I lit it up" read. EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor); if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); // B3: the kill CRUNCH fires at the 0-CROSSING (the moment of death) — the corpse now // lingers ~0.9 s playing its death anim, so the old prune-edge timing would land the // whole package a second late. The prune keeps only a small despawn puff. if (cur <= 0f && prev.Hp > 0f) { Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)p + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale))); PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume); PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); } } } // 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 * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation if (isLocalPlayer) { // Post-impl review wf_9757d214: the server dropped any scheduled damage on death // (PlayerDeathStateSystem zeroes both pendings) — drop the latched connect CUES too, // or the corpse plays a full connect package for a hit that never landed. _pendingConnectTick = 0u; _pendingConeConnectTick = 0u; } } } else if (isEnemy && _scanPrimed && cur >= health.ValueRO.Max - 0.001f) { // Phase 1 spawn-emerge cue: FIRST sighting of a fresh (full-HP) enemy ghost = its spawn // (rooms drip-spawn AFTER the player lands, so the add-edge IS the spawn moment). The primed // guard skips the initial-connect flood; a relevancy re-entry of an undamaged enemy may // rarely re-cue - accepted juice noise, never gameplay. Burst(_hitFx, cfg != null ? cfg.EnemySpawn : null, (Vector3)p + Vector3.up * 0.15f, 8); PlayClip(_telegraphClip, (Vector3)p, 0.25f); } _cache[entity] = new FxCache { Hp = cur, MaxHp = health.ValueRO.Max, Pos = p, IsEnemy = isEnemy, Windup = windup }; } _scanPrimed = true; // Phase 1: everything cached after the first scan; later add-edges are true spawns // 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) { if (c.Hp > 0f) { // Vanished while ALIVE (siege wipe / timeout cull / relevancy) — keep the legacy full read. 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); EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); } else { // B3: corpse despawn after the death window — the kill crunch already fired at the // 0-crossing; the corpse just dissolves. Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.3f, Mathf.Max(1, FeelConfig.DeathBurstCount / 2)); } } _cache.Remove(_stale[i]); } } // 07-21 G4 (SoD's co-op saturation failure is our ceiling): ally-attributed FX degrade as the live // enemy count rises — remote arcs dim, remote shakes shrink. LOCAL-player FX are untouched and enemy // telegraphs NEVER degrade (EnemyDangerTelegraphSystem is structurally separate; guidelines G4). int liveEnemies = 0; foreach (var kv in _cache) if (kv.Value.IsEnemy && kv.Value.Hp > 0f) liveEnemies++; // LIVING only (review wf_9757d214: Dying corpses linger ~1s in the cache) _allyFxScale = _remotePlayersQuery.CalculateEntityCount() == 0 ? 1f // solo: no ally FX exist to budget — never degrade the local player's own feedback (review wf_9757d214) : SaturationMath.AllyScale(liveEnemies, FeelConfig.AllyFxDegradeStart, FeelConfig.AllyFxDegradeFull, FeelConfig.AllyFxFloor); #if UNITY_EDITOR // 07-21 G4: fake-caster saturation stress (DebugOverlay toggle) — synthesizes the ally attack package // at 3 orbiting positions so the 4-caster worst case is testable without 4 connections. Editor-only; // the flag resets on play-enter (CombatStressDebug, the static-presentation-bridge rule). if (CombatStressDebug.StressAllyFx && _localPlayer != Entity.Null && UnityEngine.Time.time >= _nextStressTime) { _nextStressTime = UnityEngine.Time.time + 0.5f; _stressBeat++; for (int fake = 0; fake < 3; fake++) { float ang = _stressBeat * 0.7f + fake * 2.094f; Vector3 fpos = (Vector3)localPos + new Vector3(Mathf.Cos(ang), 0f, Mathf.Sin(ang)) * 3.5f; EmitTinted(_swingFx, fpos + Vector3.up * 0.9f, (int)Mathf.Ceil(8f * _allyFxScale), FeelConfig.RemoteSlashColor * _allyFxScale); PlayClip(_swingClip, fpos, 0.3f * _allyFxScale); } } #endif // LANTERN 4-socket fire feedback: edge-detect each socket's SocketCooldown (raw uint edge, cosmetic // only like dash/melee). A non-Cone Spark -> muzzle flash + zap; a Cone Spark -> the aimed slash-arc // cue (server-only cleave has no projectile). Replaces the single-AbilityCooldown muzzle + cone blocks. if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer) && EntityManager.HasBuffer(_localPlayer) && EntityManager.HasBuffer(_localPlayer) && SystemAPI.TryGetSingleton(out var fireDb) && fireDb.Value.IsCreated) { var scd = EntityManager.GetComponentData(_localPlayer); var socks = EntityManager.GetBuffer(_localPlayer, true); var effs = EntityManager.GetBuffer(_localPlayer, true); ref var fireAdb = ref fireDb.Value.Value; // 07-15: the cue must match the DAMAGE direction — ResolveAim(Aim, facing), the same source the // sim fire sites read; PlayerFacing alone is body-yaw and can be up to 180° off under move-facing. float2 sfdir = new float2(0f, 1f); if (EntityManager.HasComponent(_localPlayer) && EntityManager.HasComponent(_localPlayer)) { sfdir = FacingMath.ResolveAim( EntityManager.GetComponentData(_localPlayer).Aim, EntityManager.GetComponentData(_localPlayer).Direction); } int sn = math.min(SocketId.Count, math.min(socks.Length, effs.Length)); for (int sk = 0; sk < sn; sk++) { uint nf = scd.Get(sk); bool edge = _socketFireInit && nf != 0 && nf != _lastSocketFire[sk]; _lastSocketFire[sk] = nf; if (!edge) continue; byte sid = socks[sk].SparkId; if (sid == 0) continue; bool haveDef = fireAdb.TryGetAbility(sid, out var sdef); // 07-16 review: a blink (Movement archetype) stamps its cooldown row too — it's a dodge, // not a cast; no muzzle/fire cue (mirrors TickWindowMath's Movement skip). if (haveDef && sdef.Archetype == (byte)AbilityArchetype.Movement) continue; bool coneSpark = haveDef && sdef.Archetype == (byte)AbilityArchetype.Cone; if (!coneSpark) { Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8); PlayClip(_fireClip, (Vector3)localPos, 0.5f); continue; } var es = effs[sk]; float coneRange = Mathf.Max(0.1f, es.Range); float coneHalf = Mathf.Clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f); // 07-21 G6 (review wf_98bf1268): the slam's damage now lands at fire+ConeContactTicks — the arc // reveal completes AT that contact (C13) and the connect package is LATCHED to it via the C14 // idiom (knob 0 = legacy immediate). Defaults() fallback matches the release server's timing. var coneTcfg = SystemAPI.TryGetSingleton(out var coneTcv) ? coneTcv : TuningConfig.Defaults(); uint coneContactTicks = (uint)math.max(0f, coneTcfg.ConeContactTicks); float coneLife = Mathf.Max(0.34f, (coneContactTicks / 60f) / 0.6f); TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, false, coneLife); PlayClip(_swingClip, (Vector3)localPos, 0.5f); PrototypeCameraRig.AddShake(0.06f); _pendingConeRange = coneRange; _pendingConeHalf = coneHalf; if (coneContactTicks == 0u) { _pendingConeConnectTick = 0u; EvaluateConeConnect(localPos); // knob 0 = legacy same-tick connect } else { _pendingConeConnectTick = TickUtil.NonZero( TickWindowMath.FireStartRaw(nf, es.CooldownTicks) + coneContactTicks); } } _socketFireInit = true; // 07-21 G6: fire the deferred cone CONNECT when the slam lands (contact tick reached; wrap-safe; // the C14 idiom — latched once at the fire edge, never reconstructed per-frame). if (_pendingConeConnectTick != 0u && SystemAPI.TryGetSingleton(out var coneNt) && coneNt.ServerTick.IsValid && !new NetworkTick(_pendingConeConnectTick).IsNewerThan(coneNt.ServerTick)) { EvaluateConeConnect(localPos); _pendingConeConnectTick = 0u; } } // 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); // 07-15: match the cleave's DAMAGE direction (ResolveAim(Aim, facing) — same as MeleeComboSystem). Vector3 face = Vector3.forward; if (EntityManager.HasComponent(_localPlayer) && EntityManager.HasComponent(_localPlayer)) { var d = FacingMath.ResolveAim( EntityManager.GetComponentData(_localPlayer).Aim, EntityManager.GetComponentData(_localPlayer).Direction); face = new Vector3(d.x, 0f, d.y); } 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); var tcfg = SystemAPI.TryGetSingleton(out var tcv2) ? tcv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback = Defaults(), matching the server sim int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3); bool finisher = step >= comboLen; float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.2f; float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; // Slice-2 deferred reach fix: the SERVER folds per-player StatModifiers into melee range // (class seed +0.8, boons, meta tiers) -- the arc must sweep the REAL reach, not the base. if (EntityManager.HasBuffer(_localPlayer)) slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange, EntityManager.GetBuffer(_localPlayer, true))); if (finisher) slashRange *= tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult // 07-20 G2.4 (review C13): the sweep-reveal (60% of life) completes AT the LIVE contact tick, so // the drawn edge and the damage moment stay one object under knob tuning. uint contactTicks = MeleeTiming.ContactTicks((byte)step, math.max(0f, tcfg.MeleeContactTicks)); float arcLife = Mathf.Max(finisher ? 0.5f : 0.34f, (contactTicks / 60f) / 0.6f); TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, false, arcLife); // 07-20 G2.1/G5 (review C14): the CONNECT package (bite burst/thunk/kick/rumble + the finisher // hold) fires at the CONTACT tick -- not here at the swing edge, where damage doesn't exist yet. _pendingConnectStep = step; if (contactTicks == 0u) { _pendingConnectTick = 0u; EvaluateMeleeConnect(localPos, step, comboLen); // knob 0 = legacy same-tick connect } else { _pendingConnectTick = TickUtil.NonZero(mc.SwingStartTick + contactTicks); } } _lastLocalSwingTick = mc.SwingStartTick; _swingTickInit = true; // 07-20: fire the deferred connect package when the blade lands (contact tick reached; wrap-safe). if (_pendingConnectTick != 0u && SystemAPI.TryGetSingleton(out var meleeNt) && meleeNt.ServerTick.IsValid && !new NetworkTick(_pendingConnectTick).IsNewerThan(meleeNt.ServerTick)) { var ct2 = SystemAPI.TryGetSingleton(out var ctv2) ? ctv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback int cLen = (int)math.clamp((int)ct2.MeleeComboLength, 1, 3); EvaluateMeleeConnect(localPos, _pendingConnectStep, cLen); _pendingConnectTick = 0u; } } // Shoulder-lamp beam (07-16e, operator: "make the lamp actually light up" — LANTERN's light-is- // territory read starts on the suit). A warm STEADY spot (steady = true light) mounted at the kit // lamp's clavicle offset, riding the BODY yaw (PlayerFacing — the lamp is bolted to the suit, so it // sweeps with the body, not the cursor), tilted down onto the seabed ahead. Local player only. if (_localPlayer != Entity.Null && FeelConfig.ShoulderLampIntensity > 0f) { if (_lampLight == null) { var lampGo = new GameObject("~ShoulderLamp"); lampGo.transform.SetParent(_fxRoot, false); _lampLight = lampGo.AddComponent(); _lampLight.type = LightType.Spot; _lampLight.color = new Color(1f, 0.78f, 0.45f); // warm gold — ours, steady _lampLight.shadows = LightShadows.None; _lampLight.spotAngle = 58f; _lampLight.innerSpotAngle = 26f; } _lampLight.intensity = FeelConfig.ShoulderLampIntensity; _lampLight.range = FeelConfig.ShoulderLampRange; float2 lampFace = EntityManager.HasComponent(_localPlayer) ? EntityManager.GetComponentData(_localPlayer).Direction : new float2(0f, 1f); if (math.lengthsq(lampFace) < 1e-6f) lampFace = new float2(0f, 1f); var lampYaw = Quaternion.LookRotation(new Vector3(lampFace.x, 0f, lampFace.y)); _lampLight.transform.SetPositionAndRotation( (Vector3)localPos + lampYaw * new Vector3(0.27f, 0.55f, 0.05f), // the kit lamp's shoulder offset lampYaw * Quaternion.Euler(26f, 0f, 0f)); // down-tilt puts the hotspot ~3m ahead (14° landed ~6m out — too diffuse) } // Personal suit glow (08-07, A0 readability gate). The shoulder lamp above is a FORWARD spot, so it // lights the seabed ahead and never its own wearer — measured at the gate, the diver sat at 0.144 // mean luminance while rocks read 0.269 and flora 0.258, i.e. the protagonist was the second-darkest // thing on screen. Raising KeyWarm would lift the scenery by the same amount and leave the ratio // unchanged; only a light that TRAVELS with the diver fixes figure-ground. Warm and STEADY — the // readability law says warm+steady = ours/true. if (_localPlayer != Entity.Null && FeelConfig.SuitGlowIntensity > 0f) { if (_suitGlow == null) { var glowGo = new GameObject("~SuitGlow"); glowGo.transform.SetParent(_fxRoot, false); _suitGlow = glowGo.AddComponent(); _suitGlow.type = LightType.Point; _suitGlow.color = new Color(1f, 0.82f, 0.55f); _suitGlow.shadows = LightShadows.None; } _suitGlow.intensity = FeelConfig.SuitGlowIntensity; _suitGlow.range = FeelConfig.SuitGlowRange; _suitGlow.transform.position = (Vector3)localPos + new Vector3(0f, 0.9f, 0f); // torso height } else if (_suitGlow != null && _suitGlow.intensity > 0f) { _suitGlow.intensity = 0f; } else if (_lampLight != null && _lampLight.intensity > 0f) { _lampLight.intensity = 0f; } // Bubble exhaust (07-16 gap-list): a periodic trickle from the dome (entity origin ≈ chest; dome // sits ~0.78 up), jittered so it never reads as a metronome. One extra bubble rides each footstep. if (_localPlayer != Entity.Null && FeelConfig.BubbleIntervalSec > 0f && FeelConfig.BubbleBurstCount > 0) { _bubbleTimer -= dt; if (_bubbleTimer <= 0f) { _bubbleTimer = FeelConfig.BubbleIntervalSec * (0.8f + UnityEngine.Random.value * 0.4f); EmitAt(_bubbleFx, (Vector3)localPos + Vector3.up * 0.78f, FeelConfig.BubbleBurstCount); } } // Footsteps (07-15 underwater feel): stride-DISTANCE stepping — accumulate planar travel and step // every FootstepStrideMeters, so cadence tracks the real (drifting) velocity instead of a fixed // timer. Heavier read: deep thud variant + volume jitter + a silt puff at the feet. if (_localPlayer != Entity.Null) { Vector3 lp = (Vector3)localPos; if (_footInit) { float planar = new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude; float sp = dt > 1e-4f ? planar / dt : 0f; _footStepGap -= dt; if (sp >= FeelConfig.FootstepMinSpeed) { _footDistAccum += planar; // 07-16 review: the seconds floor stops a dash/blink from machine-gunning 2-4 thuds in ~0.1s // (the old fixed-interval timer capped this implicitly). if (_footDistAccum >= FeelConfig.FootstepStrideMeters && _footStepGap <= 0f) { _footDistAccum = 0f; _footStepGap = 0.18f; float j = FeelConfig.FootstepJitter; var stepClip = _footstepClips[UnityEngine.Random.Range(0, _footstepClips.Length)]; PlayClip(stepClip, lp, FeelConfig.FootstepVolume * (1f + UnityEngine.Random.Range(-j, j))); if (FeelConfig.FootstepPuffCount > 0) EmitAt(_stepFx, lp + Vector3.down * 0.85f, FeelConfig.FootstepPuffCount); // entity origin = capsule center -> feet if (FeelConfig.BubbleBurstCount > 0) EmitAt(_bubbleFx, lp + Vector3.up * 0.78f, 1); // exertion bubble, loosely step-synced } } else { _footDistAccum = FeelConfig.FootstepStrideMeters * 0.6f; // primed: the first step lands quickly on move-start } } _lastFootPos = lp; _footInit = true; } RumbleUtil.Tick(); // auto-stop any elapsed gamepad rumble pulse UpdateProjectileTrails(cfg); PruneVfx(); AnimateNumbers(dt, cam); UpdateSlash(dt); UpdateRemoteSwings(dt); } // ---- 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; } // Emit a colored particle burst at a position (per-emit startColor) — used for the enemy hit-flash + kill pop // without a dedicated particle system (the unused FeelConfig.HitFlashColor finally lights enemies on a hit). void EmitColored(ParticleSystem ps, Vector3 pos, int count, Color color) { if (ps == null || count <= 0) return; var ep = new ParticleSystem.EmitParams { position = pos, startColor = color }; ps.Emit(ep, count); // Phase 1.5 lighting: every colored burst also throws a short point-light flash (dark-ambient look). DynamicLightSystem.RequestFlash(pos, color, Mathf.Clamp(count / 18f, 0.4f, 1.4f)); } 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; // in-flight cap: unchanged, this bounds live particles too var inst = RentVfx(prefab, pos, rot); if (inst == null) return; _activeVfx.Add(new TimedVfx { Inst = inst, Kill = SystemAPI.Time.ElapsedTime + VfxLifetimeFor(prefab) }); // Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour). DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f); } /// /// Track B: take an instance from the per-prefab pool (or fill a new one) instead of Instantiating. /// Order matters — transform BEFORE the particle restart, because a world-space ParticleSystem would /// otherwise re-show the previous burst's particles at their OLD positions for a frame. /// VfxInstance RentVfx(GameObject prefab, Vector3 pos, Quaternion rot) { VfxInstance inst = null; if (_vfxPool.TryGetValue(prefab, out var stack)) { while (stack.Count > 0) // null-skip: a pooled entry destroyed out from under us is discarded { var cand = stack.Pop(); if (cand != null && cand.Go != null) { inst = cand; break; } } } inst ??= FillVfx(prefab); if (inst == null) return null; var tr = inst.Tr; tr.SetParent(_fxRoot, false); tr.SetPositionAndRotation(pos, rot); tr.localScale = _vfxPrefabScale.TryGetValue(prefab, out var s) ? s : Vector3.one; // never inherit the last rent's scale inst.Go.SetActive(true); for (int i = 0; i < inst.Trails.Length; i++) if (inst.Trails[i] != null) inst.Trails[i].Clear(); // else a streak draws from the previous despawn point for (int i = 0; i < inst.Systems.Length; i++) { var ps = inst.Systems[i]; if (ps == null) continue; ps.Clear(true); ps.Play(true); } inst.Rented = true; return inst; } /// /// Build one pooled instance. Instantiated under an INACTIVE root so Awake/Start never run, which is /// also what makes the DestroyImmediate in StripCosmetic safe: a deferred Destroy would hand out an /// instance still carrying a live Rigidbody + Collider for one frame if it were rented the same frame. /// Component arrays are cached PER INSTANCE — component references are instance-scoped, so caching them /// off the prefab asset would drive the asset instead. /// VfxInstance FillVfx(GameObject prefab) { if (_vfxFillRoot == null) { var fillGo = new GameObject("~VfxPool"); fillGo.transform.SetParent(_fxRoot, false); fillGo.SetActive(false); _vfxFillRoot = fillGo.transform; } var go = Object.Instantiate(prefab, _vfxFillRoot); StripCosmetic(go); var inst = new VfxInstance { Go = go, Tr = go.transform, Systems = go.GetComponentsInChildren(true), Trails = go.GetComponentsInChildren(true), Prefab = prefab, }; for (int i = 0; i < inst.Systems.Length; i++) { // A prefab whose stopAction is Destroy/Disable would silently destroy the POOLED instance when // the effect finishes, draining the pool and pushing dead objects onto the stack. var main = inst.Systems[i].main; main.stopAction = ParticleSystemStopAction.None; } if (!_vfxPrefabScale.ContainsKey(prefab)) _vfxPrefabScale[prefab] = prefab.transform.localScale; if (!_vfxLifetime.ContainsKey(prefab)) _vfxLifetime[prefab] = VfxLifetime(inst.Systems); return inst; } /// /// Park an instance back on its OWN prefab's stack (keyed off the record, never a re-read of VFXConfig — /// swapping a config field mid-play would otherwise file it under the wrong effect). The Rented flag is /// the at-most-once guard: a double return would hand one instance to two callers. /// void ReturnVfx(VfxInstance inst) { if (inst == null || !inst.Rented) return; inst.Rented = false; if (inst.Go == null) return; // destroyed out from under us: drop it rather than pool a dead object for (int i = 0; i < inst.Systems.Length; i++) if (inst.Systems[i] != null) inst.Systems[i].Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); for (int i = 0; i < inst.Trails.Length; i++) if (inst.Trails[i] != null) inst.Trails[i].Clear(); inst.Go.SetActive(false); if (_vfxFillRoot != null) inst.Tr.SetParent(_vfxFillRoot, false); if (!_vfxPool.TryGetValue(inst.Prefab, out var stack)) { stack = new Stack(); _vfxPool[inst.Prefab] = stack; } if (stack.Count >= VfxPerPrefabRetain) { Object.Destroy(inst.Go); return; } // bound the retained set after a burst stack.Push(inst); } double VfxLifetimeFor(GameObject prefab) => _vfxLifetime.TryGetValue(prefab, out var d) ? d : 1.0; void PruneVfx() { double now = SystemAPI.Time.ElapsedTime; for (int i = _activeVfx.Count - 1; i >= 0; i--) { if (now < _activeVfx[i].Kill) continue; ReturnVfx(_activeVfx[i].Inst); // pooled, not destroyed _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: RETURN the orphans rather than destroying them, or the pool's // bookkeeping under-counts and the instances leak out of it. if (_projTrails.Count > 0) { foreach (var kv in _projTrails) ReturnVfx(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.Tr != null) trail.Tr.position = wp; } else { var inst = RentVfx(cfg.ProjectileTrail, wp, Quaternion.identity); if (inst != null) _projTrails[entity] = inst; } } 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++) { ReturnVfx(_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) { // DestroyImmediate, not Destroy: a deferred Destroy is only applied at end-of-frame, so an instance // filled and rented in the SAME frame would still carry a live Rigidbody + Collider — exactly the // self-propelling / secondary-spawn behaviour this strip exists to prevent. Legal here because the // target is a freshly-instantiated runtime instance under an inactive root, never a prefab asset. foreach (var rb in go.GetComponentsInChildren(true)) Object.DestroyImmediate(rb); foreach (var col in go.GetComponentsInChildren(true)) Object.DestroyImmediate(col); // Cosmetic VFX must be particles ONLY. This used to disable by type-name substring ("Projectile" / // "Move"), which let any other authored helper (auto-destroy timers, effect settings, light flicker) // survive — harmless when the object was destroyed after one use, but a pooled instance re-runs // OnEnable on EVERY rent, so a survivor would re-arm each time and could Destroy the pooled object. foreach (var mb in go.GetComponentsInChildren(true)) if (mb != null) mb.enabled = false; } // Real effect duration from the longest ParticleSystem (clamped), so we don't force-kill early or hold a // finished instance out of the pool on a blanket TTL. Takes the per-instance cache so the old // GetComponentsInChildren-per-spawn is gone; the RESULT is per-prefab and memoised in _vfxLifetime. static double VfxLifetime(ParticleSystem[] systems) { float longest = 0f; for (int i = 0; i < systems.Length; i++) { if (systems[i] == null) continue; var main = systems[i].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) ---- const int AlphaSteps = 12; // Track B: fade quantisation for the floating numbers (see AnimateNumbers) class FloatingNumber { public TextMesh Tm; public Transform Tr; public float Age; public float Life; public Vector3 Vel; public Color BaseColor; public bool Active; public int ShownAlphaStep; // Track B: quantised fade step last written to Tm.color (see AnimateNumbers) } 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; float mag = Mathf.Clamp01(amount / Mathf.Max(1f, FeelConfig.HitStopRefDamage)); // big hits read bigger fn.Life = Mathf.Lerp(0.6f, 0.95f, mag); 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.ShownAlphaStep = AlphaSteps; // BaseColor is fully opaque, i.e. the top fade step — keeps AnimateNumbers from re-writing on frame 1 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.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag); 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; // Track B: legacy TextMesh bakes colour into VERTEX colours, so every colour write forces a // text-mesh rebuild — up to 32 rebuilds a frame with a full pool. Quantising the fade to 12 // steps turns ~50 rebuilds per number into 12, with no visible difference over its <1 s life. int step = (int)((1f - fn.Age / fn.Life) * AlphaSteps); if (step != fn.ShownAlphaStep) { fn.ShownAlphaStep = step; var c = fn.BaseColor; c.a = step / (float)AlphaSteps; fn.Tm.color = c; } } } // ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ---- 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; // 07-20 G2.3: the blade-smear ribbon -- a narrow band at blade height tracking the arc's leading edge // (child of the arc GO so the transform follows for free). Geometry via BuildSlashInto's angular-window // mode (review C18: one shared builder, no drift from arc retones). var smearGo = new GameObject("MeleeSmearRibbon"); smearGo.transform.SetParent(go.transform, false); _smearMesh = new Mesh { name = "MeleeSmearRibbon" }; smearGo.AddComponent().sharedMesh = _smearMesh; _smearMr = smearGo.AddComponent(); _smearMat = MakeParticleMaterial(); _smearMat.name = "MeleeSmearRibbon"; _smearMr.sharedMaterial = _smearMat; _smearMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; _smearMr.receiveShadows = false; _smearMr.enabled = false; } // Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space. // `reveal` (0..1) sweeps the arc open from one edge (sweepSign) toward the other so the cleave reads directional. // 07-20 G2.3 extension: innerFrac/y/angularWindowRad let the SAME builder cut the blade-smear ribbon // (a narrow band trailing the leading edge at blade height) -- defaults reproduce the classic ground arc. void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign, float innerFrac = 0.45f, float y = 0f, float angularWindowRad = 0f) { const int seg = ArcSeg; float r1 = Mathf.Max(0.4f, range); float r0 = r1 * innerFrac; float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge float aFull = sweepSign >= 0 ? halfAngle : -halfAngle; // far edge float aEnd = Mathf.Lerp(aStart, aFull, Mathf.Clamp01(reveal)); // current leading edge of the sweep float aBase = aStart; if (angularWindowRad > 0f) // smear mode: only the trailing band behind the leading edge aBase = sweepSign >= 0 ? Mathf.Max(aStart, aEnd - angularWindowRad) : Mathf.Min(aStart, aEnd + angularWindowRad); // UVs and triangles do not depend on ANY argument, so they are built once for the whole system // instead of being regenerated (and re-uploaded) on every call. if (!_arcStaticsBuilt) { for (int i = 0; i <= seg; i++) { _arcUvs[i * 2] = new Vector2(0.5f, 0.5f); _arcUvs[i * 2 + 1] = new Vector2(0.5f, 0.5f); } for (int i = 0; i < seg; i++) { int b = i * 2; _arcTris[i * 6 + 0] = b; _arcTris[i * 6 + 1] = b + 1; _arcTris[i * 6 + 2] = b + 2; _arcTris[i * 6 + 3] = b + 1; _arcTris[i * 6 + 4] = b + 3; _arcTris[i * 6 + 5] = b + 2; } _arcStaticsBuilt = true; } for (int i = 0; i <= seg; i++) { float a = Mathf.Lerp(aBase, aEnd, i / (float)seg); float sx = Mathf.Sin(a), cz = Mathf.Cos(a); _arcVerts[i * 2] = new Vector3(sx * r0, y, cz * r0); _arcVerts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1); float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade) _arcCols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge _arcCols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out } // First fill for THIS mesh — _slashMesh, _smearMesh and every remote arc each hit it once. // Vertices must be uploaded before triangles or index validation fails on an empty mesh. // Afterwards only the two channels that actually change are re-uploaded. if (mesh.vertexCount != _arcVerts.Length) { mesh.Clear(); mesh.vertices = _arcVerts; mesh.colors = _arcCols; mesh.uv = _arcUvs; mesh.triangles = _arcTris; } else { mesh.vertices = _arcVerts; mesh.colors = _arcCols; } mesh.RecalculateBounds(); } // 07-20 G2.1/G5 (review C14): the melee CONNECT package, fired when the blade actually LANDS. Recomputes // aim + reach LIVE at the contact tick (the cast-turn kept steering since the swing edge) over the cached // enemy snapshot; brightens the still-sweeping arc on a bite; the finisher hold is the G5 heavy beat. void EvaluateMeleeConnect(float3 localPos, int step, int comboLen) { if (_localPlayer == Entity.Null || !EntityManager.Exists(_localPlayer)) return; var cfg = VFXConfig.Instance; bool finisher = step >= comboLen; float range = 2.2f, half = 0.9f, finRange = 1.25f; // Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing. var tcfg = SystemAPI.TryGetSingleton(out var mcv) ? mcv : TuningConfig.Defaults(); if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange; if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad; if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult; if (EntityManager.HasBuffer(_localPlayer)) range = math.max(0f, StatMath.Apply(range, StatTarget.MeleeRange, EntityManager.GetBuffer(_localPlayer, true))); if (finisher) range *= finRange; float2 fdir = new float2(0f, 1f); if (EntityManager.HasComponent(_localPlayer) && EntityManager.HasComponent(_localPlayer)) fdir = FacingMath.ResolveAim( EntityManager.GetComponentData(_localPlayer).Aim, EntityManager.GetComponentData(_localPlayer).Direction); bool connected = NearestEnemyInCone(localPos, fdir, range, Mathf.Cos(half), out var nearestHit); // review wf_98bf1268: shared scan if (connected) { Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount); PlayClip(_meleeConnectClip, nearestHit, FeelConfig.MeleeConnectVolume); PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs); if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); if (_slashActive) _slashTint *= 1.4f; // the bite brighten, now AT the bite (UpdateSlash re-reads the tint) } if (finisher) { PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); } } // 07-21 G6 (review wf_98bf1268): ONE nearest-living-enemy-in-cone scan over the FX cache — shared by the // socket-fire cue, the melee connect package and the deferred cone connect (three copies would drift). bool NearestEnemyInCone(float3 pos, float2 dir, float range, float cosHalf, out Vector3 hit) { hit = (Vector3)pos; float best = float.MaxValue; bool found = false; foreach (var kv in _cache) { if (!kv.Value.IsEnemy) continue; if (!MeleeConeMath.InCone(pos, dir, range, cosHalf, kv.Value.Pos)) continue; float d2 = math.distancesq(pos, kv.Value.Pos); if (d2 < best) { best = d2; hit = (Vector3)kv.Value.Pos; found = true; } } return found; } // 07-21 G6: the cone socket's CONNECT package at the CONTACT tick — the EvaluateMeleeConnect mirror for // the SpecialSlam (aim recomputed LIVE at contact; range/half latched at the fire edge from the socket's // folded stats). Fired by the deferred check next to the socket-fire branch (C14 idiom). void EvaluateConeConnect(float3 localPos) { if (_localPlayer == Entity.Null || !EntityManager.Exists(_localPlayer)) return; var cfg = VFXConfig.Instance; float2 fdir = new float2(0f, 1f); if (EntityManager.HasComponent(_localPlayer) && EntityManager.HasComponent(_localPlayer)) fdir = FacingMath.ResolveAim( EntityManager.GetComponentData(_localPlayer).Aim, EntityManager.GetComponentData(_localPlayer).Direction); if (!NearestEnemyInCone(localPos, fdir, _pendingConeRange, Mathf.Cos(_pendingConeHalf), out var hit)) return; Burst(_hitFx, cfg != null ? cfg.Hit : null, hit + Vector3.up * 0.7f, FeelConfig.HitBurstCount); PlayClip(_meleeConnectClip, hit, FeelConfig.MeleeConnectVolume); PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs); if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); if (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing } // Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS // the range telegraph (MC-4 clarity) AND SWEEPS across + ramps per combo step so the swing reads as a // directional, escalating cleave rather than a static flash. void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f) { if (_slashMr == null || _slashMat == null) return; bool finisher = step >= comboLen; _slashRange = range; _slashHalf = halfAngle; _slashSweepSign = (step % 2 == 0) ? -1 : 1; // alternate L->R / R->L per swing -> reads as alternating strikes BuildSlashInto(_slashMesh, range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open 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; // Per-step ramp so the chain visibly builds to the finisher (the steps were byte-identical before). float t = comboLen > 1 ? math.saturate((step - 1) / (float)(comboLen - 1)) : 1f; _slashTint = finisher ? new Color(1.7f, 1.25f, 0.5f) // finisher: lamp-warm accent (07-19 retone) : Color.Lerp(new Color(0.5f, 0.9f, 1.05f), new Color(0.8f, 1.3f, 1.4f), t); // dim bioluminescent teal, brighter per step if (connected) _slashTint *= 1.4f; // brighter arc on a confirmed bite (the immediate "you hit" read) _slashTint *= Mathf.Max(0f, FeelConfig.MeleeArcIntensity); // 07-19 LANTERN retone: live dimmer knob _slashLife = lifeOverride > 0f ? lifeOverride : (finisher ? 0.50f : Mathf.Lerp(0.30f, 0.38f, t)); // 07-20: melee passes a knob-derived life (reveal ends AT contact) _slashAge = 0f; _slashActive = true; _slashMat.color = _slashTint; _slashMr.enabled = true; // 07-19 vibe: a few slow bubbles shed along the cleave path (the murk answers the swing). if (_bubbleFx != null && FeelConfig.MeleeArcBubbles > 0) EmitColored(_bubbleFx, pos + f * (range * 0.55f) + Vector3.up * 0.35f, FeelConfig.MeleeArcBubbles, new Color(0.75f, 0.95f, 1f, 0.8f)); } 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; if (_smearMr != null) _smearMr.enabled = false; return; } // MC-4 clarity: SWEEP the crescent open across the arc over the first ~60% of life (reads as a blade // travelling through the cleave), then hold + fade — instead of popping the whole cone at once. float reveal = Mathf.Clamp01(u / 0.6f); BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign); var c = _slashTint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; _slashMat.color = c; _slashMr.transform.localScale = Vector3.one * (1f + u * 0.06f); // 07-19: subtler drift // 07-20 G2.3: the blade-smear ribbon rides the arc's leading edge at blade height while the sweep runs // (the reach trick: the swept edge reads FROM THE WEAPON, not as a floor decal alone). bool smearShow = _smearMr != null && u < 0.75f; if (_smearMr != null) _smearMr.enabled = smearShow; if (smearShow) { BuildSlashInto(_smearMesh, _slashRange, _slashHalf, reveal, _slashSweepSign, innerFrac: 0.7f, y: 0.95f, angularWindowRad: 0.25f); // thin band = streak, not a pane var sc = _slashTint * 0.8f; sc.a = (u < 0.6f ? 0.7f : 0.7f * (1f - (u - 0.6f) / 0.15f)); _smearMat.color = sc; } } // Remote teammates' melee cleave arcs (deferred-items pass, co-op readability): the local player's swing // renders via _slashMr; here each REMOTE player (interpolated, GhostOwnerIsLocal DISABLED) gets a pooled // slash arc edge-detected from its replicated MeleeCombo.SwingStartTick + PlayerFacing. Observe-only client // presentation; no sim, no new [GhostField]. Anchored to the moving teammate while it sweeps open + fades. // NOTE (07-15 SoD facing, accepted asymmetry): remote arcs deliberately do NOT use FacingMath.ResolveAim — // PlayerInput.Aim is owner-only (never replicated to non-owners), so body-yaw PlayerFacing is the closest // replicated proxy; the cast-turn converges facing onto the aim within a few ticks. Do not "fix" this to Aim. void UpdateRemoteSwings(float dt) { if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return; // Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing // (the dev TuningConfig singleton is editor-only; default(TuningConfig) reads knob 0 = legacy cues). var tcfg = SystemAPI.TryGetSingleton(out var rtcv) ? rtcv : TuningConfig.Defaults(); int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3); float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; float finisherMult = tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult float remoteContactKnob = math.max(0f, tcfg.MeleeContactTicks); // 07-20 G2.4: remote sweeps track contact too _remoteSeen.Clear(); foreach (var (xf, facing, mc, entity) in SystemAPI.Query, RefRO, RefRO>() .WithAll().WithDisabled().WithEntityAccess()) { _remoteSeen.Add(entity); if (!_remoteSlashes.TryGetValue(entity, out var rs)) { rs = CreateRemoteSlash(); _remoteSlashes[entity] = rs; } uint swing = mc.ValueRO.SwingStartTick; if (rs.Init && swing != 0 && swing != rs.LastSwingTick) { int step = math.max(1, (int)mc.ValueRO.Step); bool finisher = step >= comboLen; float rRange = baseRange; // same reach fix for teammates' arcs (their buffer is OwnerSendType.All) if (EntityManager.HasBuffer(entity)) rRange = math.max(0f, StatMath.Apply(baseRange, StatTarget.MeleeRange, EntityManager.GetBuffer(entity, true))); rs.Range = finisher ? rRange * finisherMult : rRange; rs.Half = baseHalf; rs.SweepSign = (step % 2 == 0) ? -1 : 1; rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f) * _allyFxScale; // 07-21 G4: ally FX dim under saturation (brightness only — Life stays contact-honest, C13) rs.Life = Mathf.Max(finisher ? 0.50f : 0.34f, (MeleeTiming.ContactTicks((byte)step, remoteContactKnob) / 60f) / 0.6f); // 07-20: reveal ends AT contact (knob-aware, review C13) rs.Age = 0f; rs.Active = true; BuildSlashInto(rs.Mesh, rs.Range, rs.Half, 0f, rs.SweepSign); rs.Mat.color = rs.Tint; rs.Mr.enabled = true; } rs.LastSwingTick = swing; rs.Init = true; if (rs.Active) { rs.Age += dt; float u = rs.Age / Mathf.Max(1e-4f, rs.Life); if (u >= 1f) { rs.Active = false; rs.Mr.enabled = false; } else { float2 fdir = facing.ValueRO.Direction; Vector3 f = math.lengthsq(fdir) > 1e-6f ? new Vector3(fdir.x, 0f, fdir.y).normalized : Vector3.forward; var tr = rs.Mr.transform; tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.12f; tr.rotation = Quaternion.LookRotation(f, Vector3.up); float reveal = Mathf.Clamp01(u / 0.6f); BuildSlashInto(rs.Mesh, rs.Range, rs.Half, reveal, rs.SweepSign); var c = rs.Tint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; rs.Mat.color = c; tr.localScale = Vector3.one * (1f + u * 0.06f); } } } if (_remoteSlashes.Count != _remoteSeen.Count) { _remoteStale.Clear(); foreach (var kv in _remoteSlashes) if (!_remoteSeen.Contains(kv.Key)) _remoteStale.Add(kv.Key); for (int i = 0; i < _remoteStale.Count; i++) { var rs = _remoteSlashes[_remoteStale[i]]; if (rs.Mesh != null) Object.Destroy(rs.Mesh); if (rs.Mat != null) Object.Destroy(rs.Mat); if (rs.Go != null) Object.Destroy(rs.Go); _remoteSlashes.Remove(_remoteStale[i]); } } } RemoteSlash CreateRemoteSlash() { var go = new GameObject("RemoteSlashArc"); go.transform.SetParent(_fxRoot, false); var mesh = new Mesh { name = "RemoteSlashArc" }; go.AddComponent().sharedMesh = mesh; var mr = go.AddComponent(); var mat = MakeParticleMaterial(); mat.name = "RemoteSlashArc"; mr.sharedMaterial = mat; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.enabled = false; return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false }; } } }