using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; using UnityEngine; namespace ProjectM.Client { /// /// Client-only AMBIENT LIFE layer (Phase 1.5b bundle 4 — critters + weather beats, zero netcode). Sibling of /// : an observe-only in /// that never touches the sim. Three cosmetic beats, all camera-following, /// scene-gated to "Game", biome-keyed off the same camera-X + replicated /// switch / use: /// - CRITTERS: a small pool of ground bugs (+ a few airborne birds) that idle-wander near the camera and /// DART away when the local player closes in (the "scatter" beat), biome-tinted, recycled at the ring edge; /// - CLOUD SHADOWS: soft dark feathered discs drifting slowly across the ground, recycled off the far edge; /// - BLIGHT LIGHTNING: in Blight rooms only, a periodic double-blink directional-light flash + delayed /// procedural thunder (a dedicated flash light, so it never fights WorldAtmosphereSystem's RenderSettings). /// Pooled GameObjects live under a private DontDestroyOnLoad root; wall-clock time is fine (presentation). /// Knobs live in . /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class AmbientLifeSystem : SystemBase { struct Critter { public GameObject Go; public Vector3 Pos; public Vector3 Vel; public bool IsBird; public float Phase; } struct Cloud { public GameObject Go; public Vector3 Pos; } static readonly Color[] CritterTint = { new Color(0.9f, 0.95f, 0.7f, 0.95f), // 0 meadow — pale mote/butterfly new Color(0.8f, 0.66f, 0.42f, 0.95f), // 1 arid — tan new Color(0.6f, 0.72f, 1f, 0.95f), // 2 cavern — pale blue new Color(0.7f, 0.45f, 0.85f, 0.95f), // 3 blight — sickly purple }; static readonly int ColorId = Shader.PropertyToID("_Color"); Camera _cam; GameObject _root; Mesh _critterMesh, _cloudMesh; Material _critterMat, _cloudMat; Light _flashLight; AudioClip _thunderClip; MaterialPropertyBlock _mpb; readonly List _critters = new(); readonly List _clouds = new(); int _biomeKey = -1; // lightning schedule float _nextStrike = -1f; float _strikeStart = -1f; bool _thunderQueued; float _thunderAt = -1f; protected override void OnCreate() { _mpb = new MaterialPropertyBlock(); _thunderClip = FeedbackFx.MakeClip("thunder", 70f, 30f, 0.9f, 0.6f, noise: true, decay: 3.5f); } protected override void OnDestroy() { if (_root != null) Object.Destroy(_root); if (_critterMesh != null) Object.Destroy(_critterMesh); if (_cloudMesh != null) Object.Destroy(_cloudMesh); if (_critterMat != null) Object.Destroy(_critterMat); if (_cloudMat != null) Object.Destroy(_cloudMat); } protected override void OnUpdate() { if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") { Hide(); return; } if (_cam == null) _cam = Camera.main; if (_cam == null) return; if (!AmbientLifeConfig.Enabled) { Hide(); return; } if (_root == null) { _root = new GameObject("~AmbientLife"); Object.DontDestroyOnLoad(_root); _critterMesh = FeedbackFx.BuildDisc(7); // tiny speck _cloudMesh = FeedbackFx.BuildScorchMesh(28, 0.22f, 3); // soft feathered shadow blob _critterMat = FeedbackFx.MakeParticleMaterial("AmbientCritter"); _cloudMat = FeedbackFx.MakeDecalMaterial("CloudShadow"); } int key = BiomeKey(); if (key != _biomeKey) { _biomeKey = key; _critterMat.color = CritterTint[key]; } Vector3 cam = _cam.transform.position; float dt = Mathf.Min(0.05f, UnityEngine.Time.deltaTime); // clamp so a hitch can't teleport critters float now = UnityEngine.Time.time; UpdateCritters(cam, dt, now); UpdateClouds(cam, dt); UpdateLightning(key == 3, now); } int BiomeKey() { int key = 0; // meadow / base hub if (_cam.transform.position.x > 500f) { key = 1; // arid default in the expedition region if (SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle != RunLifecycle.Staging) switch (ri.CurrentBiome) { case RoomBiomeId.Meadow: key = 0; break; case RoomBiomeId.Cavern: key = 2; break; case RoomBiomeId.Blight: key = 3; break; } } return key; } // ---------- critters (bugs + birds) ---------- void UpdateCritters(Vector3 cam, float dt, float now) { if (!AmbientLifeConfig.CrittersEnabled) { for (int i = 0; i < _critters.Count; i++) SetActive(_critters[i].Go, false); return; } // local player position (flee source); missing -> far away so nothing flees 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); int want = Mathf.Clamp(AmbientLifeConfig.CritterCount, 0, 64); while (_critters.Count < want) SpawnCritter(cam); for (int i = _critters.Count - 1; i >= want; i--) { if (_critters[i].Go != null) Object.Destroy(_critters[i].Go); _critters.RemoveAt(i); } float radius = AmbientLifeConfig.CritterRadius; float fleeR2 = AmbientLifeConfig.CritterFleeRadius * AmbientLifeConfig.CritterFleeRadius; float idle = AmbientLifeConfig.CritterIdleSpeed, flee = AmbientLifeConfig.CritterFleeSpeed; for (int i = 0; i < _critters.Count; i++) { var c = _critters[i]; if (c.Go == null) { c.Go = MakeCritterGo(c.IsBird); } SetActive(c.Go, true); float birdY = c.IsBird ? 2.6f : 0.06f; Vector3 flat = new Vector3(c.Pos.x, 0f, c.Pos.z); float dxp = flat.x - playerPos.x, dzp = flat.z - playerPos.z; Vector3 target; if (dxp * dxp + dzp * dzp < fleeR2) { // DART away from the player (birds also rise a touch) var away = new Vector3(dxp, 0f, dzp); if (away.sqrMagnitude < 0.001f) away = new Vector3(Mathf.Cos(c.Phase), 0f, Mathf.Sin(c.Phase)); target = away.normalized * flee; } else { // gentle wander: a slowly rotating heading per critter float a = now * 0.35f + c.Phase; target = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * idle * (c.IsBird ? 1.8f : 1f); } c.Vel = Vector3.Lerp(c.Vel, target, 1f - Mathf.Exp(-6f * dt)); c.Pos += c.Vel * dt; // recycle when it wanders past the ring float dxc = c.Pos.x - cam.x, dzc = c.Pos.z - cam.z; if (dxc * dxc + dzc * dzc > radius * radius) { float ang = UnityEngine.Random.value * 6.2831f; float r = radius * 0.7f; c.Pos = new Vector3(cam.x + Mathf.Cos(ang) * r, 0f, cam.z + Mathf.Sin(ang) * r); c.Vel = Vector3.zero; } c.Go.transform.position = new Vector3(c.Pos.x, birdY, c.Pos.z); float s = c.IsBird ? 0.22f : 0.14f; c.Go.transform.localScale = new Vector3(s, 1f, s); _critters[i] = c; } } void SpawnCritter(Vector3 cam) { bool bird = UnityEngine.Random.value < Mathf.Clamp01(AmbientLifeConfig.BirdFraction); float ang = UnityEngine.Random.value * 6.2831f; float r = UnityEngine.Random.Range(3f, Mathf.Max(4f, AmbientLifeConfig.CritterRadius * 0.9f)); _critters.Add(new Critter { Go = MakeCritterGo(bird), Pos = new Vector3(cam.x + Mathf.Cos(ang) * r, 0f, cam.z + Mathf.Sin(ang) * r), Vel = Vector3.zero, IsBird = bird, Phase = UnityEngine.Random.value * 6.2831f, }); } GameObject MakeCritterGo(bool bird) { var go = new GameObject(bird ? "Bird" : "Bug"); go.transform.SetParent(_root.transform, false); go.AddComponent().sharedMesh = _critterMesh; var mr = go.AddComponent(); mr.sharedMaterial = _critterMat; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; return go; } // ---------- cloud-shadow drifts ---------- void UpdateClouds(Vector3 cam, float dt) { if (!AmbientLifeConfig.CloudsEnabled) { for (int i = 0; i < _clouds.Count; i++) SetActive(_clouds[i].Go, false); return; } int want = Mathf.Clamp(AmbientLifeConfig.CloudCount, 0, 16); while (_clouds.Count < want) { var go = new GameObject("CloudShadow"); go.transform.SetParent(_root.transform, false); go.AddComponent().sharedMesh = _cloudMesh; var mr = go.AddComponent(); mr.sharedMaterial = _cloudMat; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; float sp = AmbientLifeConfig.CritterRadius; // reuse a spread _clouds.Add(new Cloud { Go = go, Pos = new Vector3(cam.x + UnityEngine.Random.Range(-sp, sp), 0f, cam.z + UnityEngine.Random.Range(-sp, sp)) }); } for (int i = _clouds.Count - 1; i >= want; i--) { if (_clouds[i].Go != null) Object.Destroy(_clouds[i].Go); _clouds.RemoveAt(i); } _cloudMat.color = new Color(0.02f, 0.02f, 0.04f, Mathf.Clamp01(AmbientLifeConfig.CloudAlpha)); float size = AmbientLifeConfig.CloudSize; float drift = AmbientLifeConfig.CloudDriftSpeed; float span = size * 2.2f; for (int i = 0; i < _clouds.Count; i++) { var cl = _clouds[i]; SetActive(cl.Go, true); var p = cl.Pos; p.x += drift * dt; // drift along +X (a light prevailing wind) // recycle once it drifts a span past the camera downwind edge if (p.x > cam.x + size + span) { p.x = cam.x - size - span; p.z = cam.z + UnityEngine.Random.Range(-size, size); } cl.Pos = p; cl.Go.transform.position = new Vector3(p.x, 0.04f, p.z); cl.Go.transform.localScale = new Vector3(size, 1f, size); cl.Go.GetComponent().SetPropertyBlock(null); // uses shared material color _clouds[i] = cl; } } // ---------- Blight lightning flicker ---------- void UpdateLightning(bool inBlight, float now) { if (!AmbientLifeConfig.LightningEnabled || !inBlight) { if (_flashLight != null) _flashLight.intensity = 0f; _strikeStart = -1f; _nextStrike = -1f; return; } if (_flashLight == null) { var lgo = new GameObject("~BlightLightning"); lgo.transform.SetParent(_root.transform, false); lgo.transform.rotation = Quaternion.Euler(52f, -28f, 0f); _flashLight = lgo.AddComponent(); _flashLight.type = LightType.Directional; _flashLight.shadows = LightShadows.None; _flashLight.intensity = 0f; } _flashLight.color = AmbientLifeConfig.LightningColor; if (_nextStrike < 0f) _nextStrike = now + UnityEngine.Random.Range(AmbientLifeConfig.LightningMinInterval, AmbientLifeConfig.LightningMaxInterval); // begin a strike if (_strikeStart < 0f && now >= _nextStrike) { _strikeStart = now; _nextStrike = now + UnityEngine.Random.Range(AmbientLifeConfig.LightningMinInterval, AmbientLifeConfig.LightningMaxInterval); _thunderAt = now + UnityEngine.Random.Range(0.3f, 0.8f); // thunder trails the flash _thunderQueued = true; } // drive the double-blink envelope if (_strikeStart >= 0f) { float t = now - _strikeStart; float peak = AmbientLifeConfig.LightningIntensity; float e; if (t < 0.06f) e = peak; else if (t < 0.11f) e = peak * 0.15f; else if (t < 0.20f) e = peak; // brighter second blink else if (t < 0.5f) e = peak * (1f - (t - 0.20f) / 0.30f); else { e = 0f; _strikeStart = -1f; } _flashLight.intensity = Mathf.Max(0f, e); } if (_thunderQueued && now >= _thunderAt) { _thunderQueued = false; var p = _cam != null ? _cam.transform.position : Vector3.zero; FeedbackFx.PlayClip(_thunderClip, p, AmbientLifeConfig.ThunderVolume); } } // ---------- helpers ---------- static void SetActive(GameObject go, bool on) { if (go != null && go.activeSelf != on) go.SetActive(on); } void Hide() { for (int i = 0; i < _critters.Count; i++) SetActive(_critters[i].Go, false); for (int i = 0; i < _clouds.Count; i++) SetActive(_clouds[i].Go, false); if (_flashLight != null) _flashLight.intensity = 0f; _strikeStart = -1f; _nextStrike = -1f; } } }