Files
kronic 73cfe2943d EB-1: machines can die - structures get HP, Husks raze them, wounded base persists
Structures (Turret/Wall/Pylon) reuse the combat spine: authoring bakes Health(GhostField)+DamageEvent buffer+a Destructible tag (no HitRadius -> no friendly projectile fire; no EffectiveCharacterStats -> clamp-to-0). HealthApplyDamageSystem destroys a Destructible at 0 (occupancy auto-frees). EnemyAISystem fortress-targets the weighted-nearest of players+structures via the shared EnemyAIMath.PickWeightedNearest (StructureAggroWeight TuningConfig knob, <1 prefers structures, squared factor; snapshot above the early-return so an undefended base is razed). Persistence v3: per-structure HP threaded through 5 sites (SaveData/PendingStructure/scan-guarded/BaseRestore same-ECB born-correct/WorldLauncher via SaveApply.ToPending); SaveService floor-gate [2,3] loads old saves. Loss feedback: proximity-gated StructureFeedbackSystem; CombatFeedbackSystem suppressed for structures. Pre-code review caught the DamageEvent-buffer crash blocker + 8 majors; post-code review clean. See DR-032.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:53:34 -07:00

217 lines
9.5 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
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("StructChips", mat, StructureFeelConfig.DamageTint, 0.12f, 5f, 0.30f, 256);
_deathFx = MakeBurst("StructDeath", mat, StructureFeelConfig.DeathTint, 0.20f, 8f, 0.55f, 512);
}
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) ----
static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint)
{
if (ps == null) return;
var main = ps.main;
main.startColor = tint;
ps.transform.position = pos;
ps.Emit(count);
}
static Material MakeParticleMaterial()
{
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
return new Material(sh) { name = "StructureFeedbackParticle" };
}
ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max)
{
var go = new GameObject(name);
go.transform.SetParent(_fxRoot, false);
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.loop = false;
main.playOnAwake = false;
main.startLifetime = life;
main.startSpeed = speed;
main.startSize = size;
main.startColor = color;
main.maxParticles = max;
main.gravityModifier = 0.3f;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.enabled = false; // manual Emit(count)
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.18f;
var colOverLife = ps.colorOverLifetime;
colOverLife.enabled = true;
var grad = new Gradient();
grad.SetKeys(
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
var sizeOverLife = ps.sizeOverLifetime;
sizeOverLife.enabled = true;
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.15f));
var renderer = ps.GetComponent<ParticleSystemRenderer>();
renderer.material = mat;
renderer.renderMode = ParticleSystemRenderMode.Billboard;
return ps;
}
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol)
{
const int rate = 44100;
int len = Mathf.Max(16, (int)(dur * rate));
var clip = AudioClip.Create(name, len, 1, rate, false);
var data = new float[len];
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t = i / (float)len;
float env = Mathf.Exp(-5f * t);
float freq = Mathf.Lerp(f0, f1, t);
phase += 2f * Mathf.PI * freq / rate;
data[i] = Mathf.Sin(phase) * env * vol;
}
clip.SetData(data, 0);
return clip;
}
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
}
}
}