Files
Project-M/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs
T
kronic 90d4bed381 Feel: ambient motion layer + art look locked (clean dark-Synty, pixel OFF)
Art look LOCKED by the operator from the artlook_* side-by-sides:
clean dark-Synty; PixelOutline._MasterEnabled=0 shipped (feature stays
dev-toggleable via F3; other fullscreen effects remain backlog).

New AmbientMotionSystem (client-only PresentationSystemGroup, the
"world alive" anti-static beat, zero netcode):
- per-biome DRIFT particles from one world-space emitter following the
  camera (meadow motes / arid wind-blown dust / cavern motes / blight
  spores; palette keys on camera-X region + replicated room biome,
  mirroring WorldAtmosphereSystem's switch)
- idle SWAY on the ACTIVE cosmetic flora prop roots near the camera
  (LOD children follow the root; probed: none static-batched)
- walk-through RUSTLE: flora brushed by the local player
  (GhostOwnerIsLocal idiom) gets a decaying energy shake
Rotations write around a cached base (lossless when toggled off);
knobs in AmbientMotionConfig (scene object, code defaults when absent).

Verified live: drift palettes exact in both regions (meadow
0.62/0.85/0.60 -> arid 0.85/0.68/0.45 on region cross), flora beside
the player rocked (0,0)->(-4.0,+5.0) deg within the rustle+sway
envelope, 466/466 EditMode, console clean. Discovery for follow-up:
the BASE biome's flora props are authored INACTIVE (84 bushes etc.) -
the base has no sway targets until some are re-enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:01:14 -07:00

255 lines
11 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 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 <see cref="WorldAtmosphereSystem"/>'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 <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>; wall-clock time is fine
/// (presentation). Rotations write around a cached base so the layer is lossless when toggled off.
/// Knobs live in <see cref="AmbientMotionConfig"/> (defaults when absent).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class AmbientMotionSystem : SystemBase
{
static readonly System.Text.RegularExpressions.Regex FloraName = new System.Text.RegularExpressions.Regex(
"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> _flora = new List<Flora>(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();
foreach (var root in scene.GetRootGameObjects())
{
if (root.name != "BaseBiome" && !root.name.StartsWith("ExpeditionBiome")) continue;
foreach (var t in root.GetComponentsInChildren<Transform>(false))
{
if (!FloraName.IsMatch(t.gameObject.name)) continue;
if (t.gameObject.name.Contains("LOD")) continue; // prop roots only; LOD children follow
if (t.GetComponent<LODGroup>() == null && t.GetComponent<MeshRenderer>() == null) 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;
}
// palette key: base hub vs the replicated per-room biome (WorldAtmosphereSystem's switch shape)
int key = 0; // 0 = meadow/base
if (_cam.transform.position.x > 500f)
{
key = 1; // arid default
if (SystemAPI.TryGetSingleton<RunInfo>(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;
}
}
}
if (key != _biomeKey)
{
_biomeKey = key;
var main = _drift.main;
main.startSize = cfg != null ? cfg.DriftSize : 0.09f;
Color c;
Vector3 wind;
switch (key)
{
case 1: c = cfg != null ? cfg.AridDustColor : new Color(0.85f, 0.68f, 0.45f, 0.28f); wind = cfg != null ? cfg.AridWind : new Vector3(2.2f, -0.15f, 0.5f); break;
case 2: c = cfg != null ? cfg.CavernMoteColor : new Color(0.55f, 0.65f, 0.95f, 0.33f); wind = new Vector3(0f, 0.18f, 0f); break;
case 3: c = cfg != null ? cfg.BlightSporeColor : new Color(0.72f, 0.50f, 0.85f, 0.33f); wind = new Vector3(0f, 0.28f, 0f); break;
default: c = cfg != null ? cfg.MeadowMoteColor : new Color(0.62f, 0.85f, 0.60f, 0.35f); wind = new Vector3(0f, 0.22f, 0f); break;
}
main.startColor = c;
var vel = _drift.velocityOverLifetime;
vel.enabled = true;
vel.space = ParticleSystemSimulationSpace.World;
vel.x = wind.x; vel.y = wind.y; vel.z = wind.z;
var emission = _drift.emission;
emission.rateOverTime = cfg != null ? cfg.DriftRate : 28f;
}
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<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
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;
}
}
}
}