diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs new file mode 100644 index 000000000..f8cff10c8 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs @@ -0,0 +1,86 @@ +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Live-tunable knobs for the client-only AMBIENT LIFE slice (Phase 1.5b bundle 4: fleeing critters + + /// weather beats — cloud-shadow drifts + Blight lightning). Static bridge (mirrors / + /// ) so values can be poked at runtime via MCP execute_code without a recompile. + /// Read ONLY by (managed, main-thread). NEVER read from a [BurstCompile] system. + /// re-stamps on play-enter via [RuntimeInitializeOnLoadMethod] (statics survive + /// fast-enter-playmode reloads). All effects are cosmetic + observe-only — zero netcode. + /// + public static class AmbientLifeConfig + { + /// Master gate for every ambient-life effect. + public static bool Enabled; + + // ---- fleeing critters (ground bugs + a few birds) ---- + /// Master gate for the critter pool. + public static bool CrittersEnabled; + /// Live critters maintained near the camera. + public static int CritterCount; + /// Radius (world u) around the camera critters live in; past it they recycle to the ring edge. + public static float CritterRadius; + /// A critter within this distance of the local player DARTS away (the scatter beat). + public static float CritterFleeRadius; + /// Idle wander speed (u/s). + public static float CritterIdleSpeed; + /// Flee dart speed (u/s) when the player is close. + public static float CritterFleeSpeed; + /// Fraction of the pool that are BIRDS (airborne, faster, flee upward) vs ground bugs. + public static float BirdFraction; + + // ---- cloud-shadow drifts ---- + /// Master gate for drifting cloud shadows. + public static bool CloudsEnabled; + /// Number of soft dark shadow discs drifting across the ground near the camera. + public static int CloudCount; + /// Shadow disc radius (world u). + public static float CloudSize; + /// Peak shadow opacity (alpha). + public static float CloudAlpha; + /// Drift speed (u/s) across the ground. + public static float CloudDriftSpeed; + + // ---- Blight lightning flicker (Blight-biome only) ---- + /// Master gate for the Blight lightning beat. + public static bool LightningEnabled; + /// Min / max seconds between lightning strikes. + public static float LightningMinInterval; + public static float LightningMaxInterval; + /// Peak flash directional-light intensity. + public static float LightningIntensity; + /// Flash tint (cool blight white-purple). + public static Color LightningColor; + /// Thunder SFX volume (delayed after the flash). + public static float ThunderVolume; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + public static void ResetDefaults() + { + Enabled = true; + + CrittersEnabled = true; + CritterCount = 10; + CritterRadius = 22f; + CritterFleeRadius = 4f; + CritterIdleSpeed = 0.6f; + CritterFleeSpeed = 6f; + BirdFraction = 0.25f; + + CloudsEnabled = true; + CloudCount = 3; + CloudSize = 18f; + CloudAlpha = 0.20f; + CloudDriftSpeed = 1.6f; + + LightningEnabled = true; + LightningMinInterval = 6f; + LightningMaxInterval = 16f; + LightningIntensity = 2.4f; + LightningColor = new Color(0.75f, 0.7f, 1f); + ThunderVolume = 0.45f; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs.meta new file mode 100644 index 000000000..68760c963 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 501ee5a113c66f841abbefd303ea8880 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs new file mode 100644 index 000000000..d436c18e4 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs @@ -0,0 +1,322 @@ +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; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs.meta new file mode 100644 index 000000000..ecfb40474 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3b872fb243ca8648a9be6697f40d994 \ No newline at end of file