Each pond in PONDER is populated with fish that all have their own instance of a script called BoidBehavior.cs . In this blog post, I want to break down how this script works so I can share some insight into my process.
Introduction
BoidBehavior.cs is the engine that drives the individual fish in the pond ecosystem. It is the script that tells the fish what breed to be, how to swim, how to avoid obstacles, when to be hungry, how to eat, how to hunt, and how to die.
It is important to note, that while BoidBehavior.cs is the engine of the fish, the breed of the fish is determined by scriptable objects. I will cover these in another document, but know that the fish scriptable object is where BoidBehavior.cs pulls variables that determine swim speed, appetite, aggressiveness, 3D mesh, and other unique behaviors.
Runtime: Awake()
Find PondManager.cs Singleton
This is part of the [MANAGERS] prefab that is required for every scene.
BoidBehavior.cs registers itself to the _allFish List
PondManager.cs keeps a list of every BoidBehavior.cs in the scene. This is how the fish are able to see each other for hunting and schooling.
GetAllBoids() to read the list and get familiar with the ecosystem
BoidBehavior keeps its own local version of this list in a variable called ‘boids’. Really, it keeps multiple copies of this list. Probably really inefficient, but I am not in a spot to refactor this code quite yet. This particular list is used to keep track of the amount of minnows in the scene.
Choose your Fish scriptable object (breed) from the list of possible scriptable objects, ensuring that you don’t become a minnow if there are already 10 minnows in the pond. Set this selection as your “fish” object that you will pull data from when spawning.
Because Kavkiin Minnows are the only fish that do not eat (and therefore don’t die of hunger) all simulations will eventually end up becoming a pond full of immortal minnows. It is because of this that we check the minnow count before choosing our breed so we do not spawn more than a set max amount of minnows. This keeps the pond going forever!
While hard-coding the fish name string into the minnow check isn’t the most elegant solution, it works! In the future, maybe I could check something else like if the fish is lacking an appetite or something. That way multiple kinds of “feeder fish” can exist easily?
Now that the target Fish breed is set: BecomeBoid()
This is where the fish is finally initialized. All of the variables that make the fish unique are funneled in from the Fish breed scriptable object chosen above.
Most importantly, the fish is assigned a sizeMultiplier based on the average range of that fish breed, and that is factored into the fish’s size and foodscore. This means that fish of the same breed are still unique to one another within a reasonable margin.
sizeMultiplier serves as the BoidBehavior.cs instance’s “genetic lottery”. It alters the foodScore, the size of the mesh, the hunger timer, and the level of the caught fish that determines the value to the player. The sizeMultiplier range is the secret sauce that adds a touch of randomness to the game as far as fish catch variety.
Last stop on Awake(), if BoidBehavior is not a minnow, or a fishing lure, start the timer for hunger to set in.
Runtime: FixedUpdate()
This is where the AI makes its moment-to-moment decisions. Every frame, we work down this tree of if statements to make a choice between different behaviors:
ApplySwimBehavior();
Travel along a vector of your normalForward, and your currentSpeed (swimSpeed/huntSpeed/escapeSpeed)
ApplyUprightBehavior();
Quaternion.Slerp to a target rotation of (x,y,0) to keep fish from going belly-up. Swim bladder simulation.
ApplyObstacleAvoidanceBehavior();
Raycasting out a distance of your breed’s avoidance radius, if you hit an object tagged as an obstacle, start the TurnAroundCoroutine
ApplySchoolingBehavior();
The sum of 3 algorithms based on Craig Reynolds’ research paper on simulated flocking patterns. Cohesion, Separation, and Alignment. Each is calculated based on breed-determined weights.
ApplyHuntingBehavior();
If you are hungry, check your neighbor list for the first fish that can fit in your mouth. Set your rotation to that fish, and shift to your huntSpeed. If you are in biteRange of the target, Eat(target)
PanicCoroutine();
Shift to your escapeSpeed, move away from your predator, lose hunger, stop hunting if you found prey. In 10 seconds, you can return to normal and restart the hunger clock.
ApplyDeviateBehavior(deviateChance);
Fish don’t always swim perfectly straight. Each breed has a %chance to deviate by a breed-determined range of rotation. If all needs are met, and they are not schooling, the fish will wander.
ApplyHookedBehavior();
This is a very complicated behavior, because it is the only behavior that interacts with the player directly. I think I will go into detail on a separate Fishing Rod document.TL;DR - Look away from the player, apply deviate behavior x10 strength for a really chaotic wiggle. Start the TugTheLineCoroutine and if the rod is reeled, Land() this fish.
Runtime: Verb Methods and Coroutines
Eat(BoidBehavior boid();
The end goal of ApplyHuntingBehavior() is to eat. Whether it is a smaller fish, or the player’s fishing lure. Reset hunger, stop hunting, and increase the fish’s foodScore and sizeMultiplier. If the target is a lure, we are going to hook the fish. If the target was a hooked fish, eat that fish, but pass the isHooked variable to the new fish, while triggering a combo.
Die();
When a fish dies, be it old age or simply being eaten by a larger fish- it goes through a series of checks to alter its behavior through the course of the Decompose() coroutine. Unhook if you are hooked, set your Fish SO breed to ‘null’ and “BecomeBoid()”. BecomeBoid() has an edge case for a null fish value that sets all stats and behaviors to 0, and enables gravity on the BoidShell rigidbody. Swimming will stop, and the skeleton slowly floats to the bottom of the pond. After these conditions are met, trigger the Decompose() coroutine.
Decompose();
This coroutine is responsible for the entity cleanup after a fish’s death. decompositionTime is a variable set by the Fish breed SO. Once the timer is up, a while loop starts shrinking the entire prefab (not just the mesh). Once the scale moves at or below 0, destroy the game object.
StimulateAppetite(float chance);
Fish only hunt when they’re hungry. To increase the odds of the fish choosing to bite the player’s bait, we roll a check to trigger isHungry when a fish detects a fishing lure during GetNeighborsWithinPerceptionRange()
EncroachingAge();
When a fish eats and increases its food score to or past the fish’s foodScoreMax, the fish becomes elderly. A timer starts that upon depletion will trigger the fish to die of old age. This timer is stopped if the fish is hooked by the player, and will reset if the fish escapes the hook. Note there is a hidden mechanic, that if a fish escapes the hook, it’s foodScoreMax will increase. Allowing the fish to grow even bigger than before. It unlocks new potential from the trauma.
EncroachingHunger();
When a fish spawns, a timer starts for when it will begin to be hungry. This varies by breed. When the fish becomes hungry and eventually eats, this timer will reset again. If the fish detects that it is being hunted, it loses its appetite, and the timer stops and resumes when the panic cooldown ends.
Comments