Files
Test-Multiplayer/Assets/Scripts/Shooting/ShootingFx.cs
T
pelpanagiotisandClaude Opus 5.5 5fc9ebf1e6 Add practice range: shooting, AK47, intro cinematic, dressed warehouse scene
- PortalTestDestination practice range: Valorant-style hitscan rifle (recoil,
  spray/bloom, reload, controller support), debug HUD, practice-dummy targets
  with head/body hitboxes, hit/death/respawn animations, procedural SFX/FX
- AK47 model (Quaternius, CC0) held via Animator IK, with fire/reload/equip anims
- Intro cinematic (camera shots, title card, target beam-in, GO hand-off)
- Scene dressing editor pass: CC0 textures, props, warehouse roof, lighting and
  post-processing; custom stylized skybox shader
- CharacterMovement: camera recoil, shoulder offset, cinematic control lock
- Unity CLI pipeline package; plus in-progress chat/Rive/player changes

Asset credits: Assets/Models/Props/PROPS_CREDITS.txt (3 CC-BY 3.0 props),
Assets/Textures/Range/TEXTURES_LICENSE.txt, Assets/Resources/*/..._LICENSE.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:56:35 +03:00

181 lines
8.1 KiB
C#

using System.Collections.Generic;
using UnityEngine;
// Procedural "assets" for the shooting prototype: tinted/emissive materials cloned from the
// render pipeline's default material (so they work in URP without shipping .mat files), and
// synthesized sound effects built with AudioClip.Create (no audio files needed).
public static class ShootingFx
{
static Material baseMaterial;
static readonly Dictionary<(Color, float), Material> materialCache = new Dictionary<(Color, float), Material>();
public static Material GetMaterial(Color color, float emission = 0f)
{
if (materialCache.TryGetValue((color, emission), out var cached) && cached != null)
return cached;
if (baseMaterial == null)
{
var probe = GameObject.CreatePrimitive(PrimitiveType.Cube);
baseMaterial = probe.GetComponent<Renderer>().sharedMaterial;
Object.Destroy(probe);
}
var mat = new Material(baseMaterial) { color = color };
if (emission > 0f)
{
mat.EnableKeyword("_EMISSION");
mat.SetColor("_EmissionColor", color * emission);
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
}
materialCache[(color, emission)] = mat;
return mat;
}
// Primitive with its collider stripped — for visuals that must never block raycasts.
public static GameObject CreateVisual(PrimitiveType type, string name, Transform parent,
Vector3 localPosition, Vector3 localScale, Material material, Vector3 localEuler = default)
{
var go = GameObject.CreatePrimitive(type);
go.name = name;
Object.Destroy(go.GetComponent<Collider>());
go.transform.SetParent(parent, false);
go.transform.localPosition = localPosition;
go.transform.localRotation = Quaternion.Euler(localEuler);
go.transform.localScale = localScale;
var renderer = go.GetComponent<Renderer>();
renderer.sharedMaterial = material;
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
return go;
}
// ---- Effects -------------------------------------------------------------------------
public static void SpawnTracer(Vector3 from, Vector3 to)
{
Vector3 dir = to - from;
float length = dir.magnitude;
if (length < 0.05f) return;
var tracer = CreateVisual(PrimitiveType.Cube, "Tracer", null, from + dir * 0.5f,
new Vector3(0.015f, 0.015f, length), GetMaterial(new Color(1f, 0.85f, 0.4f), 4f));
tracer.transform.rotation = Quaternion.LookRotation(dir);
Object.Destroy(tracer, 0.05f);
}
public static void SpawnImpact(Vector3 point, Vector3 normal, bool fleshHit)
{
var decal = CreateVisual(PrimitiveType.Cube, "BulletHole", null, point + normal * 0.005f,
new Vector3(0.07f, 0.07f, 0.01f), GetMaterial(new Color(0.05f, 0.05f, 0.05f)));
decal.transform.rotation = Quaternion.LookRotation(normal);
Object.Destroy(decal, fleshHit ? 0.1f : 4f);
Color sparkColor = fleshHit ? new Color(1f, 0.2f, 0.15f) : new Color(1f, 0.75f, 0.3f);
var sparkMat = GetMaterial(sparkColor, 3f);
for (int i = 0; i < 5; i++)
{
var spark = CreateVisual(PrimitiveType.Cube, "Spark", null, point + normal * 0.03f,
Vector3.one * 0.025f, sparkMat, Random.insideUnitSphere * 180f);
var body = spark.AddComponent<Rigidbody>();
body.useGravity = true;
body.linearVelocity = (normal + Random.insideUnitSphere * 0.8f).normalized * Random.Range(2f, 4.5f);
Object.Destroy(spark, Random.Range(0.25f, 0.5f));
}
}
// Vertical light column that squeezes away — "target materialized" effect.
public static void SpawnBeam(Vector3 groundPoint)
{
var beam = CreateVisual(PrimitiveType.Cylinder, "SpawnBeam", null, groundPoint + Vector3.up * 1.5f,
new Vector3(0.9f, 1.5f, 0.9f), GetMaterial(new Color(0.35f, 0.9f, 1f), 3f));
beam.AddComponent<FxShrink>();
}
// ---- Sounds --------------------------------------------------------------------------
const int SampleRate = 44100;
static AudioClip gunshot, emptyClick, magOut, magIn, hitBody, hitHead, killConfirm;
public static AudioClip Gunshot => gunshot ??= Synth("Gunshot", 0.28f, (t, n) =>
n * Mathf.Exp(-t * 28f) * 0.9f
+ Mathf.Sin(2f * Mathf.PI * 95f * t) * Mathf.Exp(-t * 18f) * 0.8f
+ Mathf.Sin(2f * Mathf.PI * 190f * t) * Mathf.Exp(-t * 40f) * 0.3f);
public static AudioClip EmptyClick => emptyClick ??= Synth("EmptyClick", 0.05f, (t, n) =>
(n * 0.5f + Mathf.Sin(2f * Mathf.PI * 3200f * t)) * Mathf.Exp(-t * 120f) * 0.5f);
public static AudioClip MagOut => magOut ??= Synth("MagOut", 0.12f, (t, n) =>
(n * 0.6f + Mathf.Sin(2f * Mathf.PI * 700f * t) * 0.4f) * Mathf.Exp(-t * 45f) * 0.6f);
public static AudioClip MagIn => magIn ??= Synth("MagIn", 0.15f, (t, n) =>
(n * 0.5f + Mathf.Sin(2f * Mathf.PI * 1100f * t) * 0.5f) * Mathf.Exp(-t * 35f) * 0.7f
+ (t > 0.07f ? (n * 0.6f + Mathf.Sin(2f * Mathf.PI * 1500f * t) * 0.4f) * Mathf.Exp(-(t - 0.07f) * 50f) * 0.7f : 0f));
public static AudioClip HitBody => hitBody ??= Synth("HitBody", 0.06f, (t, n) =>
Mathf.Sin(2f * Mathf.PI * 1700f * t) * Mathf.Exp(-t * 60f) * 0.35f);
public static AudioClip HitHead => hitHead ??= Synth("HitHead", 0.18f, (t, n) =>
(Mathf.Sin(2f * Mathf.PI * 2600f * t) + Mathf.Sin(2f * Mathf.PI * 3900f * t) * 0.5f) * Mathf.Exp(-t * 22f) * 0.35f);
public static AudioClip KillConfirm => killConfirm ??= Synth("Kill", 0.22f, (t, n) =>
Mathf.Sin(2f * Mathf.PI * (t < 0.08f ? 880f : 1320f) * t) * Mathf.Exp(-(t % 0.08f) * 18f) * 0.35f);
static AudioClip whoosh, boom, blip, goStinger;
// Filtered noise that swells and fades, with the filter opening up (camera fly-by).
public static AudioClip Whoosh => whoosh ??= SynthWhoosh();
static AudioClip SynthWhoosh()
{
float lowpass = 0f;
return Synth("Whoosh", 0.9f, (t, n) =>
{
float cutoff = Mathf.Lerp(0.02f, 0.25f, Mathf.Sin(Mathf.PI * Mathf.Clamp01(t / 0.9f)));
lowpass += (n - lowpass) * cutoff;
float env = Mathf.Sin(Mathf.PI * Mathf.Clamp01(t / 0.9f));
return lowpass * env * env * 1.8f;
});
}
// Sub-bass drop with a noisy transient (title hit).
public static AudioClip Boom => boom ??= Synth("Boom", 1.6f, (t, n) =>
{
float freq = Mathf.Lerp(90f, 38f, Mathf.Clamp01(t / 1.2f));
return Mathf.Sin(2f * Mathf.PI * freq * t) * Mathf.Exp(-t * 2.2f) * 0.9f
+ n * Mathf.Exp(-t * 30f) * 0.5f;
});
// Short digital blip — pitch it up per target for a rising "boot sequence".
public static AudioClip Blip => blip ??= Synth("Blip", 0.12f, (t, n) =>
(Mathf.Sin(2f * Mathf.PI * 880f * t) + Mathf.Sin(2f * Mathf.PI * 1760f * t) * 0.3f) * Mathf.Exp(-t * 30f) * 0.35f);
// Bright major chord stab for "GO".
public static AudioClip GoStinger => goStinger ??= Synth("Go", 0.8f, (t, n) =>
{
float env = Mathf.Min(1f, t * 60f) * Mathf.Exp(-t * 4f);
return (Mathf.Sin(2f * Mathf.PI * 523.25f * t) + Mathf.Sin(2f * Mathf.PI * 659.25f * t)
+ Mathf.Sin(2f * Mathf.PI * 783.99f * t) + Mathf.Sin(2f * Mathf.PI * 1046.5f * t) * 0.5f) * env * 0.18f
+ n * Mathf.Exp(-t * 40f) * 0.2f;
});
delegate float SampleFunc(float time, float noise);
// Samples are generated in order, so a SampleFunc may keep state (e.g. a filter) in a closure.
static AudioClip Synth(string name, float duration, SampleFunc func)
{
int count = Mathf.CeilToInt(duration * SampleRate);
var data = new float[count];
var rng = new System.Random(name.GetHashCode());
for (int i = 0; i < count; i++)
{
float noise = (float)(rng.NextDouble() * 2.0 - 1.0);
data[i] = Mathf.Clamp(func(i / (float)SampleRate, noise), -1f, 1f);
}
var clip = AudioClip.Create(name, count, 1, SampleRate, false);
clip.SetData(data, 0);
return clip;
}
}