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>
268 lines
13 KiB
C#
268 lines
13 KiB
C#
using UnityEngine;
|
||
|
||
namespace ProjectM.Client
|
||
{
|
||
/// <summary>
|
||
/// Shared asset-free procedural-FX toolkit for the client presentation FEEDBACK systems (Combat / Structure /
|
||
/// World / Ambient), which previously each carried a near-identical private copy. Pure UnityEngine (no ECS), so
|
||
/// it stays a plain static helper. MakeBurst takes the owning FX-root transform + the per-use gravity / shape
|
||
/// radius / size-tail that were the ONLY real differences between the old copies (Combat 0/0.06/0.2,
|
||
/// Structure 0.3/0.18/0.15, World 0.25/0.10/0.2). MakeClip folds in the noise + envelope-decay knobs that were
|
||
/// the only differences between the CombatFeedback MakeClip and the AmbientAudio MakeSting.
|
||
/// </summary>
|
||
public static class FeedbackFx
|
||
{
|
||
public static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise = false, float decay = 5f)
|
||
{
|
||
const int rate = 44100;
|
||
int len = Mathf.Max(16, (int)(dur * rate));
|
||
var clip = AudioClip.Create(name, len, 1, rate, false);
|
||
var data = new float[len];
|
||
var rng = new System.Random(name.Length * 9973 + 7);
|
||
float phase = 0f;
|
||
for (int i = 0; i < len; i++)
|
||
{
|
||
float t = i / (float)len;
|
||
float env = Mathf.Exp(-decay * t);
|
||
float freq = Mathf.Lerp(f0, f1, t);
|
||
phase += 2f * Mathf.PI * freq / rate;
|
||
float s = noise ? (float)(rng.NextDouble() * 2.0 - 1.0) : Mathf.Sin(phase);
|
||
data[i] = s * env * vol;
|
||
}
|
||
clip.SetData(data, 0);
|
||
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;
|
||
// HDR start colours still push past the bloom threshold); fall back through URP unlit / Unlit/Color.
|
||
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, mainTexture = SoftDot() };
|
||
}
|
||
|
||
public static ParticleSystem MakeBurst(Transform parent, string name, Material mat, Color color,
|
||
float size, float speed, float life, int max,
|
||
float gravity = 0f, float shapeRadius = 0.06f, float sizeTail = 0.2f)
|
||
{
|
||
var go = new GameObject(name);
|
||
go.transform.SetParent(parent, false);
|
||
var ps = go.AddComponent<ParticleSystem>();
|
||
|
||
var main = ps.main;
|
||
main.loop = false;
|
||
main.playOnAwake = false;
|
||
main.startLifetime = life;
|
||
main.startSpeed = speed;
|
||
main.startSize = size;
|
||
main.startColor = color;
|
||
main.maxParticles = max;
|
||
main.gravityModifier = gravity;
|
||
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
||
|
||
var emission = ps.emission;
|
||
emission.enabled = false; // manual Emit(count)
|
||
|
||
var shape = ps.shape;
|
||
shape.enabled = true;
|
||
shape.shapeType = ParticleSystemShapeType.Sphere;
|
||
shape.radius = shapeRadius;
|
||
|
||
var colOverLife = ps.colorOverLifetime;
|
||
colOverLife.enabled = true;
|
||
var grad = new Gradient();
|
||
grad.SetKeys(
|
||
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
|
||
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
|
||
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
|
||
|
||
var sizeOverLife = ps.sizeOverLifetime;
|
||
sizeOverLife.enabled = true;
|
||
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, sizeTail));
|
||
|
||
var renderer = ps.GetComponent<ParticleSystemRenderer>();
|
||
renderer.material = mat;
|
||
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
||
return ps;
|
||
}
|
||
|
||
public static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint)
|
||
{
|
||
if (ps == null) return;
|
||
var main = ps.main;
|
||
main.startColor = tint;
|
||
ps.transform.position = pos;
|
||
ps.Emit(count);
|
||
}
|
||
|
||
public static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
|
||
{
|
||
if (ps == null) return;
|
||
ps.transform.position = pos;
|
||
ps.Emit(count);
|
||
}
|
||
|
||
public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
||
{
|
||
if (clip == null) return;
|
||
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
|
||
}
|
||
|
||
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
|
||
|
||
// Transparent unlit ground-decal material: Sprites/Default honours vertex colour × the per-renderer _Color
|
||
// MPB, blends alpha, is double-sided (Cull Off) and writes no depth (ZWrite Off) — so flat, overlapping
|
||
// ground decals never z-fight and never cast/receive shadows. Same shader family as the fuse disc.
|
||
public static Material MakeDecalMaterial(string name = "GroundDecal")
|
||
{
|
||
Shader sh = Shader.Find("Sprites/Default");
|
||
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
|
||
if (sh == null) sh = Shader.Find("Unlit/Transparent");
|
||
return new Material(sh) { name = name, renderQueue = 3000 }; // Transparent
|
||
}
|
||
|
||
// Irregular soft radial disc on the XZ plane (already flat — scale x/z, spin about Y): vertex alpha 1 at the
|
||
// centre fading to 0 at a JITTERED rim, so it reads as an organic scorch blob with NO hard rectangle edge
|
||
// (the feathered rim is what keeps it from ghosting over fog the way an opaque quad did). White vertex colour
|
||
// so the caller's material/MPB tint (dark char) shows through.
|
||
public static Mesh BuildScorchMesh(int segments = 24, float jitter = 0.3f, int seed = 1)
|
||
{
|
||
if (segments < 6) segments = 6;
|
||
var rng = new System.Random(seed * 6151 + 13);
|
||
var m = new Mesh { name = "ScorchDecal" };
|
||
var v = new Vector3[segments + 1];
|
||
var col = new Color[segments + 1];
|
||
var tris = new int[segments * 3];
|
||
v[0] = Vector3.zero;
|
||
col[0] = new Color(1f, 1f, 1f, 1f);
|
||
for (int i = 0; i < segments; i++)
|
||
{
|
||
float a = i / (float)segments * Mathf.PI * 2f;
|
||
float r = 1f - jitter * (float)rng.NextDouble();
|
||
v[i + 1] = new Vector3(Mathf.Cos(a) * r, 0f, Mathf.Sin(a) * r);
|
||
col[i + 1] = new Color(1f, 1f, 1f, 0f); // transparent feathered rim
|
||
int n = (i + 1) % segments;
|
||
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
|
||
}
|
||
m.vertices = v; m.colors = col; m.triangles = tris;
|
||
m.RecalculateBounds();
|
||
return m;
|
||
}
|
||
|
||
// A jagged crack star on the XZ plane: `spokes` thin tapered triangles radiating from the centre at random
|
||
// angles/lengths, opaque at the base and feathered to nothing at the tip. Reads as surface cracking, visually
|
||
// distinct from the scorch blob. White vertex colour; caller tints dark and scales it to the piece.
|
||
public static Mesh BuildCrackMesh(int spokes = 3, int seed = 1)
|
||
{
|
||
if (spokes < 1) spokes = 1;
|
||
var rng = new System.Random(seed * 9277 + 5);
|
||
var m = new Mesh { name = "CrackDecal" };
|
||
var v = new Vector3[spokes * 3];
|
||
var col = new Color[spokes * 3];
|
||
var tris = new int[spokes * 3];
|
||
for (int s = 0; s < spokes; s++)
|
||
{
|
||
float a = (s / (float)spokes) * Mathf.PI * 2f + (float)(rng.NextDouble() - 0.5) * 1.2f;
|
||
float len = 0.6f + 0.4f * (float)rng.NextDouble();
|
||
float w = 0.06f + 0.05f * (float)rng.NextDouble();
|
||
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||
var perp = new Vector3(-dir.z, 0f, dir.x) * w;
|
||
int b = s * 3;
|
||
v[b] = perp; v[b + 1] = -perp; v[b + 2] = dir * len;
|
||
col[b] = new Color(1f, 1f, 1f, 1f);
|
||
col[b + 1] = new Color(1f, 1f, 1f, 1f);
|
||
col[b + 2] = new Color(1f, 1f, 1f, 0f); // feathered tip
|
||
tris[b] = b; tris[b + 1] = b + 2; tris[b + 2] = b + 1;
|
||
}
|
||
m.vertices = v; m.colors = col; m.triangles = tris;
|
||
m.RecalculateBounds();
|
||
return m;
|
||
}
|
||
|
||
// Unit-radius upward-facing disc fan on the XZ plane (centre vertex + rim) for ground telegraph / blast-radius
|
||
// rings — scale x/z to the radius. No vertex colours (white default) so the material or a per-renderer _Color
|
||
// MPB fully tints + fades it. Promoted from WorldFeedbackSystem so the barrel fuse ring + the geyser telegraph
|
||
// share ONE primitive (review M6).
|
||
public static Mesh BuildDisc(int segments)
|
||
{
|
||
if (segments < 6) segments = 6;
|
||
var m = new Mesh { name = "Disc" };
|
||
var v = new Vector3[segments + 1];
|
||
var tris = new int[segments * 3];
|
||
v[0] = Vector3.zero;
|
||
for (int i = 0; i < segments; i++)
|
||
{
|
||
float a = i / (float)segments * Mathf.PI * 2f;
|
||
v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||
int n = (i + 1) % segments;
|
||
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
|
||
}
|
||
m.vertices = v;
|
||
m.triangles = tris;
|
||
m.RecalculateBounds();
|
||
return m;
|
||
}
|
||
|
||
// Unit-radius thin RING (annulus) on the XZ plane — the always-on rim of a zone telegraph (rim = the TRUE
|
||
// damage radius, guidelines G2) while a separate fill disc grows inside it. Scale x/z to the radius; inner
|
||
// edge fixed at innerFrac of the outer. No vertex colours (white) so an MPB _Color fully tints + fades it.
|
||
public static Mesh BuildRing(int segments, float innerFrac = 0.92f)
|
||
{
|
||
if (segments < 6) segments = 6;
|
||
innerFrac = Mathf.Clamp(innerFrac, 0.05f, 0.98f);
|
||
var m = new Mesh { name = "Ring" };
|
||
var v = new Vector3[segments * 2];
|
||
var tris = new int[segments * 6];
|
||
for (int i = 0; i < segments; i++)
|
||
{
|
||
float a = i / (float)segments * Mathf.PI * 2f;
|
||
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||
v[i * 2] = dir * innerFrac;
|
||
v[i * 2 + 1] = dir;
|
||
int n = (i + 1) % segments;
|
||
int b = i * 6;
|
||
tris[b] = i * 2; tris[b + 1] = n * 2 + 1; tris[b + 2] = i * 2 + 1;
|
||
tris[b + 3] = i * 2; tris[b + 4] = n * 2; tris[b + 5] = n * 2 + 1;
|
||
}
|
||
m.vertices = v;
|
||
m.triangles = tris;
|
||
m.RecalculateBounds();
|
||
return m;
|
||
}
|
||
}
|
||
}
|