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:
@@ -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<StructureAuthoring>
|
||||
{
|
||||
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<DamageEvent>(entity);
|
||||
AddComponent<Destructible>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TurretAuthoring>
|
||||
{
|
||||
@@ -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<DamageEvent>(entity);
|
||||
AddComponent<Destructible>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,12 +25,14 @@ namespace ProjectM.Server
|
||||
{
|
||||
ComponentLookup<LocalTransform> m_TransformLookup;
|
||||
ComponentLookup<Conveyor> m_ConveyorLookup;
|
||||
ComponentLookup<Health> m_HealthLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
|
||||
m_ConveyorLookup = state.GetComponentLookup<Conveyor>(isReadOnly: true);
|
||||
m_HealthLookup = state.GetComponentLookup<Health>(isReadOnly: true);
|
||||
state.RequireForUpdate<StructureCatalog>();
|
||||
state.RequireForUpdate<BaseAnchor>();
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
@@ -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<BaseAnchor>();
|
||||
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
|
||||
@@ -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<RuntimePlacedTag>(structure);
|
||||
|
||||
|
||||
@@ -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<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();
|
||||
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<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
||||
float structAggro = math.max(0f, tune.StructureAggroWeight);
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
|
||||
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(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
|
||||
|
||||
@@ -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<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(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<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity) || SystemAPI.HasComponent<Destructible>(entity)))
|
||||
ecb.DestroyEntity(entity);
|
||||
}
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
/// <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 <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 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>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>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,
|
||||
};
|
||||
|
||||
/// <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,
|
||||
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;
|
||||
|
||||
/// <summary>Knob count (overlay iteration bound).</summary>
|
||||
public const byte Count = 19;
|
||||
public const byte Count = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -253,5 +264,6 @@ namespace ProjectM.Simulation
|
||||
public float MeleeKnockbackSpeed;
|
||||
public float MeleeFinisherMult;
|
||||
public float MeleeComboLength;
|
||||
public float StructureAggroWeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/// <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 byte ConveyorResId;
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
|
||||
/// <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 GoalCharge;
|
||||
|
||||
@@ -23,7 +23,9 @@ namespace ProjectM.Simulation
|
||||
{
|
||||
if (!File.Exists(FilePath)) return null;
|
||||
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>();
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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<Health>(e) ? em.GetComponentData<Health>(e).Current : 0f,
|
||||
};
|
||||
|
||||
if (em.HasComponent<Conveyor>(e))
|
||||
|
||||
Reference in New Issue
Block a user