From 52eda313601219940b38a59cc976aa4c747a5153 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 8 Jul 2026 12:59:05 -0700 Subject: [PATCH] Hygiene B4a: extract shared FeedbackFx (dedup 4 procedural-FX copies) New FeedbackFx static (MakeClip/MakeParticleMaterial/MakeBurst/PlayClip/EmitTinted/EmitAt); the 3 *FeedbackSystem copies + AmbientAudio.MakeSting now route through it via 'using static'. MakeBurst takes the FX-root + the per-use gravity/radius/sizeTail that were the only diffs between copies; MakeClip folds in noise + decay. Behaviour-identical by construction (every particle/clip param preserved exactly). 459/459 EditMode tests pass; compiles clean. VFX are presentation-only (no EditMode coverage) -> wants a Play-mode smoke to eyeball hit/death/harvest/structure bursts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Client/Presentation/AmbientAudioSystem.cs | 19 +-- .../Presentation/CombatFeedbackSystem.cs | 92 +------------- .../Scripts/Client/Presentation/FeedbackFx.cs | 113 ++++++++++++++++++ .../Client/Presentation/FeedbackFx.cs.meta | 2 + .../Presentation/StructureFeedbackSystem.cs | 80 +------------ .../Presentation/WorldFeedbackSystem.cs | 80 +------------ 6 files changed, 128 insertions(+), 258 deletions(-) create mode 100644 Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs create mode 100644 Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs.meta diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs index f17f9022e..4529342de 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs @@ -176,23 +176,6 @@ namespace ProjectM.Client } // Short one-shot tone sweeping f0->f1 with an exponential decay envelope. - static AudioClip MakeSting(float f0, float f1, float dur, float vol) - { - const int rate = 44100; - int len = Mathf.Max(16, (int)(dur * rate)); - var clip = AudioClip.Create("sting", len, 1, rate, false); - var data = new float[len]; - float phase = 0f; - for (int i = 0; i < len; i++) - { - float t = i / (float)len; - float env = Mathf.Exp(-3.5f * t); - float freq = Mathf.Lerp(f0, f1, t); - phase += 2f * Mathf.PI * freq / rate; - data[i] = Mathf.Sin(phase) * env * vol; - } - clip.SetData(data, 0); - return clip; - } +static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f); } } diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index cd21476d1..a38720bfa 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -5,6 +5,7 @@ using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; +using static ProjectM.Client.FeedbackFx; namespace ProjectM.Client { @@ -143,11 +144,11 @@ namespace ProjectM.Client _fxRoot = new GameObject("~CombatFeedbackFX").transform; var mat = MakeParticleMaterial(); - _hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256); - _deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512); - _muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128); - _dashFx = MakeBurst("DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256); - _swingFx = MakeBurst("MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256); + _hitFx = MakeBurst(_fxRoot, "HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256); + _deathFx = MakeBurst(_fxRoot, "DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512); + _muzzleFx = MakeBurst(_fxRoot, "Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128); + _dashFx = MakeBurst(_fxRoot, "DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256); + _swingFx = MakeBurst(_fxRoot, "MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256); BuildSlash(); _dangerMat = MakeParticleMaterial(); _dangerMat.name = "EnemyDanger"; @@ -748,80 +749,11 @@ namespace ProjectM.Client // ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ---- - static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise) - { - 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(-5f * 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 Material MakeParticleMaterial() - { - // Sprites/Default is an always-included, transparent, vertex-coloured shader — reliable for - // billboarded sparks; HDR start colours still push past the bloom threshold (Stage 5 look pass). - 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 = "CombatFeedbackParticle" }; - } - ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max) - { - var go = new GameObject(name); - go.transform.SetParent(_fxRoot, false); - var ps = go.AddComponent(); - var main = ps.main; - main.loop = false; - main.playOnAwake = false; - // duration unused: manual Emit bursts with emission disabled - main.startLifetime = life; - main.startSpeed = speed; - main.startSize = size; - main.startColor = color; - main.maxParticles = max; - main.gravityModifier = 0f; - main.simulationSpace = ParticleSystemSimulationSpace.World; - var emission = ps.emission; - emission.enabled = false; // we Emit(count) manually - var shape = ps.shape; - shape.enabled = true; - shape.shapeType = ParticleSystemShapeType.Sphere; - shape.radius = 0.06f; - - 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, 0.2f)); - - var renderer = ps.GetComponent(); - renderer.material = mat; - renderer.renderMode = ParticleSystemRenderMode.Billboard; - return ps; - } void BuildSlash() { @@ -1363,17 +1295,5 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int mesh.RecalculateBounds(); } - static void EmitAt(ParticleSystem ps, Vector3 pos, int count) - { - if (ps == null) return; - ps.transform.position = pos; - ps.Emit(count); - } - - static void PlayClip(AudioClip clip, Vector3 pos, float vol) - { - if (clip == null) return; - AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); - } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs new file mode 100644 index 000000000..a59e8e5d9 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs @@ -0,0 +1,113 @@ +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); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs.meta b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs.meta new file mode 100644 index 000000000..5f762f4e4 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 09c134aba1e7696478a68382239ec4dc \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs index bcb35543b..87384c754 100644 --- a/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/StructureFeedbackSystem.cs @@ -5,6 +5,7 @@ using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; +using static ProjectM.Client.FeedbackFx; namespace ProjectM.Client { @@ -49,8 +50,8 @@ namespace ProjectM.Client if (_fxRoot != null) return; _fxRoot = new GameObject("~StructureFeedbackFX").transform; var mat = MakeParticleMaterial(); - _chipFx = MakeBurst("StructChips", mat, StructureFeelConfig.DamageTint, 0.12f, 5f, 0.30f, 256); - _deathFx = MakeBurst("StructDeath", mat, StructureFeelConfig.DeathTint, 0.20f, 8f, 0.55f, 512); + _chipFx = MakeBurst(_fxRoot, "StructChips", mat, StructureFeelConfig.DamageTint, 0.12f, 5f, 0.30f, 256, 0.3f, 0.18f, 0.15f); + _deathFx = MakeBurst(_fxRoot, "StructDeath", mat, StructureFeelConfig.DeathTint, 0.20f, 8f, 0.55f, 512, 0.3f, 0.18f, 0.15f); } protected override void OnDestroy() @@ -128,89 +129,14 @@ namespace ProjectM.Client // ---- procedural particles + SFX (mirrors WorldFeedbackSystem; self-contained) ---- - 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); - } - static Material MakeParticleMaterial() - { - 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 = "StructureFeedbackParticle" }; - } - ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max) - { - var go = new GameObject(name); - go.transform.SetParent(_fxRoot, 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 = 0.3f; - 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 = 0.18f; - 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, 0.15f)); - var renderer = ps.GetComponent(); - renderer.material = mat; - renderer.renderMode = ParticleSystemRenderMode.Billboard; - return ps; - } - static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol) - { - 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]; - float phase = 0f; - for (int i = 0; i < len; i++) - { - float t = i / (float)len; - float env = Mathf.Exp(-5f * t); - float freq = Mathf.Lerp(f0, f1, t); - phase += 2f * Mathf.PI * freq / rate; - data[i] = Mathf.Sin(phase) * env * vol; - } - clip.SetData(data, 0); - return clip; - } - - static void PlayClip(AudioClip clip, Vector3 pos, float vol) - { - if (clip == null) return; - AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); - } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs index 31b044d6a..560cb7cb5 100644 --- a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs @@ -5,6 +5,7 @@ using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; +using static ProjectM.Client.FeedbackFx; namespace ProjectM.Client { @@ -47,8 +48,8 @@ namespace ProjectM.Client if (_fxRoot != null) return; _fxRoot = new GameObject("~WorldFeedbackFX").transform; var mat = MakeParticleMaterial(); - _chipFx = MakeBurst("HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256); - _clearFx = MakeBurst("ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512); + _chipFx = MakeBurst(_fxRoot, "HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256, 0.25f, 0.10f, 0.2f); + _clearFx = MakeBurst(_fxRoot, "ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512, 0.25f, 0.10f, 0.2f); } protected override void OnDestroy() @@ -143,89 +144,14 @@ void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, bo // ---- procedural particles + SFX (mirrors CombatFeedbackSystem; self-contained) ---- - 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); - } - static Material MakeParticleMaterial() - { - 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 = "WorldFeedbackParticle" }; - } - ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max) - { - var go = new GameObject(name); - go.transform.SetParent(_fxRoot, 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 = 0.25f; - 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 = 0.10f; - 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, 0.2f)); - var renderer = ps.GetComponent(); - renderer.material = mat; - renderer.renderMode = ParticleSystemRenderMode.Billboard; - return ps; - } - static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol) - { - 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]; - float phase = 0f; - for (int i = 0; i < len; i++) - { - float t = i / (float)len; - float env = Mathf.Exp(-5f * t); - float freq = Mathf.Lerp(f0, f1, t); - phase += 2f * Mathf.PI * freq / rate; - data[i] = Mathf.Sin(phase) * env * vol; - } - clip.SetData(data, 0); - return clip; - } - - static void PlayClip(AudioClip clip, Vector3 pos, float vol) - { - if (clip == null) return; - AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); - } } }