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>
This commit is contained in:
@@ -104,6 +104,8 @@ namespace ProjectM.Client
|
||||
TuningRow("Melee knock spd", TuningKnob.MeleeKnockbackSpeed, 1f, "0.0");
|
||||
TuningRow("Melee finish x", TuningKnob.MeleeFinisherMult, 0.1f, "0.0");
|
||||
TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "0");
|
||||
GUILayout.Space(4);
|
||||
TuningRow("Struct aggro w", TuningKnob.StructureAggroWeight, 0.1f, "0.00"); // EB-1: <1 prefers structures
|
||||
}
|
||||
|
||||
GUILayout.EndScrollView();
|
||||
|
||||
@@ -172,6 +172,7 @@ namespace ProjectM.Client
|
||||
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
|
||||
uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u;
|
||||
bool isLocalPlayer = entity == _localPlayer;
|
||||
bool isStructure = SystemAPI.HasComponent<PlacedStructure>(entity); // EB-1: suppress combat cues -> StructureFeedbackSystem
|
||||
|
||||
if (_cache.TryGetValue(entity, out var prev))
|
||||
{
|
||||
@@ -184,7 +185,7 @@ namespace ProjectM.Client
|
||||
|
||||
// Local hit feedback is SUPPRESSED while the local i-frame window is active: the server
|
||||
// negates the hit; any transient Health dip is reconciliation flicker, not a real hit.
|
||||
if (cur < prev.Hp - 0.001f && !(isLocalPlayer && localIFrameActive && FeelConfig.DashHitSuppress))
|
||||
if (cur < prev.Hp - 0.001f && !isStructure && !(isLocalPlayer && localIFrameActive && FeelConfig.DashHitSuppress))
|
||||
{
|
||||
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
|
||||
@@ -202,7 +203,9 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
// Player death (players don't despawn — they respawn; Husk death is handled on prune).
|
||||
if (!isEnemy && cur <= 0f && prev.Hp > 0f)
|
||||
// EB-1: structures (not EnemyTag) would otherwise fire the HUMAN player-death cue here; their
|
||||
// damage/death is routed entirely through StructureFeedbackSystem (gated by !isStructure).
|
||||
if (!isEnemy && !isStructure && cur <= 0f && prev.Hp > 0f)
|
||||
{
|
||||
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount);
|
||||
PlayClip(_deathClip, (Vector3)p, 0.7f);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61153a58a80eb0542bbdc62085cce81b
|
||||
@@ -0,0 +1,47 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// EB-1 — static live-tunable knobs for <see cref="StructureFeedbackSystem"/> (structure damage chips +
|
||||
/// destruction bursts). A presentation-only bridge (mirrors <c>WorldFeelConfig</c>); reset on play-enter via
|
||||
/// <see cref="RuntimeInitializeOnLoadMethod"/> so poked values never leak across fast-enter-playmode sessions.
|
||||
/// Read only on the main thread by the managed feedback system, never from Burst.
|
||||
/// </summary>
|
||||
public static class StructureFeelConfig
|
||||
{
|
||||
public static bool Enabled = true;
|
||||
|
||||
/// <summary>A despawn farther than this from the local player does NOT fire a death burst — so the
|
||||
/// base->expedition RegionRelevancy despawn (all base structures drop at once) stays silent.</summary>
|
||||
public static float ProximityRange = 45f;
|
||||
|
||||
public static int ChipBurstCount = 8;
|
||||
public static int DeathBurstCount = 40;
|
||||
public static float ChipSfxVolume = 0.25f;
|
||||
public static float DeathSfxVolume = 0.6f;
|
||||
|
||||
// A LOUD, low-frequency punch is reserved for a structure DEATH only; per-chip feedback is camera-silent so
|
||||
// a wave of hits never sustains a nauseating shake (AddShake clamps cumulatively, PunchFov takes a max).
|
||||
public static float DeathFovKick = 5.5f;
|
||||
public static float DeathShake = 0.35f;
|
||||
|
||||
public static Color DamageTint = new Color(2.4f, 1.4f, 0.4f); // amber HDR spark on a hit
|
||||
public static Color DeathTint = new Color(3.0f, 0.7f, 0.25f); // red-orange HDR loss burst
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetDefaults()
|
||||
{
|
||||
Enabled = true;
|
||||
ProximityRange = 45f;
|
||||
ChipBurstCount = 8;
|
||||
DeathBurstCount = 40;
|
||||
ChipSfxVolume = 0.25f;
|
||||
DeathSfxVolume = 0.6f;
|
||||
DeathFovKick = 5.5f;
|
||||
DeathShake = 0.35f;
|
||||
DamageTint = new Color(2.4f, 1.4f, 0.4f);
|
||||
DeathTint = new Color(3.0f, 0.7f, 0.25f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c868d6648bec9fd4199c44fcf8330326
|
||||
@@ -120,11 +120,7 @@ namespace ProjectM.Client
|
||||
var se = em.CreateEntity();
|
||||
var sbuf = em.AddBuffer<PendingStructure>(se);
|
||||
foreach (var s in data.Structures)
|
||||
sbuf.Add(new PendingStructure
|
||||
{
|
||||
Type = s.Type, CellX = s.CellX, CellZ = s.CellZ, Direction = s.Direction,
|
||||
RemainingTicks = s.RemainingTicks, ConveyorResId = s.ConveyorResId, ConveyorCount = s.ConveyorCount,
|
||||
});
|
||||
sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP)
|
||||
var iobuf = em.AddBuffer<PendingStructureIo>(se);
|
||||
if (data.StructureIo != null)
|
||||
foreach (var io in data.StructureIo)
|
||||
|
||||
Reference in New Issue
Block a user