Source file unity-csharp/patterns/feedback-layer/FeedbackPlayer.cs from the GDnD code examples. Download raw file

// GDnD wiki example
// Demonstrates: one feedback component that turns gameplay events into feel (hit stop, camera
//   shake, particles, sound) using FeedbackPreset assets. Because every state raises events
//   instead of calling effects, a landing feels the same whether the player came from a jump, a
//   dash or a fall, and no state needs its own copy of the hit-stop code.
// Related pages: [[state-pattern]], [[game-feel]], [[vfx-shape-and-timing]], [[observer-pattern]]
// Unity 6 (6000.x). No packages required: camera shake is a UnityEvent<float>, so wire it in the
//   Inspector to CinemachineImpulseSource.GenerateImpulse (Cinemachine 3, dynamic float) or to
//   your own shake script.
//
// Setup: add to the player (or a "Feedback" child), assign PlayerEvents, an AudioSource for SFX,
//   and a FeedbackPreset asset per slot. Untick Juice Enabled for the all-off playtest baseline.
 
using System.Collections;
using UnityEngine;
using UnityEngine.Events;
 
namespace GDnD.Patterns.Feedback
{
    public class FeedbackPlayer : MonoBehaviour
    {
        [SerializeField] private PlayerEvents events;
 
        [Header("Presets (ScriptableObject assets)")]
        [SerializeField] private FeedbackPreset jumped;
        [SerializeField] private FeedbackPreset landed;
        [SerializeField] private FeedbackPreset lightHit;
        [SerializeField] private FeedbackPreset heavyHit;
        [SerializeField] private int heavyHitDamage = 20;     // damage at or above this uses heavyHit
        [SerializeField] private float minLandingSpeed = 6f;  // gentle landings get no feedback
 
        [Header("Outputs")]
        [SerializeField] private AudioSource sfxSource;
        [SerializeField] private UnityEvent<float> shake;
        [SerializeField] private bool juiceEnabled = true;    // A/B switch for playtests
 
        private float restoreScale = 1f;
        private Coroutine hitStop;
 
        private void OnEnable()
        {
            events.Jumped += OnJumped;
            events.Landed += OnLanded;
            events.HitLanded += OnHitLanded;
        }
 
        private void OnDisable()
        {
            events.Jumped -= OnJumped;
            events.Landed -= OnLanded;
            events.HitLanded -= OnHitLanded;
 
            // Coroutines stop with the component, so never leave the game frozen.
            if (hitStop != null) { Time.timeScale = restoreScale; hitStop = null; }
        }
 
        // The mapping from "what happened" to "which feel" lives here, and only here.
        private void OnJumped(Vector3 at) => Play(jumped, at);
 
        private void OnLanded(Vector3 at, float fallSpeed)
        {
            if (fallSpeed >= minLandingSpeed) Play(landed, at);
        }
 
        private void OnHitLanded(Vector3 at, int damage) =>
            Play(damage >= heavyHitDamage ? heavyHit : lightHit, at);
 
        public void Play(FeedbackPreset preset, Vector3 at)
        {
            if (!juiceEnabled || preset == null) return;
 
            if (preset.sound != null && sfxSource != null)
                sfxSource.PlayOneShot(preset.sound);
 
            if (preset.particlesPrefab != null)
            {
                ParticleSystem burst = Instantiate(preset.particlesPrefab, at, Quaternion.identity);
                ParticleSystem.MainModule main = burst.main;
                Destroy(burst.gameObject, main.duration + main.startLifetime.constantMax);
            }
 
            if (preset.shakeForce > 0f)
                shake.Invoke(preset.shakeForce);
 
            if (preset.hitStopSeconds > 0f)
                StartHitStop(preset.hitStopSeconds);
        }
 
        private void StartHitStop(float seconds)
        {
            if (hitStop == null) restoreScale = Time.timeScale; // don't capture a running stop's 0
            else StopCoroutine(hitStop);                         // a new hit restarts the timer
            hitStop = StartCoroutine(HitStopRoutine(seconds));
        }
 
        private IEnumerator HitStopRoutine(float seconds)
        {
            Time.timeScale = 0f;
            yield return new WaitForSecondsRealtime(seconds);   // WaitForSeconds never ends at timeScale 0
            Time.timeScale = restoreScale;
            hitStop = null;
        }
    }
}