Files
Project-M/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs
T
kronic 501c52d809 Art: A0 readability pass — murk plane, seabed albedo, and a suit glow
Operator at the A0 gate: "a little too dark to play, not very readable yet."
Measured rather than guessed, and the diagnosis was NOT "add light":

  seabed _BaseColor (0.08,0.12,0.15) — the floor was painted near-black, so it
    reflected ~10% of anything and could never read as a surface
  fog colour (0.02,0.10,0.12) — distance faded to VOID rather than to murk;
    real water scatters, so far things should get lighter and lower-contrast
  ColorAdjustments contrast +10 on top of ACES — the low end was crushed twice
  Vignette 0.28 — ate the periphery, which is where threats arrive from

Murk pass (operator picked "murk + closer camera"):
  BaseFogColor/ExpeditionFogColor -> (0.06,0.20,0.25)   [WorldAtmosphereConfig
    drives RenderSettings every frame, so the CONFIG is the real knob]
  M_Staging_Seabed albedo -> (0.18,0.26,0.30)
  contrast 10 -> 4, vignette 0.28 -> 0.16
  camera Distance 17 -> 14

That fixed the void but INVERTED figure-ground, which the numbers caught:

  player 0.144 | rock 0.269 | flora 0.258 | warm haze 0.368

The protagonist was the second-darkest thing on screen. The shoulder lamp is a
forward-facing spot tilted 26 deg down — it lights the seabed ahead and never
its own wearer — so the diver had no personal light at all. Raising KeyWarm
would have lifted the scenery by the same amount and left the ratio unchanged;
only a light that TRAVELS with the diver changes it. Added ~SuitGlow: a warm,
steady point light at torso height (FeelConfig.SuitGlowIntensity 5 /
SuitGlowRange 4.5, live-tunable, 0 = off). "Light is territory", made literal.

  player 0.144 -> 0.320, now above rock 0.282 and flora 0.250
  figure-to-murk contrast 1.32x -> 2.94x, with scene brightness unchanged

304/304 EditMode green. Awaiting the operator's eyes on the result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 16:58:52 -07:00

1206 lines
72 KiB
C#

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
{
/// <summary>
/// Client-only combat JUICE. A managed presentation system (SystemBase, main thread, NO Burst) in the
/// <see cref="PresentationSystemGroup"/> that REACTS to replicated state — it never runs simulation. Each frame
/// it edge-detects every damageable ghost's replicated <see cref="Health"/>: 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.
/// <para>
/// VFX prefer authored GabrielAguiar Shuriken prefabs supplied by <see cref="VFXConfig"/> (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
/// (<see cref="StripCosmetic"/>) — GA "projectile" prefabs ship a Rigidbody + collider + mover that would
/// otherwise self-propel and spawn secondary effects. SFX remain procedural.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
[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<Entity, FxCache> _cache = new();
bool _scanPrimed; // Phase 1: first health-scan completed -> new cache entries are true spawns, not the connect flood
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
readonly List<FloatingNumber> _numbers = new();
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
readonly List<TimedVfx> _activeVfx = new();
readonly Dictionary<Entity, GameObject> _projTrails = new();
readonly HashSet<Entity> _projSeen = new();
readonly List<Entity> _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)
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
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
// 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<Entity, RemoteSlash> _remoteSlashes = new();
readonly HashSet<Entity> _remoteSeen = new();
readonly List<Entity> _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)
struct TimedVfx { public GameObject Go; public double Kill; }
protected override void OnCreate()
{
// 07-21 G4 (review wf_9757d214): remote-player census — solo (0 remotes) forces _allyFxScale = 1.
_remotePlayersQuery = SystemAPI.QueryBuilder().WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().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()
{
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);
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<Health>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
EntityManager.CompleteDependencyBeforeRO<DashState>();
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
EntityManager.CompleteDependencyBeforeRO<PlayerInput>(); // 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<RefRO<LocalTransform>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().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<DashState>(_localPlayer)
&& SystemAPI.TryGetSingleton<NetworkTime>(out var dashNetTime) && dashNetTime.ServerTick.IsValid)
{
var localDash = EntityManager.GetComponentData<DashState>(_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<Health>, RefRO<LocalTransform>>().WithEntityAccess())
{
_seen.Add(entity);
float cur = health.ValueRO.Current;
float3 p = xf.ValueRO.Position;
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(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 && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
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);
TryHold(); // C4: kill crunch (throttled)
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);
TryHold(); // C4: kill crunch (throttled)
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<SocketCooldown>(_localPlayer)
&& EntityManager.HasBuffer<AbilitySocket>(_localPlayer)
&& EntityManager.HasBuffer<EffectiveSocketStats>(_localPlayer)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var fireDb) && fireDb.Value.IsCreated)
{
var scd = EntityManager.GetComponentData<SocketCooldown>(_localPlayer);
var socks = EntityManager.GetBuffer<AbilitySocket>(_localPlayer, true);
var effs = EntityManager.GetBuffer<EffectiveSocketStats>(_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<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
{
sfdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_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<TuningConfig>(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<NetworkTime>(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<DashCooldown>(_localPlayer))
{
uint nextDash = EntityManager.GetComponentData<DashCooldown>(_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<MeleeCombo>(_localPlayer))
{
var mc = EntityManager.GetComponentData<MeleeCombo>(_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<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
{
var d = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_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<TuningConfig>(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<StatModifier>(_localPlayer))
slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_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<NetworkTime>(out var meleeNt) && meleeNt.ServerTick.IsValid
&& !new NetworkTick(_pendingConnectTick).IsNewerThan(meleeNt.ServerTick))
{
var ct2 = SystemAPI.TryGetSingleton<TuningConfig>(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<Light>();
_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<PlayerFacing>(_localPlayer)
? EntityManager.GetComponentData<PlayerFacing>(_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<Light>();
_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; // 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<ParticleSystem>();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) });
// Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour).
DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f);
}
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<RefRO<LocalTransform>>().WithAll<Projectile>().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<ParticleSystem>();
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<Rigidbody>(true)) Object.Destroy(rb);
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(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<ParticleSystem>(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<TextMesh>();
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.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;
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) ----
void BuildSlash()
{
var go = new GameObject("MeleeSlashArc");
go.transform.SetParent(_fxRoot, false);
_slashMesh = new Mesh { name = "MeleeSlashArc" };
var mf = go.AddComponent<MeshFilter>();
mf.sharedMesh = _slashMesh;
_slashMr = go.AddComponent<MeshRenderer>();
_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<MeshFilter>().sharedMesh = _smearMesh;
_smearMr = smearGo.AddComponent<MeshRenderer>();
_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 = 16;
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);
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(aBase, aEnd, i / (float)seg);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
verts[i * 2] = new Vector3(sx * r0, y, cz * r0);
verts[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)
cols[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
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;
}
mesh.Clear();
mesh.vertices = verts;
mesh.colors = cols;
mesh.uv = uvs;
mesh.triangles = tris;
mesh.RecalculateBounds();
}
// 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 now SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash.
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
void TryHold(int frames = 0) // 07-20 G5 (review C17): ONE hold path, per-verb frames (0 = the light default)
{
if (!FeelConfig.HitStopFreezeEnabled) return;
double now = SystemAPI.Time.ElapsedTime;
if (now - _lastHoldTime < 0.22) return;
_lastHoldTime = now;
PrototypeCameraRig.Hold(frames > 0 ? frames : FeelConfig.HitStopMaxFrames);
}
// 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<TuningConfig>(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<StatModifier>(_localPlayer))
range = math.max(0f, StatMath.Apply(range, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
if (finisher) range *= finRange;
float2 fdir = new float2(0f, 1f);
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
fdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_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);
TryHold(FeelConfig.FinisherHoldFrames); // G5: the heavy payoff beat, at contact
}
}
// 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<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
fdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_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
}
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<TuningConfig>(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<LocalTransform>, RefRO<PlayerFacing>, RefRO<MeleeCombo>>()
.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().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<StatModifier>(entity))
rRange = math.max(0f, StatMath.Apply(baseRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(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<MeshFilter>().sharedMesh = mesh;
var mr = go.AddComponent<MeshRenderer>();
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 };
}
}
}