9f145e3a83
Operator delegated the placement call. Applied standard top-down-arena practice
rather than the first-pass ring scatter:
clear core (5u) the landing spot and dash room; props there cramp spawn
and cause dash collisions the player never asked for
band 6-13u solids live in KITING range, which is also roughly where
the diver's travelling light reaches. A prop outside the
lit radius reads as an invisible wall - the worst failure
mode in a dark game, so cover is pulled inward to where
it can be SEEN before it is hit
min spacing 2.6u enforced across clutter AND cover on ONE shared occupancy
list. This is a NAVIGATION guarantee, not an aesthetic:
enemies have no pathfinding, so gaps must stay wider than
a body. Rejection-sampled; a piece that cannot fit is
DROPPED rather than jammed into a gap movers can't pass
explosives >8u a barrel near the landing spot chains into you on spawn
Measured live, all three invariants hold:
17 solids, radius 7.89..12.95, min gap 2.79 (policy >=2.60), 0 explosives <8u
Nav re-validated on a CLEAN run: closest approach 1.40u, two enemies in melee.
Enemies cross the inward cover band and reach the player.
Correction worth recording: three intermediate readings showed "frozen movers"
and were WRONG. Teleporting the player around a long-running Play session
desyncs the predicted character controller and leaves stale AI state - the
server had the player back at (2.5,0) while I was measuring against (0,0), and
an earlier "all four frozen" reading was simply enemies that had converged and
were attacking. Only the clean, unmutated run is valid. Do not mutate server
state and then trust a movement measurement in the same session.
304/304 EditMode green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
204 lines
10 KiB
C#
204 lines
10 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// Server-only ONE-SHOT arena dressing seeder — the gym-scoped successor to <c>RoomFieldSystem</c>, which was
|
|
/// deleted with the run/room FSM in the 2026-08-07 audit purge (DR-054). That deletion was correct for the FSM
|
|
/// but silently took the arena's furniture with it: the baked <see cref="ClutterFieldSpawner"/>,
|
|
/// <see cref="CoverFieldSpawner"/> and <see cref="GeyserFieldSpawner"/> singletons kept baking and nothing read
|
|
/// them, so the shipping arena spawned ZERO props. Found at the A0 gate — "the world is very static".
|
|
///
|
|
/// Phase 1 is "no world — a gym", so this seeds ONCE at world start and never tears down. Everything
|
|
/// room-shaped (scatter-in-shape, budget spend-down, per-room reroll) is deliberately dropped; the Phase-2
|
|
/// pocket generator owns that.
|
|
///
|
|
/// PLACEMENT POLICY (2026-08-07, top-down-arena practice — the props must be usable, not decorative):
|
|
/// - <see cref="CoreClear"/>: a clear core around the landing spot. Props there cramp the spawn and cause
|
|
/// dash collisions the player never asked for.
|
|
/// - <see cref="BandInner"/>..<see cref="BandOuter"/>: solids live in the FOUGHT-IN band, which is also
|
|
/// roughly where the diver's travelling light reaches. A prop outside the lit radius reads as an invisible
|
|
/// wall, which is the worst failure mode in a dark game.
|
|
/// - <see cref="MinSpacing"/> is enforced across clutter AND cover on ONE shared occupancy list. This is a
|
|
/// navigation guarantee, not an aesthetic: enemy movement has NO pathfinding (a CollisionWorld sphere-cast
|
|
/// slide plus a depenetrate/nudge backstop), so gaps must stay comfortably wider than a body or movers wedge.
|
|
/// CLAUDE.md's standing rule is to re-validate movers whenever Environment cover is added.
|
|
/// - Explosive clutter (variant 3) is kept beyond <see cref="ExplosiveInner"/> so a barrel never chains into
|
|
/// the player at spawn.
|
|
///
|
|
/// Contracts carried from the original, each load-bearing:
|
|
/// - <c>baked.WithPosition</c>, never <c>FromPosition</c> — the latter resets Scale, which is a [GhostField].
|
|
/// - Geyser <c>NextEruptTick</c> stamped BORN-CORRECT off the live ServerTick via <see cref="TickUtil.NonZero"/>,
|
|
/// staggered per instance so eruptions desync. 0 stays the "unstamped / not ready" sentinel, never "fire now".
|
|
/// - A fixed seed: a gym wants the SAME arena every session, not a fresh scatter to relearn.
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
public partial struct ArenaFieldSystem : ISystem
|
|
{
|
|
/// <summary>Clear core around the arena origin — the landing spot and dash room.</summary>
|
|
const float CoreClear = 5f;
|
|
/// <summary>Solids start here: inside kiting range and inside the travelling light.</summary>
|
|
const float BandInner = 6f;
|
|
/// <summary>Solids stop here — beyond this they ring the player in rather than furnish the fight.</summary>
|
|
const float BandOuter = 13f;
|
|
/// <summary>Minimum gap between any two solid props. A NAV guarantee: enemies have no pathfinding.</summary>
|
|
const float MinSpacing = 2.6f;
|
|
/// <summary>Explosive clutter stays beyond this so a barrel never chains into the player at spawn.</summary>
|
|
const float ExplosiveInner = 8f;
|
|
/// <summary>Rejection-sampling attempts before a piece is dropped rather than placed too close.</summary>
|
|
const int PlaceAttempts = 24;
|
|
|
|
const uint ArenaSeed = 0x5EABEDu;
|
|
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<ClutterFieldSpawner>();
|
|
}
|
|
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
// One-shot for the world's lifetime. Disabling beats a bookkeeping component: no structural change,
|
|
// no re-read tick, and it cannot double-seed if the subscene streams late.
|
|
state.Enabled = false;
|
|
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
float3 origin = float3.zero;
|
|
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
|
origin = BaseGridMath.PlotCenter(anchor);
|
|
|
|
// ONE shared occupancy list so clutter and cover cannot overlap each other either.
|
|
var taken = new NativeList<float3>(Allocator.Temp);
|
|
|
|
int clutter = SeedClutter(ref state, ecb, origin, ref taken);
|
|
int cover = SeedCover(ref state, ecb, origin, ref taken);
|
|
int geysers = SeedGeysers(ref state, ecb, origin);
|
|
|
|
taken.Dispose();
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.Dispose();
|
|
|
|
UnityEngine.Debug.Log(
|
|
$"[ArenaFieldSystem] arena seeded: {clutter} clutter, {cover} cover, {geysers} geysers " +
|
|
$"(band {BandInner}-{BandOuter}u, min spacing {MinSpacing}u).");
|
|
}
|
|
|
|
/// <summary>Rejection-sampled placement in the fought-in annulus, respecting the shared min-spacing.
|
|
/// Returns false when the band is too crowded to place this piece — the caller DROPS it rather than
|
|
/// jamming a prop into a gap a mover cannot pass.</summary>
|
|
static bool TryPlace(float3 origin, float inner, float outer, ref Random rng,
|
|
ref NativeList<float3> taken, out float3 pos)
|
|
{
|
|
for (int attempt = 0; attempt < PlaceAttempts; attempt++)
|
|
{
|
|
// sqrt-distributed radius = uniform area coverage, so pieces don't bunch toward the inner edge
|
|
float u = rng.NextFloat();
|
|
float r = math.sqrt(math.lerp(inner * inner, outer * outer, u));
|
|
float a = rng.NextFloat(0f, 2f * math.PI);
|
|
var p = origin + new float3(math.cos(a) * r, 0f, math.sin(a) * r);
|
|
|
|
bool clear = true;
|
|
for (int i = 0; i < taken.Length; i++)
|
|
{
|
|
if (math.distancesq(p.xz, taken[i].xz) < MinSpacing * MinSpacing) { clear = false; break; }
|
|
}
|
|
if (!clear) continue;
|
|
|
|
taken.Add(p);
|
|
pos = p;
|
|
return true;
|
|
}
|
|
pos = default;
|
|
return false;
|
|
}
|
|
|
|
int SeedClutter(ref SystemState state, EntityCommandBuffer ecb, float3 origin, ref NativeList<float3> taken)
|
|
{
|
|
if (!SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var spawner) || spawner.Prefab == Entity.Null)
|
|
return 0;
|
|
int want = math.clamp(spawner.Count, 0, 24);
|
|
if (want == 0) return 0;
|
|
|
|
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
|
var proto = SystemAPI.GetComponent<BlightClutter>(spawner.Prefab);
|
|
var rng = new Random(ArenaSeed ^ 0xC17u);
|
|
float outer = math.max(BandInner + 1f, math.min(BandOuter, spawner.Radius > 0.01f ? spawner.Radius : BandOuter));
|
|
|
|
int placed = 0;
|
|
for (int i = 0; i < want; i++)
|
|
{
|
|
if (!TryPlace(origin, BandInner, outer, ref rng, ref taken, out var pos)) continue;
|
|
var e = ecb.Instantiate(spawner.Prefab);
|
|
ecb.SetComponent(e, baked.WithPosition(pos));
|
|
|
|
var bc = proto;
|
|
bool farEnoughForBlast = math.distance(pos.xz, origin.xz) >= ExplosiveInner;
|
|
// ~25% EXPLOSIVE (variant 3, the hazard) but never inside the blast keep-out; 0-2 are inert dressing.
|
|
bc.Variant = (farEnoughForBlast && rng.NextFloat() < 0.25f) ? (byte)3 : (byte)(i % 3);
|
|
ecb.SetComponent(e, bc);
|
|
placed++;
|
|
}
|
|
return placed;
|
|
}
|
|
|
|
int SeedCover(ref SystemState state, EntityCommandBuffer ecb, float3 origin, ref NativeList<float3> taken)
|
|
{
|
|
if (!SystemAPI.TryGetSingleton<CoverFieldSpawner>(out var spawner) || spawner.Prefab == Entity.Null)
|
|
return 0;
|
|
int want = math.clamp(spawner.Count, 0, 6);
|
|
if (want == 0) return 0;
|
|
|
|
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
|
var rng = new Random(ArenaSeed ^ 0xC0Eu);
|
|
|
|
int placed = 0;
|
|
for (int i = 0; i < want; i++)
|
|
{
|
|
if (!TryPlace(origin, BandInner, BandOuter, ref rng, ref taken, out var pos)) continue;
|
|
var e = ecb.Instantiate(spawner.Prefab);
|
|
ecb.SetComponent(e, baked.WithPosition(pos));
|
|
placed++;
|
|
}
|
|
return placed;
|
|
}
|
|
|
|
int SeedGeysers(ref SystemState state, EntityCommandBuffer ecb, float3 origin)
|
|
{
|
|
if (!SystemAPI.TryGetSingleton<GeyserFieldSpawner>(out var spawner) || spawner.Prefab == Entity.Null)
|
|
return 0;
|
|
int count = math.clamp(spawner.Count, 0, 4);
|
|
if (count == 0) return 0;
|
|
|
|
// Born-correct scheduling: read the LIVE tick. 0 stays the "unstamped" sentinel and means NOT ready.
|
|
uint stamp = 0u;
|
|
if (SystemAPI.TryGetSingleton<NetworkTime>(out var nt) && nt.ServerTick.IsValid)
|
|
stamp = nt.ServerTick.TickIndexForValidTick;
|
|
|
|
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
|
var rng = new Random(ArenaSeed ^ 0x6E7u);
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
// Geysers carry no collider, so they are exempt from the spacing list — but they still respect the
|
|
// clear core: an eruption on the landing spot is an unfair hit the player never saw coming.
|
|
float r = math.lerp(CoreClear + 2f, BandOuter, (i + 0.5f) / count);
|
|
float a = i * 2.399963f + rng.NextFloat(-0.4f, 0.4f); // golden angle: spread, not clumped
|
|
var pos = origin + new float3(math.cos(a) * r, 0f, math.sin(a) * r);
|
|
|
|
var e = ecb.Instantiate(spawner.Prefab);
|
|
ecb.SetComponent(e, baked.WithPosition(pos));
|
|
uint next = stamp != 0u
|
|
? TickUtil.NonZero(stamp + Tuning.GeyserPeriodTicks + (uint)i * 60u) // stagger so they desync
|
|
: 0u;
|
|
ecb.SetComponent(e, new Geyser { NextEruptTick = next });
|
|
}
|
|
return count;
|
|
}
|
|
}
|
|
}
|