Files
Project-M/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
T
kronic 2aebc37115 G6+G4: Cone damage-at-contact + zone fill telegraph (ZoneEffect GhostFields) + co-op saturation budget
Cone/SpecialSlam damage lands at its visual contact via ConeContactPending (knob 32, 0=legacy;
early-flush + resolve re-validate; death + BOTH class-swap paths drop armed pendings — fixes the
shipped melee death-strand in the same stroke). Zones: [GhostField] Caster/Radius/NextTick +
ZoneTelegraphSystem (Geyser latch contract on InterpolationTick; rim = true radius; arm grows,
persistent phase drains). Cone cues latch to contact (FireStartRaw, C14); TuningConfig.Defaults()
fallback at client cue sites (release-build timing fix). G4: SaturationMath ally-FX degrade
(living-enemy census, solo-exempt; enemy telegraphs structurally exempt) + CombatStressDebug +
overlay saturation rows. Reviews wf_98bf1268 (13 confirmed folded) / wf_9757d214 (5 confirmed fixed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:27:50 -07:00

236 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
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<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;
}
}
}