diff --git a/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs b/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs index 390f3e359..d566b2c9e 100644 --- a/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Building/StructureAuthoring.cs @@ -17,6 +17,8 @@ namespace ProjectM.Authoring [Tooltip("StructureType byte: 5 = Wall, 6 = Pylon (do NOT use 1-4: Turret + reserved M7 automation).")] public byte Kind = StructureType.Wall; + [Min(1f)] public float MaxHp = 150f; + private class StructureBaker : Baker { public override void Bake(StructureAuthoring authoring) @@ -29,6 +31,12 @@ namespace ProjectM.Authoring NextTick = 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(entity); + AddComponent(entity); } } } diff --git a/Assets/_Project/Scripts/Authoring/Building/TurretAuthoring.cs b/Assets/_Project/Scripts/Authoring/Building/TurretAuthoring.cs index 4d752cc20..23a6bce17 100644 --- a/Assets/_Project/Scripts/Authoring/Building/TurretAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Building/TurretAuthoring.cs @@ -14,6 +14,7 @@ namespace ProjectM.Authoring [Min(1f)] public float Range = 10f; [Min(1)] public int CooldownTicks = 30; [Min(1f)] public float Damage = 12f; + [Min(1f)] public float MaxHp = 120f; private class TurretBaker : Baker { @@ -33,6 +34,13 @@ namespace ProjectM.Authoring CooldownTicks = authoring.CooldownTicks, 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(entity); + AddComponent(entity); } } } diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs index 51e17f7f2..bebbe5e38 100644 --- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs +++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs @@ -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(); diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 62b23262d..6c547b04b 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -172,6 +172,7 @@ namespace ProjectM.Client bool isEnemy = SystemAPI.HasComponent(entity); uint windup = isEnemy && SystemAPI.HasComponent(entity) ? SystemAPI.GetComponent(entity).WindUpUntilTick : 0u; bool isLocalPlayer = entity == _localPlayer; + bool isStructure = SystemAPI.HasComponent(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); diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs new file mode 100644 index 000000000..bcb35543b --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs @@ -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 +{ + /// + /// EB-1 — client-only WORLD JUICE for player-built structures taking damage + dying ("loses have weight"). A + /// managed in that OBSERVES replicated state and + /// never mutates the sim: it edge-detects each structure ghost's [GhostField] Health.Current — 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 [RequireMatchingQueriesForUpdate] — else a cache entry leaks per kill). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class StructureFeedbackSystem : SystemBase + { + struct Cache { public float Hp; public float3 Pos; public bool DeathFired; } + + readonly Dictionary _cache = new(); + readonly HashSet _seen = new(); + readonly List _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(); + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + + bool haveLocal = false; + float3 localPos = default; + foreach (var xf in SystemAPI.Query>().WithAll()) + { + localPos = xf.ValueRO.Position; + haveLocal = true; + } + float rangeSq = StructureFeelConfig.ProximityRange * StructureFeelConfig.ProximityRange; + + _seen.Clear(); + foreach (var (health, xf, e) in + SystemAPI.Query, RefRO>().WithAll().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(); + + 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(); + 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); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs.meta new file mode 100644 index 000000000..50177ed1f --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 61153a58a80eb0542bbdc62085cce81b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs new file mode 100644 index 000000000..d460d51ff --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs @@ -0,0 +1,47 @@ +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// EB-1 — static live-tunable knobs for (structure damage chips + + /// destruction bursts). A presentation-only bridge (mirrors WorldFeelConfig); reset on play-enter via + /// so poked values never leak across fast-enter-playmode sessions. + /// Read only on the main thread by the managed feedback system, never from Burst. + /// + public static class StructureFeelConfig + { + public static bool Enabled = true; + + /// 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. + 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); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs.meta new file mode 100644 index 000000000..daf0ff099 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/StructureFeelConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c868d6648bec9fd4199c44fcf8330326 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs index 36c554a8c..fb285e21d 100644 --- a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs @@ -120,11 +120,7 @@ namespace ProjectM.Client var se = em.CreateEntity(); var sbuf = em.AddBuffer(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(se); if (data.StructureIo != null) foreach (var io in data.StructureIo) diff --git a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs index 4c911d0a6..16edf57c9 100644 --- a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs +++ b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs @@ -25,12 +25,14 @@ namespace ProjectM.Server { ComponentLookup m_TransformLookup; ComponentLookup m_ConveyorLookup; + ComponentLookup m_HealthLookup; [BurstCompile] public void OnCreate(ref SystemState state) { m_TransformLookup = state.GetComponentLookup(isReadOnly: true); m_ConveyorLookup = state.GetComponentLookup(isReadOnly: true); + m_HealthLookup = state.GetComponentLookup(isReadOnly: true); state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); @@ -47,6 +49,7 @@ namespace ProjectM.Server m_TransformLookup.Update(ref state); m_ConveyorLookup.Update(ref state); + m_HealthLookup.Update(ref state); var anchor = SystemAPI.GetSingleton(); var catalog = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); @@ -81,6 +84,14 @@ namespace ProjectM.Server NextTick = ProductionMath.RestoreNextTick(now, p.RemainingTicks), 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); diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 48a8079b6..cb0ba61a3 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -51,10 +51,28 @@ namespace ProjectM.Server 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(Allocator.Temp); + var structurePositions = new NativeList(Allocator.Temp); + foreach (var (sx, sh, se) in + SystemAPI.Query, RefRO>() + .WithAll() + .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(); playerPositions.Dispose(); + structureEntities.Dispose(); + structurePositions.Dispose(); return; } @@ -63,6 +81,7 @@ namespace ProjectM.Server uint now = serverTick.TickIndexForValidTick; // Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies. var tune = SystemAPI.TryGetSingleton(out var tcfg) ? tcfg : TuningConfig.Defaults(); + float structAggro = math.max(0f, tune.StructureAggroWeight); var ecb = new EntityCommandBuffer(Allocator.Temp); bool havePhysics = SystemAPI.TryGetSingleton(out var physics); uint envMask = SystemAPI.TryGetSingleton(out var worldCol) ? worldCol.EnvironmentMask : 0u; @@ -95,21 +114,13 @@ namespace ProjectM.Server knockback.ValueRW.UntilTick = 0; // window elapsed } - // Nearest living player (planar XZ). - int best = -1; - float bestSq = float.MaxValue; - for (int i = 0; i < playerPositions.Length; i++) - { - float2 d = playerPositions[i].xz - pos.xz; - float sq = math.lengthsq(d); - if (sq < bestSq) - { - bestSq = sq; - best = i; - } - } - - float3 targetPos = playerPositions[best]; + // EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/ + // turret is the preferred target unless a player is in the way (closer after weighting). + EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool tgtIsStruct, out int tgtIdx); + if (tgtIdx < 0) + continue; // no target (covered by the early-return, but stay safe) + Entity targetEntity = tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]; + float3 targetPos = tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]; // Seek: stop just inside strike range so the Husk holds position to attack. float stopDistance = stats.ValueRO.AttackRange * 0.9f; @@ -143,7 +154,7 @@ namespace ProjectM.Server var windTick = new NetworkTick(windRaw); if (!(windTick.IsValid && windTick.IsNewerThan(serverTick))) { - ecb.AppendToBuffer(playerEntities[best], new DamageEvent + ecb.AppendToBuffer(targetEntity, new DamageEvent { Amount = stats.ValueRO.AttackDamage, SourceNetworkId = -1, // environment / Husk, not a player @@ -207,15 +218,12 @@ namespace ProjectM.Server knockback.ValueRW.UntilTick = 0; } - // Nearest living player (reuse the snapshot taken above). - int cbest = -1; float cbestSq = float.MaxValue; - for (int i = 0; i < playerPositions.Length; i++) - { - float2 dd = playerPositions[i].xz - pos.xz; - float sq = math.lengthsq(dd); - if (sq < cbestSq) { cbestSq = sq; cbest = i; } - } - float3 cTargetPos = playerPositions[cbest]; + // EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper). + EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool cIsStruct, out int cIdx); + if (cIdx < 0) + continue; + Entity cTargetEntity = cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]; + float3 cTargetPos = cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx]; // 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff. var lg = lunge.ValueRO; @@ -233,7 +241,7 @@ namespace ProjectM.Server if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange)) { - ecb.AppendToBuffer(playerEntities[cbest], new DamageEvent + ecb.AppendToBuffer(cTargetEntity, new DamageEvent { Amount = stats.ValueRO.AttackDamage, SourceNetworkId = -1, @@ -312,6 +320,8 @@ namespace ProjectM.Server ecb.Dispose(); playerEntities.Dispose(); playerPositions.Dispose(); + structureEntities.Dispose(); + structurePositions.Dispose(); } // Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against diff --git a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs index fc4858290..97989aac9 100644 --- a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs @@ -132,8 +132,11 @@ namespace ProjectM.Server health.ValueRW.Current = newHp; - // Server-authoritative death: training dummies despawn; player death is deferred (clamp only). - if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(entity))) + // Server-authoritative death: training dummies + enemies + EB-1 Destructible structures despawn; + // 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(entity) || SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(entity))) ecb.DestroyEntity(entity); } if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton()) diff --git a/Assets/_Project/Scripts/Simulation/Combat/Destructible.cs b/Assets/_Project/Scripts/Simulation/Combat/Destructible.cs new file mode 100644 index 000000000..8f4eae4ae --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/Destructible.cs @@ -0,0 +1,14 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// EB-1 — opt-in marker for an entity that DESTROYS when + /// its hits 0 (alongside TrainingDummyTag/EnemyTag). Baked ONLY on the + /// player-built structure ghosts (Turret/Wall/Pylon) so "machines can die". DELIBERATELY a distinct tag rather + /// than gating on bare : 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. + /// + public struct Destructible : IComponentData { } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/Destructible.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/Destructible.cs.meta new file mode 100644 index 000000000..d46f4a25e --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/Destructible.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a0efc019048795541ba13795d724f331 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs b/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs index 903acf699..78bfccdb5 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs @@ -1,3 +1,4 @@ +using Unity.Collections; using Unity.Mathematics; namespace ProjectM.Simulation @@ -68,5 +69,31 @@ namespace ProjectM.Simulation math.sincos(angle, out float s, out float c); return center + new float3(c * radius, 0f, s * radius); } + /// + /// 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 applied to structure distance, so <1 + /// makes structures preferred while a sufficiently-closer player 'in the way' still wins). Planar XZ, + /// deterministic (no RNG/wall-clock). Sets = -1 when there are no targets. Pure so + /// both the Grunt and Charger passes select IDENTICALLY and it is EditMode-unit-testable. + /// + public static void PickWeightedNearest(float3 from, NativeList playerPositions, + NativeList 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; } + } + } } } diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs index 41dd6f723..2c552b59b 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs @@ -46,6 +46,10 @@ namespace ProjectM.Simulation public float MeleeFinisherMult; 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; + /// The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path. public static TuningConfig Defaults() => new TuningConfig { @@ -68,6 +72,7 @@ namespace ProjectM.Simulation MeleeKnockbackSpeed = 6f, MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback MeleeComboLength = 3f, // light, light, finisher + StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable }; /// Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path @@ -86,6 +91,7 @@ namespace ProjectM.Simulation case TuningKnob.MeleeSwingMoveScale: case TuningKnob.MeleeKnockbackSpeed: case TuningKnob.MeleeFinisherMult: + case TuningKnob.StructureAggroWeight: return math.max(0f, value); // tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem) default: @@ -118,6 +124,7 @@ namespace ProjectM.Simulation case TuningKnob.MeleeKnockbackSpeed: c.MeleeKnockbackSpeed = value; break; case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = 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) } } @@ -146,6 +153,7 @@ namespace ProjectM.Simulation case TuningKnob.MeleeKnockbackSpeed: return c.MeleeKnockbackSpeed; case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult; case TuningKnob.MeleeComboLength: return c.MeleeComboLength; + case TuningKnob.StructureAggroWeight: return c.StructureAggroWeight; default: return 0f; } } @@ -172,6 +180,7 @@ namespace ProjectM.Simulation MeleeKnockbackSpeed = c.MeleeKnockbackSpeed, MeleeFinisherMult = c.MeleeFinisherMult, MeleeComboLength = c.MeleeComboLength, + StructureAggroWeight = c.StructureAggroWeight, }; /// Reconstruct the full config from a wire snapshot (FULL state, not a delta). @@ -196,6 +205,7 @@ namespace ProjectM.Simulation MeleeKnockbackSpeed = r.MeleeKnockbackSpeed, MeleeFinisherMult = r.MeleeFinisherMult, MeleeComboLength = r.MeleeComboLength, + StructureAggroWeight = r.StructureAggroWeight, }; } @@ -221,9 +231,10 @@ namespace ProjectM.Simulation public const byte MeleeKnockbackSpeed = 16; public const byte MeleeFinisherMult = 17; public const byte MeleeComboLength = 18; + public const byte StructureAggroWeight = 19; /// Knob count (overlay iteration bound). - public const byte Count = 19; + public const byte Count = 20; } /// @@ -253,5 +264,6 @@ namespace ProjectM.Simulation public float MeleeKnockbackSpeed; public float MeleeFinisherMult; public float MeleeComboLength; + public float StructureAggroWeight; } } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs index 077e6f9e7..a4547be14 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs @@ -15,5 +15,20 @@ namespace ProjectM.Simulation for (int i = 0; i < src.Length; i++) dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count }); } + + /// EB-1: map a serialized to the staged + /// (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. + 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, + }; } } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs index 2f3f494dd..ba9170b15 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs @@ -37,6 +37,7 @@ namespace ProjectM.Simulation public uint RemainingTicks; public byte ConveyorResId; public int ConveyorCount; + public float HP; // EB-1: staged hit points (BaseRestoreSystem restores 0 -> baked Max) } /// One staged machine I/O row (M7), joined to the buffer by index. diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs index fa816849a..60bc4671c 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs @@ -25,6 +25,7 @@ namespace ProjectM.Simulation public uint RemainingTicks; // production/cooldown ticks left at save time public byte ConveyorResId; // in-flight conveyor item resource (0 = none) public int ConveyorCount; + public float HP; // EB-1: hit points at save time (0 from a pre-v3 save -> restored to baked Max) } /// @@ -50,7 +51,10 @@ namespace ProjectM.Simulation [Serializable] public class SaveData { - public const int CurrentVersion = 2; + public const int CurrentVersion = 3; // EB-1: v3 adds StructureSave.HP + + /// Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP. + public const int MinLoadableVersion = 2; public int Version = CurrentVersion; public int GoalCharge; diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs index 56e11e916..2a8a0ade8 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs @@ -23,7 +23,9 @@ namespace ProjectM.Simulation { if (!File.Exists(FilePath)) return null; var data = JsonUtility.FromJson(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(); return data; } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs index 601228201..a0edcdccf 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs @@ -35,6 +35,8 @@ namespace ProjectM.Simulation CellX = ps.Cell.x, CellZ = ps.Cell.y, 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(e) ? em.GetComponentData(e).Current : 0f, }; if (em.HasComponent(e))