Art/Fix: A0 gate pass — death-pose lock, off-palette drift, lighting debris

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>
This commit is contained in:
2026-08-07 14:45:18 -07:00
parent c87dd04e87
commit b407f7cd6f
10 changed files with 359 additions and 850 deletions
@@ -316,7 +316,8 @@ AnimatorState:
m_Name: Death
m_Speed: 1
m_CycleOffset: 0
m_Transitions: []
m_Transitions:
- {fileID: 8293450300144498373}
m_StateMachineBehaviours: []
m_Position: {x: 50, y: 50, z: 0}
m_IKOnFeet: 0
@@ -508,6 +509,31 @@ AnimatorState:
m_MirrorParameter:
m_CycleOffsetParameter:
m_TimeParameter:
--- !u!1101 &8293450300144498373
AnimatorStateTransition:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name:
m_Conditions:
- m_ConditionMode: 2
m_ConditionEvent: IsDead
m_EventTreshold: 0
m_DstStateMachine: {fileID: 0}
m_DstState: {fileID: 8302574255206078140}
m_Solo: 0
m_Mute: 0
m_IsExit: 0
serializedVersion: 3
m_TransitionDuration: 0.25
m_TransitionOffset: 0
m_ExitTime: 0.9
m_HasExitTime: 0
m_HasFixedDuration: 1
m_InterruptionSource: 0
m_OrderedInterruption: 1
m_CanTransitionToSelf: 1
--- !u!1102 &8302574255206078140
AnimatorState:
serializedVersion: 6
@@ -317,6 +317,31 @@ AnimatorState:
m_MirrorParameter:
m_CycleOffsetParameter:
m_TimeParameter:
--- !u!1101 &-3626785697368707688
AnimatorStateTransition:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name:
m_Conditions:
- m_ConditionMode: 2
m_ConditionEvent: IsDead
m_EventTreshold: 0
m_DstStateMachine: {fileID: 0}
m_DstState: {fileID: 8302574255206078140}
m_Solo: 0
m_Mute: 0
m_IsExit: 0
serializedVersion: 3
m_TransitionDuration: 0.25
m_TransitionOffset: 0
m_ExitTime: 0.9
m_HasExitTime: 0
m_HasFixedDuration: 1
m_InterruptionSource: 0
m_OrderedInterruption: 1
m_CanTransitionToSelf: 1
--- !u!206 &-3541598292348706530
BlendTree:
m_ObjectHideFlags: 1
@@ -892,7 +917,8 @@ AnimatorState:
m_Name: Death
m_Speed: 1
m_CycleOffset: 0
m_Transitions: []
m_Transitions:
- {fileID: -3626785697368707688}
m_StateMachineBehaviours: []
m_Position: {x: 50, y: 50, z: 0}
m_IKOnFeet: 0
@@ -25,8 +25,8 @@ Material:
m_TexEnvs: []
m_Ints: []
m_Floats:
- _FlickerAmount: 0.35
- _FlickerSpeed: 5
- _FlickerAmount: 0.6
- _FlickerSpeed: 2.6
- _Seed: 0
m_Colors:
- _EmissionColor: {r: 0.28, g: 2.1000001, b: 3.5, a: 3.5}
@@ -25,9 +25,9 @@ Material:
m_TexEnvs: []
m_Ints: []
m_Floats:
- _FlickerAmount: 0.4
- _FlickerSpeed: 5.5
- _Seed: 0
- _FlickerAmount: 0.7
- _FlickerSpeed: 3
- _Seed: 2.7
m_Colors:
- _EmissionColor: {r: 0.45000002, g: 2.85, b: 1.2, a: 3}
m_BuildTextureStacks: []
@@ -16,15 +16,12 @@ namespace ProjectM.Client
[Header("Master")]
public bool Enabled = true;
[Header("Biome drift particles (emitter follows the camera; sim space stays world)")]
[Header("Ambient drift particles (emitter follows the camera; sim space stays world)")]
// 2026-08-07: the four per-biome mote colours + AridWind went with the expedition — LANTERN is one look,
// so the colour is fixed cold in AmbientMotionSystem. Only rate and size stay tunable.
public bool DriftEnabled = true;
[Min(0f)] public float DriftRate = 28f;
[Min(0.01f)] public float DriftSize = 0.09f;
public Color MeadowMoteColor = new Color(0.62f, 0.85f, 0.60f, 0.35f);
public Color AridDustColor = new Color(0.85f, 0.68f, 0.45f, 0.28f);
public Color CavernMoteColor = new Color(0.55f, 0.65f, 0.95f, 0.33f);
public Color BlightSporeColor = new Color(0.72f, 0.50f, 0.85f, 0.33f);
public Vector3 AridWind = new Vector3(2.2f, -0.15f, 0.5f);
[Min(0f)] public float DriftRate = 18f;
[Min(0.01f)] public float DriftSize = 0.07f;
[Header("Idle foliage sway (root tilt) — OFF by default since 1.5b: ALL Synty flora uses the Synty/Foliage")]
[Header("VERTEX-wind shader, so root tilt double-animates. Keep for A/B; rustle below stays the reactive layer.")]
@@ -147,35 +147,22 @@ namespace ProjectM.Client
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)
// 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)
{
key = 1; // arid default
// 2026-08-07 audit purge: per-room biome selection keyed off RunInfo.CurrentBiome, which went
// with the run FSM. The expedition region keeps its single motion set.
}
if (key != _biomeKey)
{
_biomeKey = key;
_biomeKey = 0;
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;
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 = wind.x; vel.y = wind.y; vel.z = wind.z;
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 : 28f;
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
@@ -33,6 +33,38 @@ namespace ProjectM.Client
return clip;
}
static Texture2D s_softDot;
/// <summary>
/// Sprites/Default with NO texture renders hard SQUARES — which is what the ambient drift was painting
/// across the seabed (found at the 2026-08-07 A0 gate). Build the dot procedurally instead of referencing
/// an asset: runtime Resources.Load is stripped from player builds, and asset-free presentation is the
/// house rule. Self-healing across fast-enter-playmode — a destroyed texture compares == null and rebuilds.
/// </summary>
public static Texture2D SoftDot()
{
if (s_softDot != null) return s_softDot;
const int N = 32;
var t = new Texture2D(N, N, TextureFormat.RGBA32, false)
{
name = "T_SoftDotProc",
wrapMode = TextureWrapMode.Clamp
};
for (int y = 0; y < N; y++)
{
for (int x = 0; x < N; x++)
{
float dx = (x + 0.5f) / N * 2f - 1f;
float dy = (y + 0.5f) / N * 2f - 1f;
float a = Mathf.Clamp01(1f - Mathf.Sqrt(dx * dx + dy * dy));
t.SetPixel(x, y, new Color(1f, 1f, 1f, a * a)); // squared = softer falloff
}
}
t.Apply();
s_softDot = t;
return t;
}
public static Material MakeParticleMaterial(string name = "FeedbackParticle")
{
// Sprites/Default is an always-included transparent vertex-coloured shader (reliable billboarded sparks;
@@ -40,7 +72,7 @@ namespace ProjectM.Client
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
return new Material(sh) { name = name };
return new Material(sh) { name = name, mainTexture = SoftDot() };
}
public static ParticleSystem MakeBurst(Transform parent, string name, Material mat, Color color,
@@ -3,8 +3,15 @@ using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Dev-only ambiance animator for the ArtStaging scene. Play-mode; modulates LIGHTS only
/// (no material/asset writes) so nothing persists on Play exit. Self-wires by object name.
/// Dev-only ambiance animator for the ArtStaging scene. Play-mode; touches LIGHTS, TRANSFORMS of dev-only
/// objects, and particle play-state — never materials or assets — so nothing persists on Play exit.
/// Self-wires by object name.
///
/// 2026-08-07 (A0 gate, operator: "it looks flat and static"): this component existed but was attached to
/// NOTHING, so the staging scene had never actually animated. Its flora wiring also looked for a "FloraLights"
/// parent that does not exist in the scene — it now matches lights by name prefix instead. Added the motion a
/// static-bake scene cannot get from animation: camera drift, buoyancy bob on the posed heroes, and a current
/// on the particulate. The readability law is unchanged: STEADY warm = true light, GUTTERING cold = false.
/// </summary>
[DisallowMultipleComponent]
public sealed class StagingAmbiance : MonoBehaviour
@@ -12,52 +19,106 @@ namespace ProjectM.Client
[Header("Caustics")]
public float causticsSpinDegPerSec = 4f;
[Header("Warm beacon flicker")]
public float beaconFlickerAmount = 0.06f;
[Header("Bioluminescent pulse (cold = false light)")]
public float floraPulseAmount = 0.35f;
public float floraPulseSpeed = 1.1f;
[Header("Bioluminescent pulse")]
public float floraPulseAmount = 0.22f;
public float floraPulseSpeed = 1.4f;
[Header("Camera drift — a locked-off camera reads as a screenshot, not a place")]
public float camDriftAmplitude = 0.13f;
public float camDriftSpeed = 0.17f;
[Header("Buoyancy — the heroes are static bakes; sell the water instead")]
public float bobAmplitude = 0.045f;
public float bobSpeed = 0.55f;
public float swayDegrees = 0.9f;
Transform _caustics;
Light _beacon; float _beaconBase;
Light[] _flora; float[] _floraBase;
Transform _cam; Vector3 _camBase;
Transform[] _heroes; Vector3[] _heroBase; float[] _heroPhase;
void Awake()
{
var c = GameObject.Find("CausticsSpot");
if (c != null) _caustics = c.transform;
var b = GameObject.Find("WarmPool");
if (b != null) { _beacon = b.GetComponent<Light>(); if (_beacon != null) _beaconBase = _beacon.intensity; }
// Match by NAME, not by parent — the scene has no "FloraLights" container (that wiring silently
// matched nothing, which is half of why the scene looked static).
var flora = new System.Collections.Generic.List<Light>();
foreach (var l in Object.FindObjectsByType<Light>(FindObjectsSortMode.None))
if (l.gameObject.name.StartsWith("FloraGlow")) flora.Add(l);
_flora = flora.ToArray();
_floraBase = new float[_flora.Length];
for (int i = 0; i < _flora.Length; i++) _floraBase[i] = _flora[i].intensity;
var fl = GameObject.Find("FloraLights");
if (fl != null)
{
_flora = fl.GetComponentsInChildren<Light>();
_floraBase = new float[_flora.Length];
for (int i = 0; i < _flora.Length; i++) _floraBase[i] = _flora[i].intensity;
}
var hero = new System.Collections.Generic.List<Transform>();
foreach (var go in Object.FindObjectsByType<Transform>(FindObjectsSortMode.None))
if (go.parent == null && go.name.StartsWith("Hero_")) hero.Add(go);
_heroes = hero.ToArray();
_heroBase = new Vector3[_heroes.Length];
_heroPhase = new float[_heroes.Length];
for (int i = 0; i < _heroes.Length; i++) { _heroBase[i] = _heroes[i].position; _heroPhase[i] = i * 2.1f; }
// Particulate: only start LOOPING ambient emitters. "Bubbles" under ~CombatFeedbackFX is the combat
// one-shot pool (loop=false, duration 5) — force-playing it just fired the burst once and left it
// stopped, which read as "the scene is static". Never drive the feedback pool from here.
foreach (var ps in Object.FindObjectsByType<ParticleSystem>(FindObjectsSortMode.None))
if (ps.main.loop && !ps.isPlaying) ps.Play();
}
void Update()
void OnEnable() => CacheCamera();
void CacheCamera()
{
var cam = Camera.main != null ? Camera.main : Object.FindFirstObjectByType<Camera>();
if (cam == null) return;
if (_cam != cam.transform) { _cam = cam.transform; _camBase = _cam.position; }
}
void LateUpdate()
{
float t = Time.time;
// caustics slowly drift across the seabed
if (_caustics != null)
_caustics.Rotate(0f, causticsSpinDegPerSec * Time.deltaTime, 0f, Space.World);
// readability law: STEADY = true light. The warm beacon is left unmodulated (steady) on purpose.
// FLICKER = false/counterfeit light -> the cold gloam pools gutter with organic Perlin flicker,
// so "ours vs the deep's" reads through cadence even with hue/value removed.
// Readability law: STEADY = true light, so the warm beacon is deliberately NOT modulated here.
// The cold gloam pools gutter on organic Perlin noise — "ours vs the deep's" reads through cadence
// even with hue and value removed.
if (_flora != null)
for (int i = 0; i < _flora.Length; i++)
{
float phase = i * 3.1f;
float flick = 0.55f + 0.45f * Mathf.PerlinNoise(t * floraPulseSpeed * 1.8f + phase, phase * 0.7f);
float n = Mathf.PerlinNoise(t * floraPulseSpeed * 1.8f + phase, phase * 0.7f);
float flick = (1f - floraPulseAmount) + floraPulseAmount * 2f * n;
_flora[i].intensity = _floraBase[i] * flick;
}
// A locked-off camera in a still scene reads as a screenshot. A slow lissajous drift (centimetres,
// not metres) is enough to make the murk feel like water you are suspended in.
CacheCamera();
if (_cam != null)
{
float a = camDriftAmplitude;
_cam.position = _camBase + new Vector3(
Mathf.Sin(t * camDriftSpeed) * a,
Mathf.Sin(t * camDriftSpeed * 1.37f + 1.1f) * a * 0.6f,
Mathf.Cos(t * camDriftSpeed * 0.83f) * a);
}
// The heroes are posed static bakes with no rig bound, so they cannot idle. A buoyancy bob + micro-sway
// costs nothing and sells "suspended in water" — which is what the frame is actually claiming.
if (_heroes != null)
for (int i = 0; i < _heroes.Length; i++)
{
if (_heroes[i] == null) continue;
float p = t * bobSpeed + _heroPhase[i];
_heroes[i].position = _heroBase[i] + new Vector3(0f, Mathf.Sin(p) * bobAmplitude, 0f);
_heroes[i].rotation = Quaternion.Euler(
Mathf.Sin(p * 0.7f) * swayDegrees * 0.5f,
_heroes[i].rotation.eulerAngles.y,
Mathf.Cos(p * 0.9f) * swayDegrees);
}
}
}
}