13d591e789
Track 1. Before building any new "world responds to you" work, I checked what
already existed, because today's score was three presentation systems that were
present, enabled, and produced nothing. This is the fourth.
AmbientMotionSystem's rustle layer (flora parting as the local player brushes
past) was dead in the shipping arena via THREE independent gates, each a
leftover of the deleted biomes:
1. Root gate required a scene root literally named "BaseBiome" or
"ExpeditionBiome*". Both went with the expedition, so the loop matched
nothing at all. The seabed flora live under
Env_SeabedKit/Seabed_Flora/FloraClump_N.
2. Name regex was pure LAND flora: bush|grass|flower|fern|cactif|clover...
Nothing in a seabed matches that. Added seabed terms first; the land terms
stay only so an older scene still works.
3. Renderer gate tested for a renderer ON the matched object. The natural sway
unit is the CLUMP ROOT, which carries no renderer of its own - only
children - so the test rejected exactly the object we want to rotate. Now
accepts a renderer anywhere beneath, and skips a child whose parent already
matched so the clump tilts once instead of every stalk tilting out of its
socket.
Verified live: AmbientMotionSystem._flora.Count 0 -> 4 (the four clump roots).
304/304 EditMode green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
12 KiB
C#
251 lines
12 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
|
|
{
|
|
// 2026-08-08: seabed terms FIRST. The old list was pure LAND flora (bush/grass/flower/fern/cactus)
|
|
// from the deleted meadow + arid biomes, so it matched NOTHING in the shipping seabed arena and the
|
|
// whole walk-through rustle layer was dead. The land terms are kept only so an old scene still works.
|
|
static readonly System.Text.RegularExpressions.Regex FloraName = new System.Text.RegularExpressions.Regex(
|
|
"floraclump|flora_|kelp|frond|seagrass|seaweed|coral|anemone"
|
|
+ "|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();
|
|
// 2026-08-08: scan EVERY root. The old gate required a root literally named "BaseBiome" or
|
|
// "ExpeditionBiome*" — both went with the expedition, so this loop matched nothing and the rustle
|
|
// layer silently did nothing in the shipping arena. The seabed flora live under
|
|
// Env_SeabedKit/Seabed_Flora/FloraClump_N.
|
|
foreach (var root in scene.GetRootGameObjects())
|
|
{
|
|
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
|
|
// The sway unit is the CLUMP ROOT, which carries no renderer of its own — only children.
|
|
// The old renderer-on-self test rejected exactly the object we want to rotate, so accept a
|
|
// renderer anywhere beneath. Skip anything whose PARENT already matched, so we tilt the
|
|
// clump once instead of tilting every stalk out of its socket.
|
|
if (t.parent != null && FloraName.IsMatch(t.parent.gameObject.name)) continue;
|
|
if (t.GetComponent<LODGroup>() == null && t.GetComponentInChildren<MeshRenderer>() == null) continue;
|
|
// 1.5b: ground-flush carpet props (Flowers_Flat, Grass_*_Plane) must NEVER root-tilt —
|
|
// any rotation lifts their edges off the ground; they stay fully static (shader wind only).
|
|
if (t.gameObject.name.IndexOf("Flat", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| t.gameObject.name.IndexOf("Plane", System.StringComparison.OrdinalIgnoreCase) >= 0) 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;
|
|
}
|
|
|
|
// 2026-08-07 (A0 gate): the per-biome drift — meadow / arid / cavern / blight — went with the
|
|
// expedition. LANTERN is ONE look, so this collapses to a single cold marine particulate. The old
|
|
// "meadow" default was painting GREEN motes across the whole seabed (the most off-palette thing in
|
|
// the shipping frame), and the x>500 region probe pointed at space that now holds zero renderers.
|
|
if (_biomeKey != 0)
|
|
{
|
|
_biomeKey = 0;
|
|
var main = _drift.main;
|
|
main.startSize = cfg != null ? cfg.DriftSize : 0.07f;
|
|
main.startColor = new Color(0.55f, 0.80f, 0.95f, 0.26f); // cold: warm is reserved for OURS
|
|
var vel = _drift.velocityOverLifetime;
|
|
vel.enabled = true;
|
|
vel.space = ParticleSystemSimulationSpace.World;
|
|
vel.x = 0f; vel.y = 0.16f; vel.z = 0f; // detritus rising up the water column
|
|
var emission = _drift.emission;
|
|
emission.rateOverTime = cfg != null ? cfg.DriftRate : 18f;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|