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 (!ScenePolicy.IsGameplayScene()) { 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;
}
}
}