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;
}
}
}