Deferred feel items: true enemy body hit-flash, co-op remote swing arcs, near-impact strike beep

Clears the three follow-ups deferred by the combat-overhaul pass (c3b53cef2) + the
DR-041 "needs its own ShaderGraph slice" note. All client-only, observe-only presentation
(PresentationSystemGroup; no sim mutation, no [GhostField], no server work).

- Item 1: EnemyHitFlashSystem flashes the ACTUAL enemy body by driving the stock
  Entities-Graphics URPMaterialPropertyBaseColor override on the Rukhanka render-entity
  LEG children (root has no MaterialMeshInfo) -- NO ShaderGraph edit, no new component type.
  Lerp white->BodyFlashColor on a Health-decrease edge, decay back to white. Verified on
  screen (the AnimatedLitShader honors the per-instance _BaseColor override).
- Item 2: per-remote-player slash-arc pool in CombatFeedbackSystem, edge-detected from the
  replicated MeleeCombo on interpolated teammates (.WithDisabled<GhostOwnerIsLocal>());
  BuildSlashMesh -> BuildSlashInto(mesh,...) refactor; local player keeps _slashMr.
- Item 3: once-per-windup near-impact strike beep folded into the danger-cone loop, gated
  to a resolved local player.
- 9 new FeelConfig knobs (+ ResetDefaults).

390/390 EditMode, clean compile, zero Play exceptions. 3-lens adversarial review
(wf_8a998c6c-af9) -- no critical/major; fixed 4 minors: spurious beep at base origin before
the local player resolves, frozen tint if BodyFlashEnabled toggles off mid-flash, render-child
capture with no recovery, OnDestroy GO symmetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 10:51:58 -07:00
parent c3b53cef28
commit 8f96b520d6
5 changed files with 336 additions and 11 deletions
@@ -79,6 +79,21 @@ namespace ProjectM.Client
Material _barBgMat, _barFillMat; Material _barBgMat, _barFillMat;
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone. // Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
readonly Dictionary<Entity, float> _pulseStart = new(); readonly Dictionary<Entity, float> _pulseStart = new();
// Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup).
readonly Dictionary<Entity, uint> _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<Entity, RemoteSlash> _remoteSlashes = new();
readonly HashSet<Entity> _remoteSeen = new();
readonly List<Entity> _remoteStale = new();
AudioClip _hitClip; AudioClip _hitClip;
AudioClip _deathClip; AudioClip _deathClip;
@@ -152,6 +167,14 @@ namespace ProjectM.Client
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); } if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
foreach (var kv in _healthBars) foreach (var kv in _healthBars)
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo); 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() protected override void OnUpdate()
@@ -395,7 +418,8 @@ namespace ProjectM.Client
PruneVfx(); PruneVfx();
AnimateNumbers(dt, cam); AnimateNumbers(dt, cam);
UpdateSlash(dt); UpdateSlash(dt);
UpdateEnemyDanger(); UpdateEnemyDanger(localPos);
UpdateRemoteSwings(dt);
UpdateHealthBars(dt, cam, localPos); 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. // 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. // `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; const int seg = 16;
float r1 = Mathf.Max(0.4f, range); 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 + 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; tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
} }
_slashMesh.Clear(); mesh.Clear();
_slashMesh.vertices = verts; mesh.vertices = verts;
_slashMesh.colors = cols; mesh.colors = cols;
_slashMesh.uv = uvs; mesh.uv = uvs;
_slashMesh.triangles = tris; mesh.triangles = tris;
_slashMesh.RecalculateBounds(); mesh.RecalculateBounds();
} }
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS // 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; bool finisher = step >= comboLen;
_slashRange = range; _slashHalf = halfAngle; _slashRange = range; _slashHalf = halfAngle;
_slashSweepSign = (step % 2 == 0) ? -1 : 1; // alternate L->R / R->L per swing -> reads as alternating strikes _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; Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
var tr = _slashMr.transform; var tr = _slashMr.transform;
tr.position = pos + Vector3.up * 0.12f; 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 // 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. // travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
float reveal = Mathf.Clamp01(u / 0.6f); 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; 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); _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<TuningConfig>(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<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;
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<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 };
}
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger // 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 + // 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. // 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; if (_fxRoot == null || _dangerMat == null) return;
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default; Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(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). // any windup length (fixes the Charger plateauing early under the old hard-coded 22).
float windupDur = math.max(1f, tele.ValueRO.WindupTicks); float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
intensity = math.saturate(1f - remaining / windupDur); 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). // 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<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); } if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
_dangerZones.Remove(_dangerStale[i]); _dangerZones.Remove(_dangerStale[i]);
_pulseStart.Remove(_dangerStale[i]); _pulseStart.Remove(_dangerStale[i]);
_strikeBeeped.Remove(_dangerStale[i]);
} }
} }
} }
@@ -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
{
/// <summary>
/// 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
/// (<see cref="Health"/>, <see cref="EnemyTag"/>) while the visible meshes are LinkedEntityGroup CHILD render
/// entities (each with a <see cref="MaterialMeshInfo"/> + the AnimatedLitShader material, whose <c>_BaseColor</c>
/// is white). <see cref="CombatFeedbackSystem"/>'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
/// <see cref="URPMaterialPropertyBaseColor"/> (a registered <c>[MaterialProperty("_BaseColor")]</c>) on those
/// render children: on an enemy <see cref="Health"/>-decrease edge it lerps <c>_BaseColor</c> toward
/// <see cref="FeelConfig.BodyFlashColor"/> and decays back to white. No new component type, NO ShaderGraph edit,
/// no server work, no <c>[GhostField]</c> — observe-only client presentation, so it is rollback-irrelevant.
/// The render children gain <see cref="URPMaterialPropertyBaseColor"/> lazily (added once per enemy via ECB);
/// white is the baked rest value (every enemy uses the same AnimatedLitShader/Synty-atlas convention), so
/// <c>Flash==0</c> restores the untouched look and the override is never visible at rest.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class EnemyHitFlashSystem : SystemBase
{
class FlashEntry
{
public readonly List<Entity> 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<Entity, FlashEntry> _tracked = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _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<Health>();
// 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<RefRO<Health>>().WithAll<EnemyTag, LinkedEntityGroup>().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<LinkedEntityGroup>(entity);
for (int i = 0; i < leg.Length; i++)
{
var c = leg[i].Value;
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
entry.RenderKids.Add(c);
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(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<Health>(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<URPMaterialPropertyBaseColor>(c))
EntityManager.SetComponentData(c, new URPMaterialPropertyBaseColor { Value = col });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 03cbda5f0bedbe14fbf680d92db148fe
@@ -135,6 +135,27 @@ namespace ProjectM.Client
/// <summary>Seconds a rumble pulse lasts before it auto-stops.</summary> /// <summary>Seconds a rumble pulse lasts before it auto-stops.</summary>
public static float RumbleDurationSec; public static float RumbleDurationSec;
// ---- Deferred-items pass (2026-06): true body hit-flash, remote co-op swings, near-impact strike beep ----
/// <summary>Master gate for the enemy material BODY hit-flash (drives URPMaterialPropertyBaseColor on render children).</summary>
public static bool BodyFlashEnabled;
/// <summary>Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back).</summary>
public static Color BodyFlashColor;
/// <summary>Seconds the body flash decays from peak back to the baked white base.</summary>
public static float BodyFlashDurationSec;
/// <summary>Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability).</summary>
public static bool RemoteSwingEnabled;
/// <summary>Tint of a remote teammate's slash arc (cooler/friendlier than the local warm arc).</summary>
public static Color RemoteSlashColor;
/// <summary>Master gate for the near-impact \"dodge NOW\" strike beep on a winding-up enemy.</summary>
public static bool StrikeBeepEnabled;
/// <summary>Volume of the near-impact strike beep.</summary>
public static float StrikeBeepVolume;
/// <summary>Ticks before the strike lands that the beep fires (the dodge-reaction lead).</summary>
public static int StrikeBeepLeadTicks;
/// <summary>Squared world-distance from the local player beyond which the strike beep is suppressed (avoids a distant cacophony).</summary>
public static float StrikeBeepMaxDistSq;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults() public static void ResetDefaults()
{ {
@@ -201,6 +222,18 @@ namespace ProjectM.Client
RumbleKill = 0.45f; RumbleKill = 0.45f;
RumbleHeavy = 0.6f; RumbleHeavy = 0.6f;
RumbleDurationSec = 0.12f; 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
} }
} }
} }
@@ -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<PlayerTag>().WithDisabled<GhostOwnerIsLocal>()`), 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).