Files
Project-M/Assets/_Project/Scripts/Client/Presentation/NodeFeedbackSystem.cs
T
2026-07-07 20:51:18 -07:00

104 lines
5.3 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Client
{
/// <summary>
/// Client-only harvest BODY feedback for resource nodes + Blight clutter — the "you're mining this down" cue that
/// complements <see cref="WorldFeedbackSystem"/>'s chip particles/SFX. Observe-only presentation (SystemBase, main
/// thread, no Burst) in <see cref="PresentationSystemGroup"/>: it edge-detects each node/clutter ghost's replicated
/// <c>Remaining</c> and drives a CLIENT-OWNED <see cref="PostTransformMatrix"/> scale on the ghost root so the node
/// (1) SHRINKS as Remaining falls toward 0 and (2) POPS briefly larger on each hit — a clear per-hit reaction plus
/// a running "how much is left" read. PostTransformMatrix composes on top of the replicated LocalTransform and is
/// NOT a ghost field, so this is pure client cosmetics: no wire change, no sim mutation, and it never destroys a
/// ghost (GhostDespawnSystem owns despawn; the shatter burst is WorldFeedbackSystem's prune path). Uses a scale
/// reaction rather than a <c>_BaseColor</c> flash because node materials (unlike the white-based enemy/player
/// bodies) aren't guaranteed white, so a base-color override could clobber their rest tint. Knobs in
/// <see cref="WorldFeelConfig"/> (<c>NodeMinScale</c> / <c>NodeHitPopScale</c> / <c>NodePopDurationSec</c>).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class NodeFeedbackSystem : SystemBase
{
class Entry
{
public int LastRemaining;
public int MaxRemaining; // client "full" reference = the largest Remaining ever seen for this node
public float Pop; // 1 on a fresh hit, decays to 0 (the per-hit scale-up)
}
readonly Dictionary<Entity, Entry> _tracked = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
protected override void OnUpdate()
{
if (!WorldFeelConfig.Enabled) { RestoreAll(); return; }
float dt = SystemAPI.Time.DeltaTime;
EntityManager.CompleteDependencyBeforeRO<ResourceNode>();
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
_seen.Clear();
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp);
float popDecay = dt / math.max(0.01f, WorldFeelConfig.NodePopDurationSec);
float minScale = math.clamp(WorldFeelConfig.NodeMinScale, 0.05f, 1f);
float popAmt = math.max(0f, WorldFeelConfig.NodeHitPopScale);
foreach (var (node, entity) in SystemAPI.Query<RefRO<ResourceNode>>().WithEntityAccess())
Drive(entity, node.ValueRO.Remaining, popDecay, minScale, popAmt, ecb);
foreach (var (clut, entity) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
Drive(entity, clut.ValueRO.Remaining, popDecay, minScale, popAmt, ecb);
ecb.Playback(EntityManager);
ecb.Dispose();
// Prune despawned (depleted/shattered) nodes — their PostTransformMatrix dies with the ghost.
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]);
}
}
void Drive(Entity e, int remaining, float popDecay, float minScale, float popAmt, EntityCommandBuffer ecb)
{
_seen.Add(e);
if (!_tracked.TryGetValue(e, out var entry))
{
entry = new Entry { LastRemaining = remaining, MaxRemaining = math.max(1, remaining), Pop = 0f };
_tracked[e] = entry;
if (!EntityManager.HasComponent<PostTransformMatrix>(e))
ecb.AddComponent(e, new PostTransformMatrix { Value = float4x4.Scale(1f) }); // present next frame
}
else
{
if (remaining < entry.LastRemaining) entry.Pop = 1f; // a hit landed
entry.LastRemaining = remaining;
if (remaining > entry.MaxRemaining) entry.MaxRemaining = remaining;
}
entry.Pop = math.max(0f, entry.Pop - popDecay);
// Progressive shrink toward NodeMinScale as Remaining -> 0, plus a brief per-hit pop.
float frac = math.saturate(remaining / (float)math.max(1, entry.MaxRemaining));
float scale = math.lerp(minScale, 1f, frac) * (1f + entry.Pop * popAmt);
if (EntityManager.HasComponent<PostTransformMatrix>(e))
EntityManager.SetComponentData(e, new PostTransformMatrix { Value = float4x4.Scale(scale) });
}
// Toggled off (WorldFeelConfig.Enabled=false) -> restore every tracked node to full size and stop tracking.
void RestoreAll()
{
foreach (var kv in _tracked)
if (EntityManager.Exists(kv.Key) && EntityManager.HasComponent<PostTransformMatrix>(kv.Key))
EntityManager.SetComponentData(kv.Key, new PostTransformMatrix { Value = float4x4.Scale(1f) });
_tracked.Clear();
}
}
}