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:
@@ -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
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -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).
|
||||||
Reference in New Issue
Block a user