using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using UnityEngine; namespace ProjectM.Client { /// /// Phase 1.5b (ground bundle) — client-only, observe-only PER-ROOM DRESSING SCATTER. A managed /// in that OBSERVES replicated /// and instantiates biome-flavoured cosmetic ground props (pebbles, tufts, bones, /// mushrooms) inside the room's ACTUAL shape — the same authority the server /// scatter uses, on a DISTINCT hash stream so dressing never co-locates with nodes (0x0DE) or clutter /// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the /// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on /// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class RoomDressingSystem : SystemBase { Transform _root; uint _activeKey; protected override void OnStartRunning() { if (_root == null) _root = new GameObject("~RoomDressing").transform; } protected override void OnDestroy() { if (_root != null) Object.Destroy(_root.gameObject); } protected override void OnUpdate() { if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") return; if (_root == null) return; var cfg = RoomDressingConfig.Instance; bool inRoom = SystemAPI.TryGetSingleton(out var ri) && (ri.Lifecycle == RunLifecycle.InRoom || ri.Lifecycle == RunLifecycle.RoomReward || ri.Lifecycle == RunLifecycle.RoomExplore); bool on = cfg != null && cfg.Enabled && cfg.CountPerRoom > 0; if (!inRoom || !on || !SystemAPI.TryGetSingleton(out var anchor)) { Clear(); return; } uint key = RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E5u); if (key == _activeKey) return; Clear(); Build(in ri, in anchor, cfg); _activeKey = key; } void Clear() { if (_activeKey == 0 || _root == null) { _activeKey = 0; return; } for (int i = _root.childCount - 1; i >= 0; i--) Object.Destroy(_root.GetChild(i).gameObject); _activeKey = 0; } void Build(in RunInfo ri, in BaseAnchor anchor, RoomDressingConfig cfg) { GameObject[] set = ri.CurrentBiome switch { RoomBiomeId.Meadow => cfg.Meadow, RoomBiomeId.Cavern => cfg.Cavern, RoomBiomeId.Blight => cfg.Blight, _ => cfg.Arid, }; if (set == null || set.Length == 0) return; float3 baseCenter = BaseGridMath.PlotCenter(anchor); byte subSlot = (byte)(ri.CurrentRoom & 1); float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, subSlot); float3 portal = RegionMath.ExpeditionPortalPos(baseCenter, subSlot); var map = RunMapMath.Generate(ri.RunSeed); var node = map.NodeAt(RunMap.NodeId(ri.CurrentRoom, ri.CurrentCol)); var plan = RoomLayoutMath.Plan(node, ri.CurrentRoom, ri.RoomCount); var rng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E55u) | 1u); int count = cfg.CountPerRoom; for (int i = 0; i < count; i++) { float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng); for (int attempt = 0; attempt < 4; attempt++) { bool nearLanding = math.distancesq(pos.xz, origin.xz) < 9f; // party lands at the origin bool nearPortal = math.distancesq(pos.xz, portal.xz) < 6.25f; // keep the exit beacon clear if (!nearLanding && !nearPortal) break; pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng); } var prefab = set[rng.NextInt(0, set.Length)]; if (prefab == null) continue; var go = Object.Instantiate(prefab, _root, false); go.transform.SetPositionAndRotation( new Vector3(pos.x, 0f, pos.z), // terrain y=0 (origin.y is the CC capsule plane, 1 u up) Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f)); go.transform.localScale = Vector3.one * rng.NextFloat(cfg.ScaleMin, cfg.ScaleMax); foreach (var col in go.GetComponentsInChildren(true)) Object.Destroy(col); foreach (var rb in go.GetComponentsInChildren(true)) Object.Destroy(rb); } } } }