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:
2026-06-11 23:53:34 -07:00
parent 35d33f12c1
commit 73cfe2943d
21 changed files with 426 additions and 39 deletions
@@ -17,6 +17,8 @@ namespace ProjectM.Authoring
[Tooltip("StructureType byte: 5 = Wall, 6 = Pylon (do NOT use 1-4: Turret + reserved M7 automation).")] [Tooltip("StructureType byte: 5 = Wall, 6 = Pylon (do NOT use 1-4: Turret + reserved M7 automation).")]
public byte Kind = StructureType.Wall; public byte Kind = StructureType.Wall;
[Min(1f)] public float MaxHp = 150f;
private class StructureBaker : Baker<StructureAuthoring> private class StructureBaker : Baker<StructureAuthoring>
{ {
public override void Bake(StructureAuthoring authoring) public override void Bake(StructureAuthoring authoring)
@@ -29,6 +31,12 @@ namespace ProjectM.Authoring
NextTick = 0u, NextTick = 0u,
LastProcessedTick = 0u, LastProcessedTick = 0u,
}); });
// EB-1: Wall/Pylon are damageable + destructible AI targets (a wall soaks Husk strikes that would
// otherwise hit a turret). DamageEvent buffer MUST exist or an AI strike crashes at ECB playback.
// No HitRadius -> ProjectileDamageSystem ignores them (no friendly projectile fire).
AddComponent(entity, new Health { Current = authoring.MaxHp, Max = authoring.MaxHp });
AddBuffer<DamageEvent>(entity);
AddComponent<Destructible>(entity);
} }
} }
} }
@@ -14,6 +14,7 @@ namespace ProjectM.Authoring
[Min(1f)] public float Range = 10f; [Min(1f)] public float Range = 10f;
[Min(1)] public int CooldownTicks = 30; [Min(1)] public int CooldownTicks = 30;
[Min(1f)] public float Damage = 12f; [Min(1f)] public float Damage = 12f;
[Min(1f)] public float MaxHp = 120f;
private class TurretBaker : Baker<TurretAuthoring> private class TurretBaker : Baker<TurretAuthoring>
{ {
@@ -33,6 +34,13 @@ namespace ProjectM.Authoring
CooldownTicks = authoring.CooldownTicks, CooldownTicks = authoring.CooldownTicks,
Damage = authoring.Damage, Damage = authoring.Damage,
}); });
// EB-1: structures are damageable + destructible (Husks push for them; HealthApplyDamageSystem
// destroys a Destructible at Health<=0). The DamageEvent buffer MUST exist on the archetype or an
// AI/turret strike crashes at ECB playback. NO HitRadius on purpose -> ProjectileDamageSystem (needs
// Health+HitRadius) ignores structures, so player shots never friendly-fire your own turret.
AddComponent(entity, new Health { Current = authoring.MaxHp, Max = authoring.MaxHp });
AddBuffer<DamageEvent>(entity);
AddComponent<Destructible>(entity);
} }
} }
} }
@@ -104,6 +104,8 @@ namespace ProjectM.Client
TuningRow("Melee knock spd", TuningKnob.MeleeKnockbackSpeed, 1f, "0.0"); TuningRow("Melee knock spd", TuningKnob.MeleeKnockbackSpeed, 1f, "0.0");
TuningRow("Melee finish x", TuningKnob.MeleeFinisherMult, 0.1f, "0.0"); TuningRow("Melee finish x", TuningKnob.MeleeFinisherMult, 0.1f, "0.0");
TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "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(); GUILayout.EndScrollView();
@@ -172,6 +172,7 @@ namespace ProjectM.Client
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity); bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u; uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u;
bool isLocalPlayer = entity == _localPlayer; bool isLocalPlayer = entity == _localPlayer;
bool isStructure = SystemAPI.HasComponent<PlacedStructure>(entity); // EB-1: suppress combat cues -> StructureFeedbackSystem
if (_cache.TryGetValue(entity, out var prev)) 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 // 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. // 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); SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount); 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). // 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); Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount);
PlayClip(_deathClip, (Vector3)p, 0.7f); 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&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);
}
}
}
@@ -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 se = em.CreateEntity();
var sbuf = em.AddBuffer<PendingStructure>(se); var sbuf = em.AddBuffer<PendingStructure>(se);
foreach (var s in data.Structures) foreach (var s in data.Structures)
sbuf.Add(new PendingStructure sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP)
{
Type = s.Type, CellX = s.CellX, CellZ = s.CellZ, Direction = s.Direction,
RemainingTicks = s.RemainingTicks, ConveyorResId = s.ConveyorResId, ConveyorCount = s.ConveyorCount,
});
var iobuf = em.AddBuffer<PendingStructureIo>(se); var iobuf = em.AddBuffer<PendingStructureIo>(se);
if (data.StructureIo != null) if (data.StructureIo != null)
foreach (var io in data.StructureIo) foreach (var io in data.StructureIo)
@@ -25,12 +25,14 @@ namespace ProjectM.Server
{ {
ComponentLookup<LocalTransform> m_TransformLookup; ComponentLookup<LocalTransform> m_TransformLookup;
ComponentLookup<Conveyor> m_ConveyorLookup; ComponentLookup<Conveyor> m_ConveyorLookup;
ComponentLookup<Health> m_HealthLookup;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true); m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_ConveyorLookup = state.GetComponentLookup<Conveyor>(isReadOnly: true); m_ConveyorLookup = state.GetComponentLookup<Conveyor>(isReadOnly: true);
m_HealthLookup = state.GetComponentLookup<Health>(isReadOnly: true);
state.RequireForUpdate<StructureCatalog>(); state.RequireForUpdate<StructureCatalog>();
state.RequireForUpdate<BaseAnchor>(); state.RequireForUpdate<BaseAnchor>();
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
@@ -47,6 +49,7 @@ namespace ProjectM.Server
m_TransformLookup.Update(ref state); m_TransformLookup.Update(ref state);
m_ConveyorLookup.Update(ref state); m_ConveyorLookup.Update(ref state);
m_HealthLookup.Update(ref state);
var anchor = SystemAPI.GetSingleton<BaseAnchor>(); var anchor = SystemAPI.GetSingleton<BaseAnchor>();
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>()); var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
@@ -81,6 +84,14 @@ namespace ProjectM.Server
NextTick = ProductionMath.RestoreNextTick(now, p.RemainingTicks), NextTick = ProductionMath.RestoreNextTick(now, p.RemainingTicks),
LastProcessedTick = TickUtil.NonZero(now), LastProcessedTick = TickUtil.NonZero(now),
}); });
// EB-1: restore the wounded HP born-correct in the SAME ecb as Instantiate (Health.Current is a
// [GhostField]; a deferred set would leak baked Max to clients for one snapshot). Max + the
// 0->full fallback come from the BAKED prefab, never the save. Automation machines lack Health.
if (m_HealthLookup.HasComponent(prefab))
{
var hm = m_HealthLookup[prefab];
ecb.SetComponent(structure, new Health { Current = p.HP > 0f ? p.HP : hm.Max, Max = hm.Max });
}
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base }); ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
ecb.AddComponent<RuntimePlacedTag>(structure); ecb.AddComponent<RuntimePlacedTag>(structure);
@@ -51,10 +51,28 @@ namespace ProjectM.Server
playerPositions.Add(xform.ValueRO.Position); playerPositions.Add(xform.ValueRO.Position);
} }
if (playerEntities.Length == 0) // EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation
// machines lack it so the query excludes them). Snapshot ABOVE the early-return so Husks keep razing
// the base even with every player dead/away (the locked 'push for structures' fork).
var structureEntities = new NativeList<Entity>(Allocator.Temp);
var structurePositions = new NativeList<float3>(Allocator.Temp);
foreach (var (sx, sh, se) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
.WithAll<PlacedStructure>()
.WithEntityAccess())
{
if (sh.ValueRO.Current <= 0f)
continue; // skip a structure already at 0 (pending destroy this tick)
structureEntities.Add(se);
structurePositions.Add(sx.ValueRO.Position);
}
if (playerEntities.Length == 0 && structureEntities.Length == 0)
{ {
playerEntities.Dispose(); playerEntities.Dispose();
playerPositions.Dispose(); playerPositions.Dispose();
structureEntities.Dispose();
structurePositions.Dispose();
return; return;
} }
@@ -63,6 +81,7 @@ namespace ProjectM.Server
uint now = serverTick.TickIndexForValidTick; uint now = serverTick.TickIndexForValidTick;
// Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies. // Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies.
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults(); var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
float structAggro = math.max(0f, tune.StructureAggroWeight);
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics); bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u; uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
@@ -95,21 +114,13 @@ namespace ProjectM.Server
knockback.ValueRW.UntilTick = 0; // window elapsed knockback.ValueRW.UntilTick = 0; // window elapsed
} }
// Nearest living player (planar XZ). // EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/
int best = -1; // turret is the preferred target unless a player is in the way (closer after weighting).
float bestSq = float.MaxValue; EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool tgtIsStruct, out int tgtIdx);
for (int i = 0; i < playerPositions.Length; i++) if (tgtIdx < 0)
{ continue; // no target (covered by the early-return, but stay safe)
float2 d = playerPositions[i].xz - pos.xz; Entity targetEntity = tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx];
float sq = math.lengthsq(d); float3 targetPos = tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx];
if (sq < bestSq)
{
bestSq = sq;
best = i;
}
}
float3 targetPos = playerPositions[best];
// Seek: stop just inside strike range so the Husk holds position to attack. // Seek: stop just inside strike range so the Husk holds position to attack.
float stopDistance = stats.ValueRO.AttackRange * 0.9f; float stopDistance = stats.ValueRO.AttackRange * 0.9f;
@@ -143,7 +154,7 @@ namespace ProjectM.Server
var windTick = new NetworkTick(windRaw); var windTick = new NetworkTick(windRaw);
if (!(windTick.IsValid && windTick.IsNewerThan(serverTick))) if (!(windTick.IsValid && windTick.IsNewerThan(serverTick)))
{ {
ecb.AppendToBuffer(playerEntities[best], new DamageEvent ecb.AppendToBuffer(targetEntity, new DamageEvent
{ {
Amount = stats.ValueRO.AttackDamage, Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player SourceNetworkId = -1, // environment / Husk, not a player
@@ -207,15 +218,12 @@ namespace ProjectM.Server
knockback.ValueRW.UntilTick = 0; knockback.ValueRW.UntilTick = 0;
} }
// Nearest living player (reuse the snapshot taken above). // EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper).
int cbest = -1; float cbestSq = float.MaxValue; EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool cIsStruct, out int cIdx);
for (int i = 0; i < playerPositions.Length; i++) if (cIdx < 0)
{ continue;
float2 dd = playerPositions[i].xz - pos.xz; Entity cTargetEntity = cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx];
float sq = math.lengthsq(dd); float3 cTargetPos = cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx];
if (sq < cbestSq) { cbestSq = sq; cbest = i; }
}
float3 cTargetPos = playerPositions[cbest];
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff. // 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
var lg = lunge.ValueRO; var lg = lunge.ValueRO;
@@ -233,7 +241,7 @@ namespace ProjectM.Server
if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange)) if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange))
{ {
ecb.AppendToBuffer(playerEntities[cbest], new DamageEvent ecb.AppendToBuffer(cTargetEntity, new DamageEvent
{ {
Amount = stats.ValueRO.AttackDamage, Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, SourceNetworkId = -1,
@@ -312,6 +320,8 @@ namespace ProjectM.Server
ecb.Dispose(); ecb.Dispose();
playerEntities.Dispose(); playerEntities.Dispose();
playerPositions.Dispose(); playerPositions.Dispose();
structureEntities.Dispose();
structurePositions.Dispose();
} }
// Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against // Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against
@@ -132,8 +132,11 @@ namespace ProjectM.Server
health.ValueRW.Current = newHp; health.ValueRW.Current = newHp;
// Server-authoritative death: training dummies despawn; player death is deferred (clamp only). // Server-authoritative death: training dummies + enemies + EB-1 Destructible structures despawn;
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity))) // player death is deferred (clamp only). A structure carries NO EffectiveCharacterStats, so it took
// the math.max(0,..) branch above and CAN reach 0 — never give a structure stats (it would clamp to
// a non-zero floor and become immortal).
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity) || SystemAPI.HasComponent<Destructible>(entity)))
ecb.DestroyEntity(entity); ecb.DestroyEntity(entity);
} }
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>()) if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
@@ -0,0 +1,14 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// EB-1 — opt-in marker for an entity that <see cref="ProjectM.Server.HealthApplyDamageSystem"/> DESTROYS when
/// its <see cref="Health"/> hits 0 (alongside <c>TrainingDummyTag</c>/<c>EnemyTag</c>). Baked ONLY on the
/// player-built structure ghosts (Turret/Wall/Pylon) so "machines can die". DELIBERATELY a distinct tag rather
/// than gating on bare <see cref="PlacedStructure"/>: that identity is SHARED by the reserved M7 automation
/// machines (Harvester/Fabricator/Conveyor) whose teardown would silently drop in-flight conveyor cargo — the
/// tag lets each destructible opt in explicitly (zero-size, ghost-hash-neutral). See [[DR-031]] follow-on EB-1.
/// </summary>
public struct Destructible : IComponentData { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a0efc019048795541ba13795d724f331
@@ -1,3 +1,4 @@
using Unity.Collections;
using Unity.Mathematics; using Unity.Mathematics;
namespace ProjectM.Simulation namespace ProjectM.Simulation
@@ -68,5 +69,31 @@ namespace ProjectM.Simulation
math.sincos(angle, out float s, out float c); math.sincos(angle, out float s, out float c);
return center + new float3(c * radius, 0f, s * radius); return center + new float3(c * radius, 0f, s * radius);
} }
/// <summary>
/// EB-1 fortress aggro: pick a Husk's target as the weighted-nearest of the living players (weight 1) and
/// the live structures (a SQUARED <paramref name="structureWeight"/> applied to structure distance, so &lt;1
/// makes structures preferred while a sufficiently-closer player 'in the way' still wins). Planar XZ,
/// deterministic (no RNG/wall-clock). Sets <paramref name="index"/> = -1 when there are no targets. Pure so
/// both the Grunt and Charger passes select IDENTICALLY and it is EditMode-unit-testable.
/// </summary>
public static void PickWeightedNearest(float3 from, NativeList<float3> playerPositions,
NativeList<float3> structurePositions, float structureWeight, out bool isStructure, out int index)
{
isStructure = false;
index = -1;
float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float sq = math.lengthsq(playerPositions[i].xz - from.xz);
if (sq < bestSq) { bestSq = sq; index = i; isStructure = false; }
}
float w = math.max(0f, structureWeight);
float wsq = w * w; // applied to SQUARED distance so the weight scales true distance
for (int i = 0; i < structurePositions.Length; i++)
{
float sq = math.lengthsq(structurePositions[i].xz - from.xz) * wsq;
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
}
}
} }
} }
@@ -46,6 +46,10 @@ namespace ProjectM.Simulation
public float MeleeFinisherMult; public float MeleeFinisherMult;
public float MeleeComboLength; public float MeleeComboLength;
// EB-1 fortress aggro: a <1 multiplier on a Husk's SQUARED distance to a structure (so structures are
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
public float StructureAggroWeight;
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary> /// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
public static TuningConfig Defaults() => new TuningConfig public static TuningConfig Defaults() => new TuningConfig
{ {
@@ -68,6 +72,7 @@ namespace ProjectM.Simulation
MeleeKnockbackSpeed = 6f, MeleeKnockbackSpeed = 6f,
MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback
MeleeComboLength = 3f, // light, light, finisher MeleeComboLength = 3f, // light, light, finisher
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
}; };
/// <summary>Clamp a knob to its safe floor: tick knobs &gt;= 1, value knobs &gt;= 0. Used by every write path /// <summary>Clamp a knob to its safe floor: tick knobs &gt;= 1, value knobs &gt;= 0. Used by every write path
@@ -86,6 +91,7 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeSwingMoveScale: case TuningKnob.MeleeSwingMoveScale:
case TuningKnob.MeleeKnockbackSpeed: case TuningKnob.MeleeKnockbackSpeed:
case TuningKnob.MeleeFinisherMult: case TuningKnob.MeleeFinisherMult:
case TuningKnob.StructureAggroWeight:
return math.max(0f, value); return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem) // tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem)
default: default:
@@ -118,6 +124,7 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeKnockbackSpeed: c.MeleeKnockbackSpeed = value; break; case TuningKnob.MeleeKnockbackSpeed: c.MeleeKnockbackSpeed = value; break;
case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break; case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break;
case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break; case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break;
case TuningKnob.StructureAggroWeight: c.StructureAggroWeight = value; break;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem) // unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
} }
} }
@@ -146,6 +153,7 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeKnockbackSpeed: return c.MeleeKnockbackSpeed; case TuningKnob.MeleeKnockbackSpeed: return c.MeleeKnockbackSpeed;
case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult; case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult;
case TuningKnob.MeleeComboLength: return c.MeleeComboLength; case TuningKnob.MeleeComboLength: return c.MeleeComboLength;
case TuningKnob.StructureAggroWeight: return c.StructureAggroWeight;
default: return 0f; default: return 0f;
} }
} }
@@ -172,6 +180,7 @@ namespace ProjectM.Simulation
MeleeKnockbackSpeed = c.MeleeKnockbackSpeed, MeleeKnockbackSpeed = c.MeleeKnockbackSpeed,
MeleeFinisherMult = c.MeleeFinisherMult, MeleeFinisherMult = c.MeleeFinisherMult,
MeleeComboLength = c.MeleeComboLength, MeleeComboLength = c.MeleeComboLength,
StructureAggroWeight = c.StructureAggroWeight,
}; };
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary> /// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
@@ -196,6 +205,7 @@ namespace ProjectM.Simulation
MeleeKnockbackSpeed = r.MeleeKnockbackSpeed, MeleeKnockbackSpeed = r.MeleeKnockbackSpeed,
MeleeFinisherMult = r.MeleeFinisherMult, MeleeFinisherMult = r.MeleeFinisherMult,
MeleeComboLength = r.MeleeComboLength, MeleeComboLength = r.MeleeComboLength,
StructureAggroWeight = r.StructureAggroWeight,
}; };
} }
@@ -221,9 +231,10 @@ namespace ProjectM.Simulation
public const byte MeleeKnockbackSpeed = 16; public const byte MeleeKnockbackSpeed = 16;
public const byte MeleeFinisherMult = 17; public const byte MeleeFinisherMult = 17;
public const byte MeleeComboLength = 18; public const byte MeleeComboLength = 18;
public const byte StructureAggroWeight = 19;
/// <summary>Knob count (overlay iteration bound).</summary> /// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 19; public const byte Count = 20;
} }
/// <summary> /// <summary>
@@ -253,5 +264,6 @@ namespace ProjectM.Simulation
public float MeleeKnockbackSpeed; public float MeleeKnockbackSpeed;
public float MeleeFinisherMult; public float MeleeFinisherMult;
public float MeleeComboLength; public float MeleeComboLength;
public float StructureAggroWeight;
} }
} }
@@ -15,5 +15,20 @@ namespace ProjectM.Simulation
for (int i = 0; i < src.Length; i++) for (int i = 0; i < src.Length; i++)
dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count }); dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count });
} }
/// <summary>EB-1: map a serialized <see cref="StructureSave"/> to the staged <see cref="PendingStructure"/>
/// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the
/// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP.</summary>
public static PendingStructure ToPending(in StructureSave s) => new PendingStructure
{
Type = s.Type,
CellX = s.CellX,
CellZ = s.CellZ,
Direction = s.Direction,
RemainingTicks = s.RemainingTicks,
ConveyorResId = s.ConveyorResId,
ConveyorCount = s.ConveyorCount,
HP = s.HP,
};
} }
} }
@@ -37,6 +37,7 @@ namespace ProjectM.Simulation
public uint RemainingTicks; public uint RemainingTicks;
public byte ConveyorResId; public byte ConveyorResId;
public int ConveyorCount; public int ConveyorCount;
public float HP; // EB-1: staged hit points (BaseRestoreSystem restores 0 -> baked Max)
} }
/// <summary>One staged machine I/O row (M7), joined to the <see cref="PendingStructure"/> buffer by index. /// <summary>One staged machine I/O row (M7), joined to the <see cref="PendingStructure"/> buffer by index.
@@ -25,6 +25,7 @@ namespace ProjectM.Simulation
public uint RemainingTicks; // production/cooldown ticks left at save time public uint RemainingTicks; // production/cooldown ticks left at save time
public byte ConveyorResId; // in-flight conveyor item resource (0 = none) public byte ConveyorResId; // in-flight conveyor item resource (0 = none)
public int ConveyorCount; public int ConveyorCount;
public float HP; // EB-1: hit points at save time (0 from a pre-v3 save -> restored to baked Max)
} }
/// <summary> /// <summary>
@@ -50,7 +51,10 @@ namespace ProjectM.Simulation
[Serializable] [Serializable]
public class SaveData public class SaveData
{ {
public const int CurrentVersion = 2; public const int CurrentVersion = 3; // EB-1: v3 adds StructureSave.HP
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
public const int MinLoadableVersion = 2;
public int Version = CurrentVersion; public int Version = CurrentVersion;
public int GoalCharge; public int GoalCharge;
@@ -23,7 +23,9 @@ namespace ProjectM.Simulation
{ {
if (!File.Exists(FilePath)) return null; if (!File.Exists(FilePath)) return null;
var data = JsonUtility.FromJson<SaveData>(File.ReadAllText(FilePath)); var data = JsonUtility.FromJson<SaveData>(File.ReadAllText(FilePath));
if (data == null || data.Version != SaveData.CurrentVersion) return null; // EB-1: additive floor [MinLoadableVersion, CurrentVersion] so OLD v2 saves still load (a missing HP
// field 0-defaults and the restore guard maps 0 -> baked Max); v0/v1 garbage is still rejected.
if (data == null || data.Version < SaveData.MinLoadableVersion || data.Version > SaveData.CurrentVersion) return null;
data.Ledger ??= Array.Empty<LedgerRow>(); data.Ledger ??= Array.Empty<LedgerRow>();
return data; return data;
} }
@@ -35,6 +35,8 @@ namespace ProjectM.Simulation
CellX = ps.Cell.x, CellX = ps.Cell.x,
CellZ = ps.Cell.y, CellZ = ps.Cell.y,
RemainingTicks = ProductionMath.RemainingTicks(ps.NextTick, nowTick), RemainingTicks = ProductionMath.RemainingTicks(ps.NextTick, nowTick),
// EB-1: guarded so automation machines (no Health) don't crash the autosave path (no try/catch).
HP = em.HasComponent<Health>(e) ? em.GetComponentData<Health>(e).Current : 0f,
}; };
if (em.HasComponent<Conveyor>(e)) if (em.HasComponent<Conveyor>(e))