e0c59ad663
Track B. All 21 one-shot cues funnelled through FeedbackFx.PlayClip -> AudioSource.PlayClipAtPoint, which allocates a GameObject + AudioSource per call and schedules a delayed Destroy — ~20-33 times a second in light combat. New OneShotAudioPool is a 32-voice 3D ring behind an UNCHANGED PlayClip signature, so all 20 consuming call sites are untouched. Parity is the whole game here: PlayClipAtPoint sets spatialBlend = 1 explicitly (a fresh AudioSource is 2D) and leaves the rest at stock defaults. Two deliberate divergences, both forced by the voices being long-lived: playOnAwake = false, and dopplerLevel = 0 because a pooled voice TELEPORTS between events and would otherwise pitch-bend. Root is DontDestroyOnLoad (WorldLauncher does LoadScene(Single) while the client world is alive) with a SubsystemRegistration reset, or session two rents destroyed voices. Authored impact VFX are pooled per prefab instead of Instantiate/Destroy per hit: components cached per INSTANCE (refs are instance-scoped), main.stopAction forced to None (a prefab set to Destroy silently drains the pool), instances filled under an inactive root so Awake/Start never run — which is what makes the DestroyImmediate in StripCosmetic safe — ps.Clear before Play, TrailRenderer.Clear after the reposition, and a Rented flag as the at-most-once guard against a double Return aliasing one instance to two callers. Per-frame allocation: the slash-arc and enemy-wedge mesh builders each allocated four arrays on every call (up to twice a frame, and once per winding enemy); HUD and ability-bar labels rebuilt their strings every frame; damage-number fades rewrote TextMesh vertex colours every frame; health bars pushed uGUI writes unconditionally; two systems played back an empty EntityCommandBuffer (a structural-change sync point) every frame. Also closes an AudioClip leak across all seven clip-owning systems: an AudioClip.Create'd clip is a standalone UnityEngine.Object, so destroying a system's FX root left it alive (MusicSystem ~6.8 MB, AmbientAudioSystem ~2 MB per client-world teardown). CombatFeedbackSystem's TryHold call sites go with this commit because they share the file; the camera-side removal lands in the next one. Verified live: PlayClipAtPoint's "One shot audio" GameObject never appears again across 270 frames of combat with kills; the VFX pool fills to its retain cap and stabilises; real cues route through the ring. 304/304 EditMode green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
319 lines
15 KiB
C#
319 lines
15 KiB
C#
using System.Collections.Generic;
|
|
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
using UnityEngine;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// Client-only AMBIENT LIFE layer (Phase 1.5b bundle 4 — critters + weather beats, zero netcode). Sibling of
|
|
/// <see cref="AmbientMotionSystem"/>: an observe-only <see cref="SystemBase"/> in
|
|
/// <see cref="PresentationSystemGroup"/> that never touches the sim. Three cosmetic beats, all camera-following,
|
|
/// scene-gated to "Game", biome-keyed off the same camera-X + replicated <see cref="RunInfo.CurrentBiome"/>
|
|
/// switch <see cref="AmbientMotionSystem"/>/<see cref="WorldAtmosphereSystem"/> 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 <see cref="AmbientLifeConfig"/>.
|
|
/// </summary>
|
|
[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<Critter> _critters = new();
|
|
readonly List<Cloud> _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);
|
|
FeedbackFx.DestroyClip(ref _thunderClip); // not owned by _root — see FeedbackFx.DestroyClip
|
|
}
|
|
|
|
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
|
|
// 2026-08-07 audit purge: per-room biome selection keyed off RunInfo.CurrentBiome. The room
|
|
// biomes went with the run FSM; the expedition region keeps its single ambient set.
|
|
}
|
|
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<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
|
|
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<MeshFilter>().sharedMesh = _critterMesh;
|
|
var mr = go.AddComponent<MeshRenderer>();
|
|
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<MeshFilter>().sharedMesh = _cloudMesh;
|
|
var mr = go.AddComponent<MeshRenderer>();
|
|
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<MeshRenderer>().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<Light>();
|
|
_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;
|
|
}
|
|
}
|
|
}
|