diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 31dce0464..2c0775d79 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -120,14 +120,14 @@ namespace ProjectM.Server if (sweep) { foreach (var depenXform in SystemAPI.Query>() - .WithAll().WithNone()) + .WithAll().WithNone().WithNone()) depenXform.ValueRW.Position = EnemyMoveUtil.Depenetrate(in physics, depenXform.ValueRO.Position, SweepRadius, envFilter); } foreach (var (xform, stats, cooldown, knockback, windup, region) in SystemAPI.Query, RefRO, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) + .WithAll().WithNone().WithNone()) { float3 pos = xform.ValueRO.Position; byte huskRegion = region.ValueRO.Region; @@ -398,7 +398,7 @@ namespace ProjectM.Server foreach (var (xform, stats, knockback, windup, spitter, region) in SystemAPI.Query, RefRO, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) + .WithAll().WithNone().WithNone()) { float3 pos = xform.ValueRO.Position; byte sRegion = region.ValueRO.Region; @@ -503,7 +503,7 @@ namespace ProjectM.Server // Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables). foreach (var (lunge, isLunging) in SystemAPI.Query, EnabledRefRW>() - .WithAll().WithPresent().WithNone()) + .WithAll().WithPresent().WithNone().WithNone()) { isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick } @@ -591,7 +591,7 @@ namespace ProjectM.Server float nudgeStep = UnstickNudgeSpeed * dt; foreach (var (nxform, nstats, nav, nregion, nent) in SystemAPI.Query, RefRO, RefRW, RefRO>() - .WithAll().WithNone().WithEntityAccess()) + .WithAll().WithNone().WithNone().WithEntityAccess()) { float3 npos = nxform.ValueRO.Position; byte nRegion = nregion.ValueRO.Region; diff --git a/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs b/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs new file mode 100644 index 000000000..d702d4f75 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs @@ -0,0 +1,85 @@ +#if UNITY_EDITOR +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// 07-20 sandbox combat target: keeps ONE planted dummy alive in the DevSandbox + /// (scene-name gate — the established dev-script convention; GymSub bakes no WaveDirector, so this is the + /// sandbox's enemy source by design). The dummy is a real enemy ghost instantiated from a baked prefab + /// (grunt-shaped preferred), parked at the first player's position + a fixed offset, HP fat enough to eat a + /// few chains; it dies through the NORMAL death path (Dying corpse window → destroy → kill pop on the client) + /// and respawns at the same anchor shortly after — kill feedback stays testable on repeat. Editor-only. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + public partial class TargetDummySpawnSystem : SystemBase + { + const float RespawnDelaySec = 1.5f; + const float DummyHealth = 400f; + static readonly float3 AnchorOffset = new float3(3f, 0f, 1.5f); + + float _respawnTimer; + float3 _anchor; + bool _anchored; + + protected override void OnUpdate() + { + if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "DevSandbox") + return; + + // A dummy exists (alive OR in its Dying corpse window) -> nothing to do; the corpse window + // conveniently delays the respawn timer until the body is gone. + var dummyQ = SystemAPI.QueryBuilder().WithAll().Build(); + if (!dummyQ.IsEmpty) { _respawnTimer = 0f; return; } + + // Anchor once on the first player seen (spawn point + offset = a consistent training spot). + if (!_anchored) + { + foreach (var xf in SystemAPI.Query>().WithAll()) + { + _anchor = xf.ValueRO.Position + AnchorOffset; + _anchored = true; + break; + } + if (!_anchored) return; // no player yet (world still connecting) + } + + _respawnTimer += SystemAPI.Time.DeltaTime; // wall-frame is fine: dev spawner in the plain server group + if (_respawnTimer < RespawnDelaySec) return; + _respawnTimer = 0f; + + // Grunt-shaped prefab preferred (no lunge/spit machinery on a training dummy); fall back to any enemy. + var prefQ = EntityManager.CreateEntityQuery(new EntityQueryDesc + { + All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) }, + None = new ComponentType[] { typeof(LungeState), typeof(SpitterState) }, + Options = EntityQueryOptions.IncludePrefab, + }); + var prefabs = prefQ.ToEntityArray(Allocator.Temp); + if (prefabs.Length == 0) + { + prefabs.Dispose(); + var anyQ = EntityManager.CreateEntityQuery(new EntityQueryDesc + { + All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) }, + Options = EntityQueryOptions.IncludePrefab, + }); + prefabs = anyQ.ToEntityArray(Allocator.Temp); + if (prefabs.Length == 0) { prefabs.Dispose(); return; } // no enemy ghosts in this world + } + var prefab = prefabs[0]; + prefabs.Dispose(); + + var inst = EntityManager.Instantiate(prefab); + var baked = EntityManager.GetComponentData(prefab); + EntityManager.SetComponentData(inst, baked.WithPosition(_anchor)); // WithPosition: never FromPosition (Scale reset) + EntityManager.SetComponentData(inst, new Health { Current = DummyHealth, Max = DummyHealth }); + EntityManager.AddComponent(inst); // server-only: plants it (every AI pass excludes the tag) + } + } +} +#endif diff --git a/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs.meta b/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs.meta new file mode 100644 index 000000000..ad8245828 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Debug/TargetDummySpawnSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 036a5a62e96d4ac49b556c748135b478 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Debug/TargetDummy.cs b/Assets/_Project/Scripts/Simulation/Debug/TargetDummy.cs new file mode 100644 index 000000000..5baa11b4e --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Debug/TargetDummy.cs @@ -0,0 +1,14 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// 07-20 sandbox combat testing: marks a server-spawned enemy ghost as a PLANTED target dummy — + /// every EnemyAISystem pass excludes it (no move, no attack, no knockback integration, no depenetration + /// nudge), so it stands at its anchor and just takes hits. Server-only (runtime-added at spawn, never + /// replicated); the client sees a normal interpolated enemy, so health bars, hit flash, damage, kill pop + /// and the death path all exercise the REAL pipeline. Spawned + respawned by TargetDummySpawnSystem in the + /// DevSandbox (GymSub bakes no WaveDirector — the sandbox's combat target is this dummy, by design). + /// ★ EnemyTag-reuse audit (CLAUDE.md): cleave/projectile damage MUST see it (the point); AI passes exclude + /// it; wave/room cleared-checks would count it — acceptable ONLY because the spawner is sandbox-gated. + public struct TargetDummyTag : IComponentData { } +} diff --git a/Assets/_Project/Scripts/Simulation/Debug/TargetDummy.cs.meta b/Assets/_Project/Scripts/Simulation/Debug/TargetDummy.cs.meta new file mode 100644 index 000000000..6e47510fb --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Debug/TargetDummy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 013cf2990a041494fa876653dff1d059 \ No newline at end of file