using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; using UnityEngine; namespace ProjectM.Client { /// /// Client-only AMBIENT MOTION layer (Phase 1.5 "world alive" — the anti-static beat, zero netcode): /// - per-biome DRIFT particles (meadow motes / arid wind-blown dust / cavern motes / blight spores) from /// one world-space emitter that follows the camera; palette keys on camera-X region + the replicated /// per-room biome, mirroring 's switch /// - idle SWAY on the cosmetic flora prop roots near the camera (Game.unity classic layer; probed /// 2026-07-09: 212 flora roots, none static-batched, LOD children follow the root) /// - walk-through RUSTLE: flora brushed by the LOCAL player (the GhostOwnerIsLocal idiom) gets an /// energy pulse that shakes and decays /// Observe-only in ; wall-clock time is fine /// (presentation). Rotations write around a cached base so the layer is lossless when toggled off. /// Knobs live in (defaults when absent). /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class AmbientMotionSystem : SystemBase { // 2026-08-08: seabed terms FIRST. The old list was pure LAND flora (bush/grass/flower/fern/cactus) // from the deleted meadow + arid biomes, so it matched NOTHING in the shipping seabed arena and the // whole walk-through rustle layer was dead. The land terms are kept only so an old scene still works. static readonly System.Text.RegularExpressions.Regex FloraName = new System.Text.RegularExpressions.Regex( "floraclump|flora_|kelp|frond|seagrass|seaweed|coral|anemone" + "|bush|grass|flower|fern|groundcover|ground_cover|wildflower|clover|reed|succulent|cactus", System.Text.RegularExpressions.RegexOptions.IgnoreCase); struct Flora { public Transform T; public Vector3 Pos; public Quaternion BaseRot; public float Phase; public float RustleEnergy; } readonly List _flora = new List(256); bool _floraCached; string _sceneKey; Camera _cam; GameObject _root; ParticleSystem _drift; int _biomeKey = -1; protected override void OnDestroy() { RestoreFlora(); if (_root != null) Object.Destroy(_root); } protected override void OnUpdate() { var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); if (scene.name != "Game") { RestoreFlora(); _floraCached = false; return; } if (_cam == null) _cam = Camera.main; if (_cam == null) return; var cfg = AmbientMotionConfig.Instance; bool enabled = cfg == null || cfg.Enabled; if (!enabled) { RestoreFlora(); if (_drift != null && _drift.isPlaying) _drift.Stop(); return; } if (!_floraCached || _sceneKey != scene.path) CacheFlora(scene); UpdateDrift(cfg); UpdateSwayAndRustle(cfg); } // ---------- flora cache ---------- void CacheFlora(UnityEngine.SceneManagement.Scene scene) { RestoreFlora(); _flora.Clear(); // 2026-08-08: scan EVERY root. The old gate required a root literally named "BaseBiome" or // "ExpeditionBiome*" — both went with the expedition, so this loop matched nothing and the rustle // layer silently did nothing in the shipping arena. The seabed flora live under // Env_SeabedKit/Seabed_Flora/FloraClump_N. foreach (var root in scene.GetRootGameObjects()) { foreach (var t in root.GetComponentsInChildren(false)) { if (!FloraName.IsMatch(t.gameObject.name)) continue; if (t.gameObject.name.Contains("LOD")) continue; // prop roots only; LOD children follow // The sway unit is the CLUMP ROOT, which carries no renderer of its own — only children. // The old renderer-on-self test rejected exactly the object we want to rotate, so accept a // renderer anywhere beneath. Skip anything whose PARENT already matched, so we tilt the // clump once instead of tilting every stalk out of its socket. if (t.parent != null && FloraName.IsMatch(t.parent.gameObject.name)) continue; if (t.GetComponent() == null && t.GetComponentInChildren() == null) continue; // 1.5b: ground-flush carpet props (Flowers_Flat, Grass_*_Plane) must NEVER root-tilt — // any rotation lifts their edges off the ground; they stay fully static (shader wind only). if (t.gameObject.name.IndexOf("Flat", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.gameObject.name.IndexOf("Plane", System.StringComparison.OrdinalIgnoreCase) >= 0) continue; var p = t.position; _flora.Add(new Flora { T = t, Pos = p, BaseRot = t.localRotation, Phase = (p.x * 12.9898f + p.z * 78.233f) % 6.2831f, RustleEnergy = 0f, }); } } _floraCached = true; _sceneKey = scene.path; } void RestoreFlora() { for (int i = 0; i < _flora.Count; i++) if (_flora[i].T != null) _flora[i].T.localRotation = _flora[i].BaseRot; } // ---------- biome drift particles ---------- void UpdateDrift(AmbientMotionConfig cfg) { bool driftOn = cfg == null || cfg.DriftEnabled; if (!driftOn) { if (_drift != null && _drift.isPlaying) _drift.Stop(); return; } if (_root == null) { _root = new GameObject("~AmbientMotion"); Object.DontDestroyOnLoad(_root); } if (_drift == null) { var mat = FeedbackFx.MakeParticleMaterial("AmbientDrift"); _drift = FeedbackFx.MakeBurst(_root.transform, "Drift", mat, Color.white, 0.09f, 0.15f, 9f, 600, gravity: 0f, shapeRadius: 1f, sizeTail: 0.9f); var main = _drift.main; main.simulationSpace = ParticleSystemSimulationSpace.World; main.startLifetime = 9f; var shape = _drift.shape; shape.enabled = true; shape.shapeType = ParticleSystemShapeType.Box; shape.scale = new Vector3(36f, 7f, 24f); var noise = _drift.noise; noise.enabled = true; noise.strength = 0.45f; noise.frequency = 0.25f; var emission = _drift.emission; emission.enabled = true; } // 2026-08-07 (A0 gate): the per-biome drift — meadow / arid / cavern / blight — went with the // expedition. LANTERN is ONE look, so this collapses to a single cold marine particulate. The old // "meadow" default was painting GREEN motes across the whole seabed (the most off-palette thing in // the shipping frame), and the x>500 region probe pointed at space that now holds zero renderers. if (_biomeKey != 0) { _biomeKey = 0; var main = _drift.main; main.startSize = cfg != null ? cfg.DriftSize : 0.07f; main.startColor = new Color(0.55f, 0.80f, 0.95f, 0.26f); // cold: warm is reserved for OURS var vel = _drift.velocityOverLifetime; vel.enabled = true; vel.space = ParticleSystemSimulationSpace.World; vel.x = 0f; vel.y = 0.16f; vel.z = 0f; // detritus rising up the water column var emission = _drift.emission; emission.rateOverTime = cfg != null ? cfg.DriftRate : 18f; } if (!_drift.isPlaying) _drift.Play(); // follow the camera's ground focus so the volume always blankets the view var camPos = _cam.transform.position; _drift.transform.position = new Vector3(camPos.x, 3.5f, camPos.z); } // ---------- sway + rustle ---------- void UpdateSwayAndRustle(AmbientMotionConfig cfg) { bool swayOn = cfg == null || cfg.SwayEnabled; bool rustleOn = cfg == null || cfg.RustleEnabled; if (!swayOn && !rustleOn) { RestoreFlora(); return; } float swayRadius = cfg != null ? cfg.SwayRadius : 30f; float swayAmp = cfg != null ? cfg.SwayAmpDeg : 1.4f; float swayHz = cfg != null ? cfg.SwayHz : 0.8f; float rustleRadius = cfg != null ? cfg.RustleRadius : 1.7f; float rustleAmp = cfg != null ? cfg.RustleAmpDeg : 7f; float rustleHz = cfg != null ? cfg.RustleHz : 9f; float rustleDecay = cfg != null ? cfg.RustleDecay : 2.5f; // local player position (rustle source); missing (menu/no ghost yet) -> far away Vector3 playerPos = new Vector3(1e9f, 0f, 1e9f); foreach (var lt in SystemAPI.Query>().WithAll()) playerPos = new Vector3(lt.ValueRO.Position.x, 0f, lt.ValueRO.Position.z); float t = UnityEngine.Time.time; float dt = UnityEngine.Time.deltaTime; var camPos = _cam.transform.position; float swayR2 = swayRadius * swayRadius; float rustleR2 = rustleRadius * rustleRadius; for (int i = 0; i < _flora.Count; i++) { var f = _flora[i]; if (f.T == null) continue; float dxc = f.Pos.x - camPos.x, dzc = f.Pos.z - camPos.z; bool nearCam = dxc * dxc + dzc * dzc < swayR2; if (rustleOn) { float dxp = f.Pos.x - playerPos.x, dzp = f.Pos.z - playerPos.z; if (dxp * dxp + dzp * dzp < rustleR2) f.RustleEnergy = 1f; else if (f.RustleEnergy > 0f) f.RustleEnergy = Mathf.Max(0f, f.RustleEnergy - rustleDecay * dt); } if (!nearCam && f.RustleEnergy <= 0f) { _flora[i] = f; continue; } float ax = 0f, az = 0f; if (swayOn && nearCam) { ax += swayAmp * Mathf.Sin(t * swayHz * 6.2831f + f.Phase); az += swayAmp * 0.8f * Mathf.Sin(t * swayHz * 5.5f + f.Phase * 1.7f); } if (f.RustleEnergy > 0f) { float e = f.RustleEnergy; ax += rustleAmp * e * Mathf.Sin(t * rustleHz * 6.2831f + f.Phase); az += rustleAmp * 0.7f * e * Mathf.Sin(t * rustleHz * 5.1f + f.Phase * 2.3f); } f.T.localRotation = f.BaseRot * Quaternion.Euler(ax, 0f, az); _flora[i] = f; } } } }