# 3. Interactable Components with Audio Part of [[Interactable Audio]]. Previous: [[2. Pooled AudioSource Service]]. Next: [[4. Interactable Audio Pitfalls and Quest]]. > [!note] Verification status > Uses `Collision.relativeVelocity`, `Collision.GetContact`, `Collision2D.relativeVelocity`, `Physics.OverlapSphereNonAlloc`, `AudioSource` members - present in the 6000.5 reference (checked 2026-11-07) - plus the `AudioService` / `InteractionSound` types from Parts 1-2. **Code not compiled or play-tested.** ## 3.1 Interaction plumbing ```csharp using UnityEngine; public interface IInteractable { string Prompt { get; } bool CanInteract(GameObject user); void Interact(GameObject user); } ``` ```csharp using UnityEngine; using UnityEngine.InputSystem; /// Finds the nearest IInteractable in range and calls it on the Interact action. public class Interactor : MonoBehaviour { [SerializeField] InputActionReference interact; [SerializeField] float radius = 1.6f; [SerializeField] LayerMask interactableMask; [SerializeField] InteractionSound focusSound; // soft tick when a new target is focused readonly Collider[] _hits = new Collider[16]; IInteractable _focused; public IInteractable Focused => _focused; void OnEnable() { interact.action.Enable(); interact.action.performed += OnInteract; } void OnDisable() { interact.action.performed -= OnInteract; } void Update() { int n = Physics.OverlapSphereNonAlloc(transform.position, radius, _hits, interactableMask, QueryTriggerInteraction.Collide); IInteractable best = null; float bestD = float.MaxValue; for (int i = 0; i < n; i++) { var it = _hits[i].GetComponentInParent<IInteractable>(); if (it == null || !it.CanInteract(gameObject)) continue; float d = (_hits[i].transform.position - transform.position).sqrMagnitude; if (d < bestD) { bestD = d; best = it; } } if (best != _focused) { _focused = best; if (_focused != null && focusSound) AudioService.Play(focusSound, transform.position); } } void OnInteract(InputAction.CallbackContext _) => _focused?.Interact(gameObject); } ``` > [!warning] Code not compiled in Unity > Not compiled. `GetComponentInParent<IInteractable>()` works with interfaces in current Unity versions; if yours complains, look up a concrete base class instead. Show `Focused.Prompt` with UI Toolkit ([[UI Toolkit and Diegetic UI]]). ## 3.2 Door: open, close, locked, motor loop ```csharp using UnityEngine; public class AudioDoor : MonoBehaviour, IInteractable { [SerializeField] Transform hinge; [SerializeField] float openAngle = 100f; [SerializeField] float openTime = 0.8f; [SerializeField] bool locked; [SerializeField] string requiredKeyId = ""; [Header("Sounds")] [SerializeField] InteractionSound openSound, closeSound, lockedSound, unlockSound, motorLoop, slamSound; bool _open; float _t; // 0 closed .. 1 open float _dir; // +1 opening, -1 closing, 0 idle AudioHandle _motor; public string Prompt => locked ? "Locked" : (_open ? "Close" : "Open"); public bool CanInteract(GameObject user) => true; public void Interact(GameObject user) { if (locked) { var keys = user.GetComponent<IKeyRing>(); if (keys != null && keys.Has(requiredKeyId)) { locked = false; AudioService.Play(unlockSound, hinge.position); } else { // Level Up! "show 'em the door, then send 'em after the key": the clunk says LOCKED. AudioService.Play(lockedSound, hinge.position); return; } } _open = !_open; _dir = _open ? 1f : -1f; AudioService.Play(_open ? openSound : closeSound, hinge.position); if (motorLoop && !_motor.IsPlaying) _motor = AudioService.Play(motorLoop, hinge.position, hinge); } void Update() { if (_dir == 0f) return; _t = Mathf.Clamp01(_t + _dir * Time.deltaTime / openTime); hinge.localRotation = Quaternion.Euler(0f, Mathf.SmoothStep(0f, openAngle, _t), 0f); if ((_dir > 0f && _t >= 1f) || (_dir < 0f && _t <= 0f)) { if (_dir < 0f && slamSound) AudioService.Play(slamSound, hinge.position); _dir = 0f; _motor.Stop(0.15f); } } } public interface IKeyRing { bool Has(string keyId); } ``` > [!warning] Code not compiled in Unity > Not compiled. The motor loop's `InteractionSound` must have **Loop** ticked; the handle's fade-out stop keeps the loop from clicking off. For animation-driven doors use [[Timeline Basics]] signals or animation events to fire the same sounds. ## 3.3 Lever / switch with a hazard telegraph ```csharp using UnityEngine; using UnityEngine.Events; public class AudioLever : MonoBehaviour, IInteractable { [SerializeField] InteractionSound pullSound, resetSound, deniedSound; [SerializeField] float cooldown = 1f; [SerializeField] UnityEvent onPulled; // e.g. start a RhythmicEffectorHazard, open a gate bool _on; float _readyAt; public string Prompt => _on ? "Reset" : "Pull"; public bool CanInteract(GameObject user) => true; public void Interact(GameObject user) { if (Time.time < _readyAt) { AudioService.Play(deniedSound, transform.position); return; } _readyAt = Time.time + cooldown; _on = !_on; AudioService.Play(_on ? pullSound : resetSound, transform.position); if (_on) onPulled.Invoke(); } } ``` > [!warning] Code not compiled in Unity > Not compiled or play-tested. Wire `onPulled` to a hazard's warning (e.g. the beat hazard in [[4. Physics 2D Effectors and Joints]]) - the lever click + wind-up is the **telegraph** Rogers insists on (Level 12). ## 3.4 Pickup with a streak pitch ```csharp using UnityEngine; /// Coins/gems: pitch climbs one semitone per pickup within the streak window (greed made audible). public class AudioPickup : MonoBehaviour { [SerializeField] InteractionSound pickupSound; [SerializeField] int value = 1; [SerializeField] string playerTag = "Player"; static int s_streak; static float s_lastPickup = -999f; const float StreakWindow = 0.6f; const int MaxSemitones = 12; void OnTriggerEnter(Collider other) { if (!other.CompareTag(playerTag)) return; s_streak = Time.time - s_lastPickup <= StreakWindow ? s_streak + 1 : 0; s_lastPickup = Time.time; float pitchMul = Mathf.Pow(2f, Mathf.Min(s_streak, MaxSemitones) / 12f); AudioService.Play(pickupSound, transform.position, null, 1f, pitchMul); // TODO: add 'value' to score - see High Scores, Saves and Player Stats gameObject.SetActive(false); // or return to a pickup pool } } ``` > [!warning] Code not compiled in Unity > Not compiled. Set the pickup sound's **cooldown** to ~0 and **maxInstances** to ~6, otherwise a fast streak gets swallowed by the anti-spam. Score hook: [[High Scores, Saves and Player Stats]]. Level 13: "never underestimate the greed of the player" - the rising pitch rewards chaining. ## 3.5 Physics impact audio (3D) ```csharp using UnityEngine; /// Impact sounds scaled by relative speed, with light/heavy variants and per-object cooldown. [RequireComponent(typeof(Rigidbody))] public class ImpactAudio : MonoBehaviour { [SerializeField] InteractionSound lightImpact, heavyImpact; [SerializeField] float minSpeed = 0.6f; // below: silent [SerializeField] float heavySpeed = 4f; // above: heavy variant [SerializeField] float maxSpeed = 10f; // volume saturates here [SerializeField] float objectCooldown = 0.08f; [SerializeField] float noiseRadiusPerMps = 1.5f; // for enemy hearing float _nextAllowed; void OnCollisionEnter(Collision c) { if (Time.time < _nextAllowed) return; // Use the component of relative velocity along the contact normal (glancing scrapes are quieter). ContactPoint cp = c.GetContact(0); float speed = Mathf.Abs(Vector3.Dot(c.relativeVelocity, cp.normal)); if (speed < minSpeed) return; _nextAllowed = Time.time + objectCooldown; float vol = Mathf.InverseLerp(minSpeed, maxSpeed, speed); var snd = speed >= heavySpeed && heavyImpact ? heavyImpact : lightImpact; AudioService.Play(snd, cp.point, null, Mathf.Lerp(0.25f, 1f, vol), Mathf.Lerp(1.05f, 0.95f, vol)); NoiseEvents.Emit(cp.point, speed * noiseRadiusPerMps); } } /// Minimal global noise channel. Bridge this to the hearing sense in your enemy AI. public static class NoiseEvents { public static event System.Action<Vector3, float> Noise; // position, audible radius (m) public static void Emit(Vector3 pos, float radius) => Noise?.Invoke(pos, radius); } ``` > [!warning] Code not compiled in Unity > Not compiled. Both bodies with `ImpactAudio` will fire for the same collision - put it on the lighter/more "voiced" object only, or let the per-sound `maxInstances` absorb the duplicate. Subscribe your enemy hearing from [[Organic Enemy Sensing]] to `NoiseEvents.Noise`. Add a matching dust puff from [[3.Particles Spawning Particles on Contact]]. **2D version:** identical logic with `OnCollisionEnter2D(Collision2D c)`, `c.relativeVelocity`, `c.GetContact(0).normal` (see [[3. Physics 2D Queries ContactFilter2D and Layers]]). For 2D games set the sounds' `spatialBlend` to 0 or low - stereo pan from position is usually enough. Apply it to the crate in [[6. Worked Example Pushable Prop]]: drop it from 0.5 m, 2 m, 5 m and listen for three distinct impacts. ## 3.6 Breakable ```csharp using UnityEngine; public class AudioBreakable : MonoBehaviour { [SerializeField] InteractionSound breakSound; [SerializeField] GameObject brokenPrefab; [SerializeField] float breakSpeed = 5f; void OnCollisionEnter(Collision c) { if (c.relativeVelocity.magnitude < breakSpeed) return; // Play at the object's position, NOT on a source attached to this object (it's about to vanish). AudioService.Play(breakSound, transform.position); if (brokenPrefab) Instantiate(brokenPrefab, transform.position, transform.rotation); Destroy(gameObject); } } ``` The comment is the whole lesson: a sound on an AudioSource *attached to* a destroyed object dies with it. Pooled emitters (or `PlayClipAtPoint`) outlive the object. ## 3.7 Proximity hum (pattern A, voice-friendly) ```csharp using UnityEngine; /// A generator/console hum on its own AudioSource that only occupies a voice when the listener is near. [RequireComponent(typeof(AudioSource))] public class ProximityLoop : MonoBehaviour { [SerializeField] Transform listener; [SerializeField] float hysteresis = 2f; [SerializeField] float checkInterval = 0.25f; AudioSource _src; float _next; void Awake() { _src = GetComponent<AudioSource>(); _src.loop = true; _src.playOnAwake = false; if (listener == null && Camera.main) listener = Camera.main.transform; } void Update() { if (Time.time < _next || listener == null) return; _next = Time.time + checkInterval; float d = Vector3.Distance(transform.position, listener.position); if (!_src.isPlaying && d < _src.maxDistance) { _src.time = Random.Range(0f, _src.clip ? _src.clip.length : 0f); // de-phase identical hums _src.Play(); } else if (_src.isPlaying && d > _src.maxDistance + hysteresis) _src.Stop(); } } ``` > [!warning] Code not compiled in Unity > Not compiled. Use **Linear** or **Custom** rolloff so volume really reaches 0 at `maxDistance` before you stop it (Logarithmic never quite reaches zero, so stopping causes a click). ## Docs - `Collision.relativeVelocity`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Collision-relativeVelocity.html - `Collision.GetContact`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Collision.GetContact.html - `Collision2D.relativeVelocity`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Collision2D-relativeVelocity.html - `Physics.OverlapSphereNonAlloc`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Physics.OverlapSphereNonAlloc.html - `AudioSource.time`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioSource-time.html - `AudioRolloffMode`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioRolloffMode.html