b407f7cd6f
Found by running the A0 style-proof against the SHIPPING scene instead of ArtStaging (which judges static glTF bakes on an import shader, with zero SkinnedMeshRenderers and no Animator — it cannot answer "does the game look like this"). Player-facing bugs fixed: - AC_PlayerTopDown/AC_EnemyTopDown "Death" had ZERO outgoing transitions. Any State enters on IsDead and nothing ever leaves. The player ghost is a persistent entity, so its Rukhanka animator never resets: once you died you rendered sprawled on the seabed permanently — sim fully respawned, moving and fighting, visually a corpse. Added Death -> Idle on (IsDead == false). - AmbientMotionSystem drift was still the deleted biome system: key 0 = "meadow", MeadowMoteColor (0.62, 0.85, 0.60) = GREEN motes, 600 of them in a 36x7x24 box following the camera — the most off-palette thing in the frame. Its x>500 region probe pointed at space now holding zero renderers. Collapsed to one cold marine particulate; dropped the 4 dead mote colours + AridWind. - FeedbackFx.MakeParticleMaterial built Sprites/Default with NO texture, so every procedural particle rendered as a hard SQUARE. Added a procedural soft dot (no Resources.Load — build-stripped; asset-free presentation is the rule). - Game.unity carried a stock Unity "Directional Light" (warm-WHITE, 1.05, Soft) outranking KeyWarm as a second competing shadow caster, plus 6 orphaned LandmarkLights — four at x=1030/1530 lighting a kilometre of empty water, two with no renderer within 12u, two of them purple. All 7 deleted. Added RimCold for silhouette separation; key:fill set to the tuned 3.9:1; WarmPool -> Soft. ArtStaging (kept as a lighting lab): KeyWarm 0.15 -> 0.85 — it was the only shadow-caster yet sat BELOW GloamFill 0.22, which destroys form by definition and is what read as "flat". StagingAmbiance was attached to nothing and its flora wiring searched for a Game.unity parent, which is what read as "static"; it also force-played the combat one-shot pool, now restricted to looping emitters. Flicker raised to a measured x2.11 cold swing vs x1.03 warm. 304/304 EditMode green; death-recovery, palette and particulate all verified live in Game.unity Play. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
239 lines
10 KiB
C#
239 lines
10 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;
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|