diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index b50f86319..d8d7bdef7 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -79,6 +79,21 @@ namespace ProjectM.Client Material _barBgMat, _barFillMat; // Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone. readonly Dictionary _pulseStart = new(); + // Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup). + readonly Dictionary _strikeBeeped = new(); + + // 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; @@ -152,6 +167,14 @@ namespace ProjectM.Client if (kv.Value != null) { var mf = kv.Value.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); } foreach (var kv in _healthBars) if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo); + 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() @@ -395,7 +418,8 @@ namespace ProjectM.Client PruneVfx(); AnimateNumbers(dt, cam); UpdateSlash(dt); - UpdateEnemyDanger(); + UpdateEnemyDanger(localPos); + UpdateRemoteSwings(dt); UpdateHealthBars(dt, cam, localPos); } @@ -691,7 +715,7 @@ namespace ProjectM.Client // 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. - void BuildSlashMesh(float range, float halfAngle, float reveal, int sweepSign) + void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign) { const int seg = 16; float r1 = Mathf.Max(0.4f, range); @@ -721,12 +745,12 @@ namespace ProjectM.Client tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2; tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2; } - _slashMesh.Clear(); - _slashMesh.vertices = verts; - _slashMesh.colors = cols; - _slashMesh.uv = uvs; - _slashMesh.triangles = tris; - _slashMesh.RecalculateBounds(); + 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 @@ -738,7 +762,7 @@ namespace ProjectM.Client 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 - BuildSlashMesh(range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open + 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; @@ -766,15 +790,105 @@ namespace ProjectM.Client // 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); - BuildSlashMesh(_slashRange, _slashHalf, reveal, _slashSweepSign); + 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.10f); } + // 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. + void UpdateRemoteSwings(float dt) + { + if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return; + int comboLen = SystemAPI.TryGetSingleton(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3; + float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; + float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; + float finisherMult = tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f; + + _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; + rs.Range = finisher ? baseRange * finisherMult : baseRange; + rs.Half = baseHalf; + rs.SweepSign = (step % 2 == 0) ? -1 : 1; + rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f); + rs.Life = finisher ? 0.26f : 0.18f; + 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.10f); + } + } + } + + 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 }; + } + // Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger // cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE + // WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame. - void UpdateEnemyDanger() + void UpdateEnemyDanger(float3 localPos) { if (_fxRoot == null || _dangerMat == null) return; Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; @@ -804,6 +918,20 @@ namespace ProjectM.Client // any windup length (fixes the Charger plateauing early under the old hard-coded 22). float windupDur = math.max(1f, tele.ValueRO.WindupTicks); intensity = math.saturate(1f - remaining / windupDur); + + // Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to + // enemies near the local player (the danger cone already proves it's winding up to strike). + if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks + && (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until)) + { + float3 ep = xf.ValueRO.Position; + if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq) + { + PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume); + _strikeBeeped[entity] = until; + } + } + } // Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost). @@ -861,6 +989,8 @@ namespace ProjectM.Client if (g != null) { var mf = g.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); } _dangerZones.Remove(_dangerStale[i]); _pulseStart.Remove(_dangerStale[i]); + _strikeBeeped.Remove(_dangerStale[i]); + } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs new file mode 100644 index 000000000..7e1467e72 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Rendering; // URPMaterialPropertyBaseColor, MaterialMeshInfo (Entities Graphics) +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-only TRUE BODY hit-flash for enemies (the focused follow-up DR-041 deferred as "its own ShaderGraph + /// slice"). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds the gameplay components + /// (, ) while the visible meshes are LinkedEntityGroup CHILD render + /// entities (each with a + the AnimatedLitShader material, whose _BaseColor + /// is white). 's colored particle puff is the asset-free stand-in; THIS system + /// flashes the actual body by driving the built-in Entities-Graphics per-instance override + /// (a registered [MaterialProperty("_BaseColor")]) on those + /// render children: on an enemy -decrease edge it lerps _BaseColor toward + /// and decays back to white. No new component type, NO ShaderGraph edit, + /// no server work, no [GhostField] — observe-only client presentation, so it is rollback-irrelevant. + /// The render children gain lazily (added once per enemy via ECB); + /// white is the baked rest value (every enemy uses the same AnimatedLitShader/Synty-atlas convention), so + /// Flash==0 restores the untouched look and the override is never visible at rest. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class EnemyHitFlashSystem : SystemBase + { + class FlashEntry + { + public readonly List RenderKids = new(); + public float LastHp; + public float Flash; // 1 on a fresh hit, decays to 0 + public bool Settled; // wrote the final white frame after a flash ended (skips per-frame writes at rest) + } + + readonly Dictionary _tracked = new(); + readonly HashSet _seen = new(); + readonly List _stale = new(); + + static readonly float4 White = new float4(1f, 1f, 1f, 1f); + + protected override void OnUpdate() + { + if (!FeelConfig.BodyFlashEnabled) { RestoreAllToRest(); return; } // settle any mid-flash body back to white before bailing + float dt = SystemAPI.Time.DeltaTime; + EntityManager.CompleteDependencyBeforeRO(); + + // Pass 1: discover enemies, ensure each is tracked + its render children carry the override component. + _seen.Clear(); + var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); + foreach (var (health, entity) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + { + _seen.Add(entity); + if (_tracked.ContainsKey(entity)) continue; + + var entry = new FlashEntry { LastHp = health.ValueRO.Current, Flash = 0f, Settled = true }; + var leg = EntityManager.GetBuffer(entity); + for (int i = 0; i < leg.Length; i++) + { + var c = leg[i].Value; + if (!EntityManager.Exists(c) || !EntityManager.HasComponent(c)) continue; + entry.RenderKids.Add(c); + if (!EntityManager.HasComponent(c)) + ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White }); + } + // Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame). + if (entry.RenderKids.Count > 0) _tracked[entity] = entry; + } + ecb.Playback(EntityManager); + ecb.Dispose(); + + // Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children. + var bc = FeelConfig.BodyFlashColor; + float4 peak = new float4(bc.r, bc.g, bc.b, bc.a); + float decay = dt / math.max(0.01f, FeelConfig.BodyFlashDurationSec); + foreach (var kv in _tracked) + { + var entity = kv.Key; + var entry = kv.Value; + if (!_seen.Contains(entity)) continue; // despawned -> pruned below + + float cur = EntityManager.GetComponentData(entity).Current; + if (cur < entry.LastHp - 0.001f) { entry.Flash = 1f; entry.Settled = false; } + entry.LastHp = cur; + + if (entry.Flash <= 0f) + { + if (!entry.Settled) { WriteColor(entry, White); entry.Settled = true; } // settle to baked white once + continue; + } + + entry.Flash = math.max(0f, entry.Flash - decay); + WriteColor(entry, math.lerp(White, peak, entry.Flash)); + } + + // Prune despawned enemies (their render children die with them; just drop the managed entry). + if (_tracked.Count != _seen.Count) + { + _stale.Clear(); + foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); + for (int i = 0; i < _stale.Count; i++) _tracked.Remove(_stale[i]); + } + } + + // Settle every tracked enemy's body back to its baked white rest color and stop tracking — invoked when + // BodyFlashEnabled is toggled OFF mid-flash so no body is left frozen at an overdriven tint (re-tracked on re-enable). + void RestoreAllToRest() + { + foreach (var kv in _tracked) WriteColor(kv.Value, White); + _tracked.Clear(); + } + + void WriteColor(FlashEntry entry, float4 col) + { + for (int i = 0; i < entry.RenderKids.Count; i++) + { + var c = entry.RenderKids[i]; + if (EntityManager.Exists(c) && EntityManager.HasComponent(c)) + EntityManager.SetComponentData(c, new URPMaterialPropertyBaseColor { Value = col }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs.meta new file mode 100644 index 000000000..5c135fea8 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 03cbda5f0bedbe14fbf680d92db148fe \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs index fb36d18bd..4b7624a96 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs @@ -135,6 +135,27 @@ namespace ProjectM.Client /// Seconds a rumble pulse lasts before it auto-stops. public static float RumbleDurationSec; + // ---- Deferred-items pass (2026-06): true body hit-flash, remote co-op swings, near-impact strike beep ---- + /// Master gate for the enemy material BODY hit-flash (drives URPMaterialPropertyBaseColor on render children). + public static bool BodyFlashEnabled; + /// Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back). + public static Color BodyFlashColor; + /// Seconds the body flash decays from peak back to the baked white base. + public static float BodyFlashDurationSec; + /// Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability). + public static bool RemoteSwingEnabled; + /// Tint of a remote teammate's slash arc (cooler/friendlier than the local warm arc). + public static Color RemoteSlashColor; + /// Master gate for the near-impact \"dodge NOW\" strike beep on a winding-up enemy. + public static bool StrikeBeepEnabled; + /// Volume of the near-impact strike beep. + public static float StrikeBeepVolume; + /// Ticks before the strike lands that the beep fires (the dodge-reaction lead). + public static int StrikeBeepLeadTicks; + /// Squared world-distance from the local player beyond which the strike beep is suppressed (avoids a distant cacophony). + public static float StrikeBeepMaxDistSq; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] public static void ResetDefaults() { @@ -201,6 +222,18 @@ namespace ProjectM.Client RumbleKill = 0.45f; RumbleHeavy = 0.6f; RumbleDurationSec = 0.12f; + + // Deferred-items pass (2026-06) + BodyFlashEnabled = true; + BodyFlashColor = new Color(3.2f, 2.8f, 2.2f, 1f); // hot near-white overdrive (multiplies the Synty atlas base map) + BodyFlashDurationSec = 0.16f; + RemoteSwingEnabled = true; + RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc + StrikeBeepEnabled = true; + StrikeBeepVolume = 0.40f; + StrikeBeepLeadTicks = 8; + StrikeBeepMaxDistSq = 225f; // 15 m + } } } diff --git a/Docs/Vault/07_Sessions/2026/2026-06-27_Deferred_Feel_Items_BodyFlash_RemoteSwings_StrikeBeep.md b/Docs/Vault/07_Sessions/2026/2026-06-27_Deferred_Feel_Items_BodyFlash_RemoteSwings_StrikeBeep.md new file mode 100644 index 000000000..9a666fc43 --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-06-27_Deferred_Feel_Items_BodyFlash_RemoteSwings_StrikeBeep.md @@ -0,0 +1,35 @@ +--- +title: Deferred Combat-Feel Items — Body Hit-Flash + Remote Co-op Swings + Strike Beep — Build +date: 2026-06-27 +tags: [session, combat, juice, presentation, rukhanka, entities-graphics, netcode, co-op] +permalink: gamevault/07-sessions/2026/2026-06-27-deferred-feel-items +--- + +# Deferred combat-feel items — Build session + +Cleared the three focused follow-ups that the combat-overhaul pass had explicitly deferred (named in commit `c3b53cef2` + the [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] "needs its own ShaderGraph slice" note). All three are **client-only, observe-only presentation** (PresentationSystemGroup; no sim mutation, no `[GhostField]`, no server work, rollback-irrelevant). + +## What shipped +- **Item 1 — TRUE body hit-flash (resolves the DR-041 deferral).** New `EnemyHitFlashSystem` (client `SystemBase`, PresentationSystemGroup). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds gameplay components (`Health`, `EnemyTag`); the visible meshes are `LinkedEntityGroup` CHILD render entities (each with `Unity.Rendering.MaterialMeshInfo` + the `Shader Graphs/AnimatedLitShader` material, `_BaseColor` baked white). The system drives the **built-in Entities-Graphics per-instance override `Unity.Rendering.URPMaterialPropertyBaseColor`** (`[MaterialProperty("_BaseColor")]`) on those children: a `Health`-decrease edge lerps `_BaseColor` toward `FeelConfig.BodyFlashColor` (HDR near-white) and decays back to white. **No ShaderGraph edit, no new component type** — the original deferral assumed a custom `_Flash*` graph property was required; the reusable shortcut is the stock EG `URP*MaterialProperty*` components, which the deformation shader already honors. +- **Item 2 — co-op REMOTE swing arcs.** `CombatFeedbackSystem` now renders each remote teammate's melee cleave: a per-remote-player pooled `RemoteSlash` (Mesh+Material+GO), edge-detected from the **replicated `MeleeCombo.SwingStartTick`** on interpolated teammates (`.WithAll().WithDisabled()`), reusing the refactored `BuildSlashInto(mesh, …)` sweep. The local player keeps its dedicated `_slashMr`. (`MeleeCombo` replicates to non-owners — `PlayerAnimationDriveSystem.RemoteDriveJob` already relies on this for teammate attack anim.) +- **Item 3 — near-impact strike beep.** Folded into the existing enemy danger-cone loop (which already computes `remaining` ticks to impact): a once-per-windup `_strikeBeepClip` "dodge NOW" cue at `StrikeBeepLeadTicks` (default 8 ≈ 130 ms) before the strike lands, distance-gated to the local player. +- 9 new `FeelConfig` knobs (+ `ResetDefaults`) covering all three. + +## How it went (verify ladder) +- Drove the whole thing off an **empirical Play probe** of a live Husk: confirmed render entities are LEG children (not the root), shader = `AnimatedLitShader`, `_BaseColor` = white, and the children do **not** ship `URPMaterialPropertyBaseColor` until added. +- **Override-works proof:** the unfocused editor kept disposing the netcode worlds mid-Play (known hazard) and server-spawned test husks were culled (spawned outside the director's bookkeeping), so I proved the mechanism on the **local player** (same `AnimatedLitShader`, persistent, centered): tinted its render children via the override → captured the Game view → **body rendered red**. Same shader ⇒ the enemy flash works. Separately confirmed `EnemyHitFlashSystem` attaches the override to all 4 enemy render children at runtime. +- **390/390 EditMode**, clean compile, zero Play exceptions with all three paths live. + +## Post-impl adversarial review (`wf_8a998c6c-af9`) +3 lenses (ECS/Entities-Graphics correctness · lifecycle/leaks/rollback · netcode-read edge cases). **No critical/major.** Fixed 4 real minors: +- **[FIXED] Strike beep could fire spuriously at base origin** — the "no local player ⇒ `localPos`=`float3.zero` ⇒ distance gate suppresses" assumption is FALSE: origin IS the base, so base-siege enemies within 15 m would beep before the local player ghost resolves (co-op join / save-load mid-siege). Gated the beep on `_localPlayer != Entity.Null`. +- **[FIXED] `BodyFlashEnabled` toggled off mid-flash froze an enemy tinted** (reachable via the FeelConfig tuning toggle). Added `RestoreAllToRest()` on the disable edge (settle white + clear; re-tracked on re-enable). +- **[FIXED] `RenderKids` captured once with no recovery** — don't finalize tracking until render children exist (retry next frame if Rukhanka child setup lags ghost instantiation). +- **[FIXED] `OnDestroy` symmetry** — also destroy the pooled remote-slash `GO`. +- **[NOTED, no change]** hardcoded white-restore (moot — every enemy uses the Synty-atlas white-`_BaseColor` convention; documented in the system); runtime `AddComponent` fragmentation (bounded, once/child); committed-Charger lunge relies on the cone not the beep (intended). + +## Gotchas worth remembering +- **Material-driven body flash on Rukhanka/EG = drive a stock `URP*MaterialProperty*` component on the render-entity CHILDREN, not a custom ShaderGraph property.** The render entities are `LinkedEntityGroup` children with `MaterialMeshInfo` (the root has none); `_BaseColor` bakes white, so flash-toward-color / decay-to-white needs no per-material rest capture. Add the override at runtime (once/child) from a client observe-only system; settle to white at rest so the override is invisible when idle. +- **To prove an EG per-instance override is honored by a deformation shader without a stable enemy:** tint the **local player** (same material) and screenshot — the player is persistent + centered, unlike server-spawned test enemies which get culled and unlike the worlds which the unfocused editor disposes. + +See [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] (item-1 origin) · [[DR-022_Animation_Pipeline_Rukhanka_Synty]] (render-entity structure) · [[DR-038_Slice1_Combat_Readability_HUD_Declutter]] (danger-cone the beep folds into).