Tight coupling between classes can often be avoided by using UnityEvents.
For example, let's say you want to play a sound when the player attacks. The tightly-coupled code would look something like this:
class Player : MonoBehaviour {
void Update() {
if(Input.GetKeyDown("Attack")) {
AudioManagerScript.Instance.PlayAttackSoundFor(gameObject);
}
}
}
This code has a tightly-coupled dependency between AudioManagerScript and Player. We don't want that. So what we can do instead, is this:
class Player : MonoBehaviour {
public UnityEvent<GameObject> onAttack;
void Update() {
if(Input.GetKeyDown("Attack")) {
onAttack?.Invoke(gameObject);
}
}
}
Now the inspector for the player has an event "onAttack" which looks similar to this (reused a screenshot from another answer of mine to a different question).
We can now use the inspector to bind this event to any number of methods from other gameObjects. We can use any method for this which receives a GameObject as their first parameter or which has no obligatory parameters.
The nice thing about this is that the Player doesn't need to know which (if any) other objects react to it attacking. And we can add as many objects as we want. For example, we could also delegate the spawining of a projectile to a separate class "ProjectileSpawner" that also subscribes to this event. And we can later refactor that class to use an object pool.
This also mean that the AudioManagerScript doesn't need to be a singleton anymore. It would be possible to have multiple instances of AudioManagerScript in the same scene and decide which one to use for what by binding the events of different objects to different managers.
And if the AudioManagerScript doen't really do more than calling the Play() method of the right AudioSource, then you might not even need the audio manager anymore, because you can just bind the Play method of the AudioSource to the event directly.