Files
Project-M/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
T
kronic 4febf3dfe2 Hazard: Blight geyser — permanent periodic both-sides AoE (Phase 1.5b bundle 3)
Review-first netcode slice (design review wf_900e9965-8f0 -> 11 folded;
post-impl wf_5c8299f8-299 clean). A PERMANENT periodic telegraphed AoE in
Blight-biome rooms only; one new [GhostField] uint NextEruptTick.

- Geyser component + GeyserEruptSystem (server): inverted invalid-tick guard
  (a baked-0 tick must NEVER erupt -> lazy-stamps born-correct instead of the
  party-wiping per-tick barrage), inline both-sides gather (SourceNetworkId=-1),
  reschedule = now + period (never +=), never destroyed.
- GeyserTelegraphSystem (client): absolute-tick growing warning disc +
  erupt burst on the >0->=<0 crossing, latched per NextEruptTick + arm-guard
  (never edge-detects the replicated field -> no phantom on 0->stamp /
  relevancy re-entry). Reuses ScorchDecalSystem + DynamicLightSystem.
- Seeded in RoomFieldSystem (Blight-gated on plan.Biome, born-correct staggered
  stamp from live ServerTick), GeyserFieldSpawner + authoring wired into the
  Gameplay subscene. Geyser.prefab duplicated from ResourceNode (no collider).
- BuildDisc promoted to FeedbackFx (shared with the barrel fuse ring).
- 474/474 EditMode incl. the unstamped-storm regression; live no-storm end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:53:48 -07:00

209 lines
9.9 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;
}
}
}