This article will describe the way that Rogue Tap implements interactive fish in a pond.
The rooms in Rogue Tap are procedurally generated. The larger ones have a chance to contain a water feature - like a small pond. The generation algorithm would decorate the pond with some lilypads, but it did not look complete.
After adding some water effects and our fish-boids, the ponds looks a little more alive.
Here is a gif of the finished product. There is also an in-game video.
The improvements come in two parts:
The water effect I achieved by using an amazing post provided by Damien Mayance. He posted it on Reddit (Direct Link) for the community to use. Posts like his are what motivate me to write articles like this. I appreciate them tremendously.
The Fish AI was a little more complicated and required a custom implementation because I wanted to add behavior where the fish would become startled. I used articles like this as a base to roll my own (skittish) boids implementation.
As mentioned earlier, I did not create this effect. I am using one provided here. The author, Damien, provides two items: a shader, and a utility script for creating reflections. I did not want reflections, so I only used the quad and the shader to create the appearance of water.
Damien has done a great job with his implementation. The package he provides includes a prefab that includes everything you need to use the water effect. There is only one aspect that one needs to understand to effectively use the asset: layer sorting.
The water shader does its magic on everything behind it and that was drawn before it. In Unity, the order of drawing is controlled first by the Sorting Layers and second by the order within that layer.
You set both the layer and the order data on your Sprite Renderer component. In Rogue Tap, I created a sorting layer for each of the following (in ascending order):
You can achieve this with separate layers, or by setting the order field properly and using a single layer, or any combination of orders and layers that achieves the appropriate stack.
The Pond does a few grunt work things that are helpful, but not especially interesting. I will include the entire class here with comments. The logic does not warrant additional explanation.
public class Pond : MonoBehaviour { //Collection of possible fish prefabs to spawn public List<GameObject> fishPrefabs; //Time between updates to the fish AI. //This is probably not necessary with such lightweight AI //but the effect is also purely cosmetic, so we don't want to risk //slowing down the important action. public float updateInterval = 0.5f; //This could be considered a performance lever. //But I noticed that it helps to create more natural looking schooling //if we skip some fish when updating the AI we see //fish that linger on their path a big longer public bool randomized = false; //This is the repulsor point that attaches to the player //when they get near the pond. I ultimately turned this off //because I wanted to fish to eventually get near the player //but I left it in the code. public TrackingRepulsor playerRepulsor; //Collection of fish List<Fish> fish; //Collection of repusling entities List<Repulsor> repulsors; //Caches school center (performance enhancement) private Vector3 schoolCenter = Vector3.zero; //Time we last updated the fish AI private float lastUpdate; //Root for adding our prefab instances private Transform creatureRoot; void Awake() { //Find any fish that were added in the scene Fish[] fishChildren = GetComponentsInChildren<Fish>(); fish = new List<Fish>(fishChildren); foreach (Fish f in fish) { f.SetPond(this); } //Find and repulsors that were added in the scene Repulsor[] repChildren = GetComponentsInChildren<Repulsor>(); repulsors = new List<Repulsor>(repChildren); //Set our creature root this.creatureRoot = transform.FindChild("Creatures"); } public void AddFish(int count) { for (int i = 0; i < count; i++) { AddFish(); } } //Adds a fish from a randomly selected prefab. public void AddFish() { GameObject prefab = fishPrefabs[UnityEngine.Random.Range(0, fishPrefabs.Count)]; Vector3 pos = transform.position + (Vector3)UnityEngine.Random.insideUnitCircle; GameObject fish = GameObject.Instantiate(prefab, pos, Quaternion.identity, creatureRoot) as GameObject; Fish f = fish.GetComponent<Fish>(); if(f == null) { Debug.LogError("Failed to find fish on prefab!"); return; } f.SetPond(this); this.fish.Add(f); } //Handles when the player enters the pond's active zone //We do not want to bother updating the fish AI when no one can see them. //Here we also attach the repulsor to the player's transform public void OnTriggerEnter2D(Collider2D collision) { Activate(); if (playerRepulsor != null) { playerRepulsor.Track(collision.transform); } } //Stop updating our AI when the player leaves our active zone //Stop the repulsor from tracking the player public void OnTriggerExit2D(Collider2D collision) { Deactivate(); if (playerRepulsor != null) { playerRepulsor.StopTracking(); } } private void Deactivate() { foreach(Fish f in fish) { f.enabled = false; } } private void Activate() { foreach (Fish f in fish) { f.enabled = true; } } //Performance enhancement //Fish can request the center of the school from the pond //instead of each fish calculating the center itself public Vector3 CalculateCenter() { if (this.schoolCenter != Vector3.zero) { return this.schoolCenter; } foreach (Fish f in this.fish) { this.schoolCenter += f.Position; } this.schoolCenter /= this.fish.Count; return this.schoolCenter; } void Update() { //determine if it is time to update float timeSinceUpdate = Time.time - lastUpdate; if (timeSinceUpdate < updateInterval) { return; } //reset the school center to zero so that it gets recalculated this update this.schoolCenter = Vector3.zero; foreach (Fish f in fish) { // skip fish randomly if configured to do so // if we skip a fish, it will continue moving in its last direction // instead of sitting still if (randomized && UnityEngine.Random.value < 0.5f) { continue; } //Run the fish AI f.School(fish, this.repulsors); } this.lastUpdate = Time.time; } }
Now on to the interesting part. Most boid-inspired algorithms follow three parts. Each individual fish tries to do the following:
The Fish class represents an individual in the school. Here is are the members I used for the class.
public class Fish : MonoBehaviour { //Strength of attraction to the center of the school public float schoolAttraction = 0.01f; //How close a fish wants to be to every other fish public float desiredSpacing = 2f; //Strength of desire to match the velocity of every other fish public float velocityMatch = 0.15f; //Maximum Speed the Fish can move public float maxSpeed = 0.5f; //Strength of randomness in the movement of the fish public float chaos = 0.05f; //Speed increase when the fish is startled by the player public float startleMultiplier = 5f; //Direction of movement [HideInInspector] public Vector3 velocity; //Cache the transform (is this still an improvement in Unity 5.4?) private Transform trans; //The host pond private Pond pond; //Controls the startled state of the fish private bool startled = false; //How long a fish is startled private float startleDuration = 0.75f; //Last position of the player when we were startled //We save this so that if the player does not move, s/he won't startle us again private Vector3 lastStartPos; }
Here is how I implemented each of those steps.
Vector3 schoolCenter = this.pond.CalculateCenter(); Vector3 schoolCenterForce = schoolCenter - Position; schoolCenterForce *= schoolAttraction;
This part is simple enough. Get the position of the school's center from the pond, determine the direction to it, then apply our weighting factor.
//Start with no avoidance Vector3 avoidanceForce = Vector3.zero; foreach (Fish f in allFish) { //We cannot avoid ourself - even if we want to if (f == this) { continue; } //Is this other fish getting too close? float dist = Vector3.Distance(Position, f.Position); if (dist > desiredSpacing) { continue; } //If they are too close, calculate the distance to get away from them and add it //Also note that we multiply by a factor based on the how close they are. //This means that we will more aggressively avoid the closest fish. avoidanceForce += (Position - f.Position) * (desiredSpacing / dist); } //Now we consider any of the repulsors foreach (Repulsor r in repulsors) { //Each repulsor has a Collider2D on it called range //If we are inside of the Collider, we avoid the repulsor based on how close we are //and how strong the repulsor is (r.force) if (!r.enabled || !r.range.OverlapPoint(Position)) { continue; } float dist = Vector3.Distance(Position, r.Position); avoidanceForce += (Position - r.Position) * Mathf.Pow(r.size / dist, 2) * r.force; }
Here we add up the velocity of every fish in the school, and then average it. The average is multiplied by our weighting for this component.
Vector3 flockVelocity = Vector3.zero; foreach (Fish f in allFish) { if (f == this) { continue; } flockVelocity += f.velocity; } flockVelocity /= allFish.Count - 1; flockVelocity *= velocityMatch;
Now we add together all of the individual components of our AI. Then ensure the velocity is less than our max speed.
//Add in our chaos factor Vector3 chaosVelocity = UnityEngine.Random.insideUnitCircle * chaos; //Apply all of our components to our velocity velocity += schoolCenterForce + avoidanceForce + flockVelocity + chaosVelocity; //Set Z to 0 because we are only in 2D velocity.z = 0; //And then limit the speed if greater than our max if (velocity.magnitude > maxSpeed) { velocity *= (maxSpeed / velocity.magnitude); }
I found that the fish tended to get caught up in the corners. To fix that, I created the repulsors. I placed a repulsor in each corner of the pond. This makes the fish move toward the center a bit and keeps the AI looking more natural.
Now we have some nice, calm, schooling fish. And when the player arrives, they just continue on their merry way. That is not realistic at all! We need to see them scatter a bit when someone looms over the pond. To achieve that, we add some startling logic to our AI.
This code is added to the Fish class.
//When the player enters the fish collider, we get startled! public void OnTriggerEnter2D(Collider2D collision) { Startle(collision.transform.position); } //This may not be necessary. //I added it to try to cover the situation where the player moves, //BUT! they do not exit the fish's collider. If that happens, //I want to get startled again. This effect may not be worth the performance //hit. public void OnTriggerStay2D(Collider2D collision) { Startle(collision.transform.position); } //The heart of the startling lofic public void Startle(Vector3 source) { //If we are fleeing, don't startle again //Also, if we see the player again -but- he has not moved //do not get startled if (this.startled || this.lastStartPos == source) { return; } //Save the startle position this.lastStartPos = source; //Set started state this.startled = true; //set the flee direction this.velocity = (Position - source).normalized * maxSpeed * startleMultiplier; //Only 2D this.velocity.z = 0; //Calm down after a specified duration Invoke("CompleteStartle", startleDuration); } //Method to calm us down //We do not need to reset the velocity here. //That will happen once the AI executes again public void CompleteStartle() { this.startled = false; } //We need to update out School method. //Exit immediately if we are in the startled state. public void School(List<Fish> allFish, List<Repulsor> repulsors) { if (!enabled || this.startled || allFish == null || allFish.Count < 2) { return; } //rest of our fish AI }
There you have it: a passable implementation of some fish schooling in a pond that react to the player. It isn't a game changer, but it is a nice little touch that enhances the player's immersion.
And here is a demonstration of the player fishing in the pond. When the fish take the bait, we reuse their startle logic so that they run with it!