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;
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
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 _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); }
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<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
// 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<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).
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<MeshFilter>(); 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]);
}
}
}