73cfe2943d
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>
217 lines
9.5 KiB
C#
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<=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<=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);
|
|
}
|
|
}
|
|
}
|