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