using UnityEngine;
namespace ProjectM.Client
{
///
/// 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.
///
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;
}
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 };
}
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();
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();
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;
}
}
}