Files
Project-M/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs
T
kronic 52eda31360 Hygiene B4a: extract shared FeedbackFx (dedup 4 procedural-FX copies)
New FeedbackFx static (MakeClip/MakeParticleMaterial/MakeBurst/PlayClip/EmitTinted/EmitAt); the 3 *FeedbackSystem copies + AmbientAudio.MakeSting now route through it via 'using static'. MakeBurst takes the FX-root + the per-use gravity/radius/sizeTail that were the only diffs between copies; MakeClip folds in noise + decay. Behaviour-identical by construction (every particle/clip param preserved exactly).

459/459 EditMode tests pass; compiles clean. VFX are presentation-only (no EditMode coverage) -> wants a Play-mode smoke to eyeball hit/death/harvest/structure bursts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:59:05 -07:00

143 lines
6.3 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
using static ProjectM.Client.FeedbackFx;
namespace ProjectM.Client
{
/// <summary>
/// EB-1 — client-only WORLD JUICE for player-built structures taking damage + dying ("loses have weight"). A
/// managed <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that OBSERVES replicated state and
/// never mutates the sim: it edge-detects each structure ghost's [GhostField] <c>Health.Current</c> — a decrease
/// spawns a small amber chip (camera-SILENT so a siege's many hits never clamp the shake), and a destruction
/// (an HP&lt;=0 edge OR a despawn) spawns a LOUD red-orange burst + camera punch. A PROXIMITY GATE suppresses the
/// destruction burst unless the structure was near the local player, so the base->expedition RegionRelevancy
/// despawn (every base structure drops from this client at once) stays SILENT. De-duped: a structure fires its
/// death burst AT MOST once (the HP&lt;=0 edge sets DeathFired so the prune-cleanup skips it; the server destroys
/// a structure the same tick it hits 0, so the prune is usually the path that fires). CombatFeedbackSystem
/// suppresses structures, so this is the SOLE structure cue. Procedural particles + SFX (mirrors
/// WorldFeedbackSystem; self-contained). Never destroys a ghost (GhostDespawnSystem owns despawn); prunes the
/// cache EVERY frame (no <c>[RequireMatchingQueriesForUpdate]</c> — else a cache entry leaks per kill).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class StructureFeedbackSystem : SystemBase
{
struct Cache { public float Hp; public float3 Pos; public bool DeathFired; }
readonly Dictionary<Entity, Cache> _cache = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
Transform _fxRoot;
ParticleSystem _chipFx;
ParticleSystem _deathFx;
AudioClip _chipClip;
AudioClip _deathClip;
protected override void OnCreate()
{
_chipClip = MakeClip("struct_chip", 700f, 500f, 0.05f, 0.30f);
_deathClip = MakeClip("struct_death", 220f, 60f, 0.35f, 0.55f);
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~StructureFeedbackFX").transform;
var mat = MakeParticleMaterial();
_chipFx = MakeBurst(_fxRoot, "StructChips", mat, StructureFeelConfig.DamageTint, 0.12f, 5f, 0.30f, 256, 0.3f, 0.18f, 0.15f);
_deathFx = MakeBurst(_fxRoot, "StructDeath", mat, StructureFeelConfig.DeathTint, 0.20f, 8f, 0.55f, 512, 0.3f, 0.18f, 0.15f);
}
protected override void OnDestroy()
{
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
}
protected override void OnUpdate()
{
if (!StructureFeelConfig.Enabled) { _cache.Clear(); return; }
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<PlacedStructure>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
bool haveLocal = false;
float3 localPos = default;
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
float rangeSq = StructureFeelConfig.ProximityRange * StructureFeelConfig.ProximityRange;
_seen.Clear();
foreach (var (health, xf, e) in
SystemAPI.Query<RefRO<Health>, RefRO<LocalTransform>>().WithAll<PlacedStructure>().WithEntityAccess())
{
_seen.Add(e);
float cur = health.ValueRO.Current;
float3 pos = xf.ValueRO.Position;
bool nearby = haveLocal && math.distancesq(pos, localPos) <= rangeSq;
if (_cache.TryGetValue(e, out var prev))
{
if (cur <= 0f && prev.Hp > 0f && !prev.DeathFired)
{
if (nearby) FireDeath(pos);
_cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = true };
continue;
}
if (cur < prev.Hp - 0.001f && cur > 0f && nearby)
{
EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.7f, StructureFeelConfig.ChipBurstCount, StructureFeelConfig.DamageTint);
PlayClip(_chipClip, (Vector3)pos, StructureFeelConfig.ChipSfxVolume);
}
}
_cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = _cache.TryGetValue(e, out var c2) && c2.DeathFired };
}
// Prune: a despawn = destroyed (or a region-transit drop). Proximity-gated so the +1000 base->expedition
// despawn stays silent; de-duped against an HP<=0 edge that already fired this structure's death.
if (_cache.Count != _seen.Count)
{
_stale.Clear();
foreach (var kv in _cache)
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++)
{
var c = _cache[_stale[i]];
if (!c.DeathFired && haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq)
FireDeath(c.Pos);
_cache.Remove(_stale[i]);
}
}
}
void FireDeath(float3 pos)
{
EmitTinted(_deathFx, (Vector3)pos + Vector3.up * 0.6f, StructureFeelConfig.DeathBurstCount, StructureFeelConfig.DeathTint);
PlayClip(_deathClip, (Vector3)pos, StructureFeelConfig.DeathSfxVolume);
PrototypeCameraRig.PunchFov(StructureFeelConfig.DeathFovKick, 110f);
PrototypeCameraRig.AddShake(StructureFeelConfig.DeathShake);
}
// ---- procedural particles + SFX (mirrors WorldFeedbackSystem; self-contained) ----
}
}