Airtoum

    Airtoum

    Airtoum

    Airtoum

    Airtoum

    Airtoum: Airtoum
    Ellumi 1 Ellumi 2 Shepherd1

    Airtoum
    Airtoum
    Airtoum

    airtoum\_airtoum_airtoum

    Airtoum

    Airtoum

    Airtoum

    Ellumi The Fire Sprite

    Ellumi The Fire Sprite was a game that I worked on where you played as Ellumi, a creature made from fire, and you solved puzzles with help from friends who would follow you along the way.

    One of the parts of the game that I worked on was the character system. This included every character in the game: The player, the helpers, and NPCs.

    Here's a diagram of the inheritance structure for the characters in the game:

    Airtoum

    Dynamic Character holds most of the meat for character movement and AI.

    Inheriting from that are Main Character and Background Character. The Main Character class would include Ellumi (the player), and any helpers that would follow them around. There was an intermediate layer between the game inputs and the character objects reading them for player movement; that way we could reuse the player movement code for AI movement by just having the AI perform the appropriate inputs. That intermediary was implemented here.

    Background Character is a class for NPCs that the player would talk to that would also need access to the functionality of Dynamic Character: for instance, they could support movement or other actions that are triggered by certain points during dialogue.

    Splitting off of Main Character are the elemental subtypes: Fire Sprite, Air Sprite, and Tree Sprite (which has yet to be implemented). These classes override some of their inherited functions to give them behaviour specific to them, and also include element-specific abilities.


    Inside DynamicCharacter is the FixedUpdate function and the functions involved in platformer movement. FixedUpdate first calls AIInput, which gets input if the character is being controlled by an AI, and then starts a coroutine called DoCharacterPhysics.

    I put the character physics in a coroutine since I had some physics-related things I needed done after the physic ticks were calculated, and FixedUpdate runs right before them. The only way to get something to happen directly after the physics tick is with the WaitForFixedUpdate yield instruction.

    I'm not going to go into every detail over the platforming system since it is fairly standard but there are some special things that I did.

    Inputting left or right makes a target horizontal speed get calculated, and if the player is going the opposite direction or slower than that speed, it exponentially makes them approach that speed. This does make this aspect of the controls dependent upon the number of physics ticks rather than the elapsed time, but I've done an exponential function for this based on time before and it was a major head scratcher that I didn't bother repeating.
    However, if the player is in the air and their actual speed happens to be faster than their target speed, the game will allow them to maintain their fast speed. This makes the controls feel way better, and when paired with fire-jumps (see below), can allow for some really fast, fun movement.

    The reason why I felt the need to do things after the physics tick was for ground checking. The last thing the player movement coroutine does before yielding to wait for the end of physics cycle is set onGround to false. After that, an entire separate component called CollisionData records all collisions during the physics ticks (with OnCollisionEnter2D and OnCollisionStay2D). After it does that, then the player movement coroutine resumes and calls the IterateOverCollisions function in CollisionData, and passes in its function EvaluateCollision as a parameter. The CollisionData component then calls EvaluateCollision on each collision it recorded.I felt like this was a 100% sure method to guarantee that there wouldn't be any funky race conditions and that the collision check would happen in the right order as quickly as possible. It had to be done this way since the collision list would have to be cleared at the start of the next physics update, and in order to have the quickest response to collisions, the check should happen immediately. Also, I felt like checking the collisions directly would be the best way to check if the player was onGround since other solutions like raycasting and collision circles, while extremely accurate, aren't technically 100% accurate. However, it turned out that this method wasn't 100% accurate either (see below).The check for if a collision was standable ground involved checking the surface normal of whatever the collision was with against a maximum steepness angle.

    Once we've set the onGround variable from the previous physics tick, we make use of it by only allowing the player to jump on the ground. I also implemented systems for coyote time and a miniscule jump cooldown (which I was upset that I had to include but there was a problem with the player jumping twice while going uphill or something). I also added support for momentum jumps from moving floors (and coyote time which saves the momentum of the last floor you were on) but this has yet to be fully tested.


    Getting the abilities of the characters to work was challenging. The Fire Sprites would have the special ability to shoot fireballs towards the mouse cursor when you click and hold, and if they shot at a surface next to them, it would propel them away form it, allowing for a sort of fire-jump.

    It took some time to determine what exactly the fire-jump would be like; at first I had it working like a jetpack but that was way too powerful and wasn't what I intended.

    After that, I turned it more into a sort of jump. It would apply an impulse to the player, but only once at the beginning of a click-and-hold.

    However, that still presented some problems. The player could click repeatedly fast enough at the ground to do multiple fire jumps at once, enabling a sort of "double-jump" that propelled them way higher than intended. Also, if a player got next to a wall, they could fire-jump up it by angling slightly towards the wall but mostly upwards. Since they were always near a surface, the fire jumps would keep going through.

    I first fixed the wall-climbing problem by making the force applied by fire-jump the same direction as the normal of what surface you were pushing off of, but that made jumping off of sloped platforms strange and awkward.

    In order to fix the "double-jump" bug, I made it so that Ellumi had to be on the ground in order for a fire-jump to take effect. However, just that alone would take away the fun emergent behaviour of pushing off of walls and going backwards. So, instead, mid-air fire jumps just had their vertical force set to 0. This solution solved both the double-jump bug and the wall-climbing bug, so I could undo the previous change involving the surface normals.

    However, there was still a sneaky fire-jump related bug that persisted. If the player did a regular jump and a fire-jump at the same time, both jumps would go through, and the player would do a super-jump. This was because the fire-jump would occur first, notice it was on the ground, and start a fire-jump. Then, the regular jump would occur, still be on the ground, and also happen. This was fairly straightforward to fix; I just had to copy over what the regular jump had to do and set onGround to false and start the jump cooldown timer after a fire jump.

    Lastly, there is still one bug related to jumping that is still in the game: if the player jumps while moving into a wall, they can sort of jump up it. I can only ever reproduce it while holding the mouse down to shoot fireballs, but even that shouldn't do anything since there's only 1 impulse at the start and the fireballs don't collide with Ellumi. It looks like it has something to do with the game falsely approving certain wall collisions as standable, but I'm not entirely sure. However, this bug is really inconsistent to pull off and most likely won't occur during regular gameplay. Fingers crossed.


    The Air Sprite class overrides most of the classes inherited from DynamicChracter. This is mainly because the Air Sprite can fly.

    The movement and ability for the Air Sprite was more straightforward, since I didn't have to worry about jumping, and the ability just spawns something rather than affecting the player directly.


    My methods for the player controlling the abilities require mouse controls, and for that I made a mouse singleton. When the mouse clicks, it broadcasts an event that a mouse click has started, been held, or was released. The special character classes then subscribe to those events and listen accordingly. A mouse singleton was necessary since based on certain other events the mouse singleton will emit an ability mouse event or a move command mouse event.
    There are also events for switching control between characters; after asking a helper to use their ability, the mouse ability controls switch to them, and after using the ability of a helper, the controls revert back to the player character. However, the characters keep track of what control scheme they're currently using rather than the mouse singleton.


    In retrospect, I'm glad that I planned the code architecture out beforehand, but at the same time, I feel like there is probably a better solution I could have gone with to make the structure less messy. Perhaps designing two architectures beforehand and selecting the better one can help me make sure I can keep things as scalable as possible while also remaining fairly clean and simple.

    Ellumi The Fire Sprite

    Ellumi The Fire Sprite was a game that I worked on where you played as Ellumi, a creature made from fire, and you solved puzzles with help from friends who would follow you along the way.

    One of the parts I worked on for this game was the AI system. I was responsible for the helper's pathfinding system, since they follow the player and the player is able to tell them where to go.


    There were two separate AI systems I made for this game: The platformer AI and the Air Sprite AI. The platformer AI would have been for a Tree Sprite helper that moved according to platformer mechanics, but it also could apply to Fire Sprites. However, the player would control Ellumi directly. The Air Sprite needed a separate system since Air Sprites can fly around, and move completely differently from everyone else.


    Air Sprite AI

    I'm going to cover Air Sprite AI first since it's simpler, it's more relevant to the demo, and I was able to get it working very well, even though I started on the platformer AI earlier.

    I knew I had to approach the Air Sprite AI differently than the platformer AI, since moving in small increments and checking for nearby walls would have been really inefficient and inaccurate.

    Both pathfinding algorithms work by using a recursive search. The Air Sprite pathfinding begins with a function called ExploreAir, which sort of houses the search. The first nodes of the search are started here, and all the extra processing after finding the best path is also done here.

    There are two types of search nodes, and the search will recursively switch between them based on various situations it finds itself. All of the search nodes are fed a pos variable as the spatial position in the level where the node is. I frequently alter this variable within search nodes as they look around and prepare to call other search nodes.
    The first is ExploreFly, which tries to go in a straight line directly towards the destination. The other is ExploreSurface, which sticks to the walls of level geometry to try to get around them. Both searches initially only returned their best score, which was the lowest distance their branch of the search tree got to the destination. However, in order to make decisions from the search nodes, I had to also return whatever "move" the AirSprite needed to do according to the best found path. So, I made use of C#'s tuple feature, and had both search nodes return a tuple of the best score and whatever the best move associated with that score was. In the case of the Air Sprite, that ended up being (float, Vector2). I used a Vector2 since that's the exact direction the AI would have to go, and transforming that into actual inputs could be done in ExploreAir. (the input system only really allowed for 8 directions of movement)

    However, later on, for debugging purposes, I also wanted each node to also return the entire selected branch it thought was the best path, so they were then fashioned to return (float, Vector2, List<(Vector2, Vector2)>). The list is of tuples, and the first Vector2 in the tuple is the spatial position of the nodes in the best path, and the second is the "move" that those nodes want the Air Sprite to go, if the Air Sprite were at them.

    In ExploreAir, it starts by calling the search node ExploreFly. I could have started multiple nodes here, like I do in the platformer pathfinding, but I only need one here.Here's a flowchart of ExploreFly:

    best, move, and path keep track of what the best decision from this node should be. move refers to what immediate action the Air Sprite should do if this were the first node in the tree, while path keeps track of the "future nodes" - everything that comes after this one in the optimal branch of the search tree.ExploreFly takes explore\_inc and explore\_dec as inputs, and these are booleans to tell it if it should search vertex indices in a decreasing or increasing way. Think of it like searching clockwise or counterclockwise around the surface. The vertices of the CompositeCollider count upwards around it, so the neighbors of each vertex always have adjacent indices.CheckIfBetter is a local function within ExploreFly. A version of it is going to appear in every search node.The left branch of "did we hit something" means we didn't hit something, so we're able to achieve a distance of 0 by flying straight at it.The check against the paramater ignore\_collider comes into play in ExploreSurface. If we're searching the edge, we want to know when we've made it to the other side of the obstacle and can then leave the edge, so there's a check of ExploreFly within it. However, if that check just hits the surface we're already exploring, we don't need to trigger more ExploreSurfaces on it.The big process on the Composite Collider does a lot of iteration, so it would be nice to not have to do it twice. That's another part of why ignore\_collider is necessary. The process itself involves iterating over all the paths in the CompositeCollider (since it can support having multiple disjointed sections), and then iterating over each point in the path to try to find out which pair of points makes up the edge that is closest to the raycast point. The process produces 3 relevant variables:
    coll\_paths - a list of all the collider's paths (which are lists of points)
    closest\_segment\_path - index of which path has the edge we raycasted onto
    closest\_segment\_lower\_point - index of the lower point in the edge on that path (lower as in lower index, not spatially lower). We don't need to save the higher point since it's just (lower point + 1) mod the amount of points on the path.
    Those variables, along with the collider itself, are all passed to ExploreSurface. (referred to as "data from previous step" on flowchart)
    final\_path takes whatever the current future path is and prepends (pos, move) to the beginning of it, so it can return final\_path as a potential future path for the parent node.

    With these two functions defined, the Air Sprite does a decent job of getting to it's destination. ExploreAir starts by calling an ExploreFly node, and whatever move it returns is then converted into the 8-directional inputs that control where the Air Sprite flies towards.
    As a side note, the move vector is treated probabalistically. If the resultant vector was Vector2(4, -2), then it would be normalized to Vector2(0.89, -0.44). The components of the vector are then treated like probabilities; there's an 89% chance that right will be pressed, and a 44% chance that down will be pressed. This is a bit suboptimal, but it's not really noticable. I made it this way just in case if it started rubbing against a wall it wasn't seeing, since the pathfinding starts from the middle of the Air Sprite and it may still be colliding with a wall at one of the exterior parts, so this will usually let it slide past the wall.
    HOWEVER, this algorithm did result in some problems. For instance, this video shows a problem the AI had with multiple paths that both make it to the destination: a really long path that goes to the left around the entire wall, and a short path to the right. Since they both had the same exact score, the AI had trouble choosing which one was better, so the one that was searched first was prioritized.

    This was fixed by adding a small depth penalty to the search. The depth penalty only needed to be teensy; I set it to 0.05. This meant that the score of a search that reaches the destination after 20 nodes would have 20 * 0.05 added to its score, so that would be equivalent to getting as close as 1 unit away from the destination instead of being at it. This solved the issue quite nicely.There were other issues. Since the move that is returned determines what direction the Air Sprite should move in, the ExploreFly function needs a way to know if it's reached the surface of the wall yet and can start moving along the walls. It does this by checking the distance from its position to where it first collides into a wall. If the distance is short enough, then it can assume it's reached the wall and can propagate up whatever wall move is better. If not, then it assumes it hasn't reached the wall yet and needs to keep flying towards it.This leads to a bug, where if the initial fly towards the destination is steep enough against a wall, the initial fly and the path along the wall might end up contradicting each other. In this video, the destination is at the bottom left, so the Air Sprite tries to fly left. It then reaches the wall, which it then searches, and that search tells it to go around the wall by flying right. Once it flies right a bit, the initial fly becomes too long before it hits the wall, so it tries to fly straight towards the destination until the fly distance drops beneath the threshold again.

    The fix for this bug also makes the routes the AI chooses better, a problem that had also bothered me for a while but had brushed off as my solution was good enough:

    The solution involved the overall best path the AI chooses, which is returned in the path variable, and currently only used for debugging purposes. What I did was iterate over the best path it had found, and then look for shortcuts. We start at the back of the path, near wherever it ends, and work our way up it back to where the search begins. If, at any point, we find a point along the path that we can just fly to from where the Air Sprite currently is, we forget everything before that point in the sequence and just beeline towards that node instead of whatever was returned as the best move.Internally, this doesn't make the internal best path quite the same as the optimal path, since it doesn't care that it could skip to point B in the figure above from point A, but once the Air Sprite acts upon the shortcut and reaches point A, the shortcut to point B will be discovered, and then it will be taken. So, it doesn't need to have all of the future shortcuts stored, since it would work just the same anyway.And how does this fix the bug of not knowing what counts as being close enough to the wall? Well, the corner the wall searching wants the AI to go to is directly accessible to the Air Sprite as a shortcut, so it can disregard the initial urge of trying to go straight towards the destination.


    Platformer AI

    Getting this to work was a bit of a nightmare. I did enjoy it, though. The development process could be broken down into versions.

    v1

    I'm not going to diagram quite to the same level for these, since that would be too much diagramming.There are many parallels between this and the Air Sprite pathfinding. Version 1 of platforming was developed first before the Air Sprite, so the full breadth of things I had learned hadn't been added to it.v1 was made up of these functions:
    ExplorePlatform - The analogue to ExploreAir. This houses the search algorithm, and processes its results into inputs.
    ExploreLateral - This searches recursively in increments to the side.
    ExploreWall - This searches recursively in increments up walls.
    An important difference between the Platformer AI and the Air Sprite AI was that everything was done in discrete increments; for v1, I based these increments on the width of the character's collider.At this point in development, ExploreLateral and ExploreWall returned the tuple (float, AIMoves). AIMoves was an enum that encoded all the moves the AI could do: Stop, Lateral, Up, Down, LateralUp, JumpUpwards, JumpLeft, JumpRight. A lot of these resulted in the same inputs, like Up and JumpUpwards. Anyways, the float is the score of that branch of the search, and the AIMoves is whatever move should be done if this node is the root of the tree.ExploreLateral(Vector2 pos, bool is\_right, int depth, int max\_depth):

    ExploreWall(Vector2 pos, bool is\_right, int depth, int max\_depth, int wall\_depth):

    Both of these functions have a parameter variable is\_right, which, if true, means that the function is going to the right, and to the left if not. I may have overcommitted to reusing code, so there are AIMoves like Lateral and LateralUp, where the lateral is later decoded to mean "whichever of left/right is correct". The is\_right variable is passed down to both ExploreLateral and ExploreWall, and is used to keep track of which direction the player is walking and which side the wall is on.All the environment checks are made using Physics2D.Raycast. The downwards check is done using 3, one in the center of the character and two more on their left and right sides, since they may standing halfway off of a platform.In this diagram, the recursive calls like "ExploreLateral() to the side" tell you the position where that node starts, not the direction it's looking. So the "to the side" thing means that the next node is starting to the side of the current node, according to is\_right.The snap to floor in ExploreLateral is for slopes. It helps keep the node a fixed distance above the floor. The check for if we can stand on something to our side is for slopes that are steeper, and might be caught to our sides. There's a class variable called AICliffDownwarpDistance which determined how far away the floor could be for snapping. In v1, this was actually as high as 6, since that was just how I pathfound for dropping off edges.ExploreWall has a variable called wall\_depth, which keeps track of how many wall nodes we've searched in a row. A variable is calculated based off of the player's jump height called max\_wall\_depth, and if wall\_depth ever gets greater than max\_wall\_depth, that means that the wall is taller than we can jump, so we should stop that breach of the search.An important thing to note is that, at most, each node only other spawned 1 child node, or terminated. Under this structure, there was no need for the CheckIfBetter command seen earlier; there was always 1 definitive answer and that was returned. The only time there are 2 nodes spawned is in ExplorePlatform; an ExploreLateral node to the left and one to the right.This algorithm is okay with staying on a single platform, but it can't make any jumps to higher platforms or jump over gaps.

    v2

    In which situations could the algorithm jump to a new platform? Well, jumps could be made from just about any ordinary ExploreLateral node. So, every time the ExploreLateral node decides to walk, it also creates a jump node to the left, the right, and straight up. We would also need to alter ExplorePlatform to consider jumping left, right, or up.

    The search at this point required a new type of node; ExploreJump. ExploreJump went through a lot of iterations, but its structure was mostly similar to those previous. However, along with position, it also tracked expected velocity. I used a trajectory function to calculate the expected next point after some timestep (which was usually 0.1 or 0.2 seconds), and then raycasted between the current position and the next position to see if we hit anything. If we didn't, then we do ExploreJump again at the next position with depth increased and the velocity adjusted. But if we did, then we need to run some checks on it. Can we stand on it? If so, then do an ExploreLateral to both the left and right. We may have landed somewhere new. If not, is it a wall? Our current ExploreWall function isn't prepped to handle this. Is it a ceiling? I originally had this cancel the jump, but that caused some problems if a jump was required, but there was also a low ceiling that you'd bonk your head on, so I changed it to do another ExploreJump with vertical velocity 0.This solution, while it technically worked, spawned a whole host of new problems.The character loved to jump way more than it needed to. If the player clicks slightly above the ground, the AI will jump right before reaching the destination to try to get exactly to it, or worse, it will jump a little bit early to try to snipe it in the fall of the jump. Okay, let's add a minimum score you can get with jump nodes, to make walking capable of reaching 0 but jumps not. Nope, that didn't really change anything. Let's give a score penalty to jump nodes. Nope, it either still jumps just as much or not at all.The AI sometimes bounced after finishing a jump with another jump. This might have been due to the jump button being pressed during the entire jump. Okay, we'll make it only press jump on the first node in the jump. Should that happen in ExploreLateral or in the ExploreJump? I chose to put it in ExploreJump, which was a messy solution.The AI now starts wriggling back and forth. This is due to the discrete system we're using; it's finding that a jump just to the right gets really close to the destination, but as soon as the character walks right, the grid shifts with the character, and now there's a jump to the left that works better than the one to the right currently does.Also, not to mention, this is horribly unoptimized. Every time the jump node lands, it spawns two more ExploreLateral nodes on either side, since it doens't know if it landed on a new platform or not. This results in a whole lot of redoing of work, re-exploring the same walk paths if we're just in a flat area. I tried to mitigate this by cancelling walks we've explored before, by storing them with their nearest grid coordinate in a dictionary along with the score we made it to them with. If the new score to this spot was better, update the dictionary; if not, throw this walk out. This cancelled about 25 walks on average, which was okay, but performance still suffered.At this point, I was considering a less discrete system that was more akin to the Air Sprite and how it looked at the vertices of geometry. Jumps could tell if they're landing on a platform that's already accessible by walk and wall nodes by checking the edge, rather than the position. I did not attempt this system; maybe it would be an interesting thing to try.In order to try to wrangle these bugs, I changed the functions to also return a list of the best branch (as seen in the Air Sprite AI), at least just so I can see what the AI thought was the best path. I think the return type ended up being (float, AIMoves, List<(Vector2, AIMoves)>). This required me to change ExploreLateral and ExploreWall to have the function CheckIfBetter along with keeping track of best, move, and path. In the video above, you can see the best path found being drawn in white.

    v2 - Memory Edition

    https://youtu.be/n7HzVSDfCBQ

    Since the algorithm was finding a good path sometimes, I figured that the algorithm could just recognize when it had a good path and then hold onto it for a while.This didn't work. The returned list was not a robust enough to be followed. I tried to make a system where the AI would follow the first AIMove in the list until that node's position was reached, and then move to the next. If it found itself in the position of a future node, okay, it skipped ahead on accident. There was an issue where it ended up making a move that missed a node and getting completely lost.The system for detecting if it had a good path was based on this: if the score is 0: hold onto that path for 2 seconds before calculating a new one. If the score is 1: hold onto it for 1 second. The wait time asymptotically reduces to 0 as the score approaches infinitely poor.I was able to get it in a loop where it leapt up a platform, overshot its destination, fell down, recalculated, and then climbed back up, only to overshoot it again. The recalculation time sometimes caused the character to take the wackiest paths to the destination.At this point, the code was becoming increasingly unmaintainable.

    v3

    I decided that there was too much to keep track of, so I'll build it from the ground-up again, and this time, I'll just use markers to indicate where jumps are viable in levels. A bit of a cop-out, sure, but it's a significantly better solution that that mess.I, uh, did it again. Here's as best I got it before moving to v4:

    I leant too far into the idea of getting a returned list that is robust enough to be followed. I threw away the AIMoves at this point, and instead opted to use the inputFlags codes I was already using. (The variable inputFlags is an int that stores all the controls the character is doing. The bits within the int all encode a different control)ExploreLateral and ExploreWall both now also kept track of velocity! ExploreWall could handle exploring walls upwards, and downwards. float best was now renamed to the much fancier float best\_fitness, and AIMoves move had been replaced with a List<(Vector2, int)> node\_inputs. List<(Vector2, AIMoves)> path was now changed to the better-named List<(Vector2, int) future\_path. node\_inputs would be for storing whatever inputs the current node should be doing for however many physics ticks the current node would last, and future\_path stores a concatenated list of all the inputs in the future. Why is node\_inputs a list? Well...To maximize accuracy and robustness of the returned path, I decided to simulate every physics step within each of the nodes. Now I wouldn't actually do any simulation, this was all just calculations to determine where next\_pos would be, tweaking next\_pos and next\_vel about 15 times between nodes. Then, once we know where next\_pos would be given certain assumptions, we can test to see if those assumptions are true using raycasts. If the path from pos to next\_pos was interrupted, try to find out where it was interrupted.At the end of every node, final\_path is processed for being returned. node\_inputs are attatched to the front of final\_path, and future\_path is added after that. The function can then return final\_path as a potential future\_path for a parent node.This quickly became unmaintainable. Initially, everything seemed to be simulated to be way too fast. That was due to the simulated positions not factoring in Time.fixedDeltaTime. However, it's still weird, and I have no clue what's going on. It looks like it's only keeping the node inputs from the last node in the tree. This was supposed to be easy: reimplement the stuff without all the jumping, except maybe on ledges where you would have stopped anyway, and then add the jump markers.

    v4

    The best version so far was v1. So I reverted to it. Now all I need to do is some tweaking and add those jump markers.So, I re-added the ExploreJump function. Since I was rewriting this one, I went ahead and wrote it in the CheckIfBetter format.

    This ExploreJump doesn't need to be super-duper good since it's going to be triggered by manually placed markers instead. Maybe I should have added a handling for bonking your head on ceilings, but that can be worked around by fiddling with the marker to make the agent think it has less jump power than it does.Anyways, that's enough of that. Let's look at the jump marker! I copied the same structure I used for the camera constraints in this game. It's simple, quick, albeit a smidge dirty.A class AIMarkers has a static variable List<AIMarker> Markers. This is essentially just a global variable that keeps track of all AIMarkers.The class AIMarker adds itself to AIMarkers.Markers in its Awake() function, and removes itself in its OnDestroy() function. This should work just fine. To add for some versatility, the AIMarker class support smore than just the jump markers. That's the only thing it's used for though. In order to provide that support, it has a public virtual function that the AI can call that's named ModifyAI(Vector2 pos). The pos variable is used to determine if something is in the marker's activation area. The function returns a tuple of types (bool, MarkerTypes, Vector2), where the bool is success/fail for if the pos has activated the marker, the MarkerTypes is just an enum (currently can be only None and JumpMarker), and the Vector2 is just there since I needed a way to report the jump direction from the JumpMarker. I guess it's where the information from the AIMarker would be passed through. I may have made this hard for myself.Finally, there's the JumpHere class, which inherits from AIMarker. It has a size variable, a jump\_direction variable and a gravitational\_acceleration variable. It overrides ModifyAI so that it returns true if the position of the search node is within a box of size size around the JumpHere marker. It then also returns MarkerTypes.JumpMarker, and jump_direction. gravitational_acceleration is only used for the in-editor rendering done in OnDrawGizmos, which I'm a bit proud of:

    And then, back in ExploreLateral, I just took the case where it would have just chosen to walk left or right, and then had it scan through every AIMarker in AIMarkers.Markers and then if the conditions were met, call ExploreJump. I did have to reimplement CheckIfBetter for this, since now this one branch was searching both ExploreLateral and ExploreJump.Also, for v2 and v3, the housing function ExplorePlatform did different searches based off if the character was in the air or not, to try to continue looking at whatever jump path it was doing before. Instead of doing that, I just have this algorithm not run when the character is in the air. Whatever inputs they're doing are preserved, so they still complete the jump.There was a bug where landing from an ExploreJump would shove the next node into the ground, but that was resolved by not having it do that.I think it turned out pretty great.Here's a video of me playing around with it.


    Maybe if my code were cleaner and had less band-aids, the vision of getting v2 to work might have been. There were definitely some things that I really should have turned into functions. Perhaps that non-discrete solution is worth looking into. Maybe I could have saved a whole lot of headache if I had done more research on the subject, or planned it out more. Maybe if I had been less stubborn, I could have gotten to this working solution much much earlier, and then maybe toyed with the idea of getting a helper-independent system to work. All in all, though, this certainly was valuable, and I did get to make cool stuff. I'm quite proud of the Air Sprite solution, which I suspect is at least a little bit similar to Unity's NavMeshAgent (although that may use a network instead). I am also very happy to get the Platformer AI functional.

    Saving and Loading in Shepherd

    What I learned about saving and loading from working on Shepherd:Save/Load systems are one of the things that are better to get in a game earlier than later. It's similar to multiplayer in that aspect; if you want them in your game, it's best to plan ahead and add them early before your project grows too much instead of trying to shove them into a whole game.Of course, that's not really what comes to you intuitively. Saving and loading feels sort of like a peripheral system that you can add whenever, like a main menu or a pause screen. Plus, in the process of developing a game, it usually feels more appropriate to, y'know, work on the game itself.Here's how I implemented saving and loading in Shepherd:First, we needed to define the parameters. What needs to be saved? When is the player going to save? How much can the answers to those questions change? I figured that everything that the player could interact with in a meaningful, mechanical way whose state could change would need to be saved. That includes: The player, the sheep and all their stats, the time of day, the scene that was currently loaded, the state of every food source, water source, every cactus with pickable fruit, every disarmable obstacle, each coyote instance, the player's tutorial progress...When would the player save? We didn't quite know yet. So, I went for the safest option: The game can be saved and loaded whenever.Before I even started implementing the save/load functionality, I had previously wrote a script called SessionRecorder meant for analysis of playtests that recorded the player's stats and the stats of the sheep and dumped them into a .csv file. Ideally, I would also like to reuse that system. So I did. Whenever the game needs to be saved, snag all the data from the player and sheep and write it to file.Speaking of writing to file, there were some issues I had with that, too. I would need to store complex data structures in some sort of file, and ideally I would also want the file to be human-readable, too. So saving to a csv file (comma-separated values, this is sort of similar to what spreadsheets use) probably wouldn't have been the best. So! I decided to switch to JSON. And, after a few minutes of not finding a satisfactory JSON encoder for Unity, I made the decision to write a JSON encoder/decoder myself. (!!)I think I should have not done that. There was probably something already out there that would encode and decode JSON if I had bothered to spend a few extra minutes looking for it. But instead, I spent a couple of days writing my own kind of hideous encoder/decoder. Lots of type checking and dictionaries with string keys and object values. Also, I later had to go back and change the loading from save files to make it safer and future proof, since it should still load everything else (and not throw an error) if there were, say, a single value that was missing from a save file.As I was trying to figure out how to save objects of multiplicity (such as food sources and brambles, which there could be many of and the number would increase as we expanded the game), I realized another subtle constraint that I might need to satisfy: Loading zones. Levels, worlds, or whatever else you want to call it, are saved in Unity as scenes. If the size of the game ever got too large to load the entire map all at once, we would need to split it up into multiple parts.

    An example of a loading zone. There needs to be an overlap between the two scenes to make the illusion work for the player, but that then involves having two copies of all the objects within that overlap.

    Notice how there's a small area that's shared by both scenes. That means that there are technically two copies of the stuff inside that shared area, even though from the player's perspective they look like one and the same (which is what we want). If we wanted to save the state of something that existed inside that shared area, we would need a way for these two instances to share a single state.AND THEN IT HIT ME. We'd also been having trouble getting the state of objects to persist across scene switches. So, for instance, if you pluck a treat off a cactus and then go camping, then the treat would go back to being unpicked on that cactus. What if I could kill three birds with one stone?Introducing some of the wackiest saving architecture you've ever seen. So, every object of multiplicity has a position. Instead of tagging them by ID or name, we record the state of each object under their position. We have a global class in our game called "GameController", which persists across scene switches and is always present during gameplay. Inside that GameController are a few dictionaries, which are a data structure that allow you to associate data with a key (kind of like looking up meanings of a word in a real dictionary). When each Object of Multiplicity loads in, it first looks at it's position, and then checks the GameController to see if there's any data for an object saved under its position. If there is, that means that this area had been loaded previously, and it should set its own state to whatever data is stored. If there is not any data saved in the GameController, then use whatever values it normally has when it's encountered for the first time, and save those to the GameController. Whenever the state of an Object of Multiplicity changes in any way (from interacting with the player or some other way), update the corresponding record in the GameController. For something that moves over time, like the Coyote, use the spawn position as its identifier instead of its current position.When the game needs to save to file, grab the dictionaries that are in the GameController (since they represent the state of every Object of Multiplicity and are updated with every state change), and encode them into JSON. When the game needs to load from the file, first unpack everything in the save file into the dictionaries in GameController, and then broadcast an event to all Objects of Multiplicity to read from the GameController and set their state accordingly.Objects of Multiplicity <-> GameController <-> SaverLoader <-> SaveFile1.jsonThe GameController needs to be updated with every state change (and not just when the game needs to be saved to file) since it's also peristing state across scene switches. It would probably be better to have it ask for the data only when the scene is about to switch, but I didn't do that.Since the dictionaries take in a position (technically, it's a hash calculated from the position), any two objects of the same variety that are in the same position share their state, even across scenes. Also, every object inherently has a (most likely unique) position, so it takes no effort to add or remove new objects since you don't need to ensure a unique name or ID. Moving an object between saving and loading will just have it's old data go unused and it's state reset anew, which isn't really that big of a deal. Maybe I could go back and make sure the unused data gets cleaned up.Additionally, I did have to go back and change how the loading worked. I mentioned this earlier, but this became apparent after we implemented a variable for "Easy Mode" for the sheep to keep them from dying straight away while the player is still learning the controls of the game. When this was added, I realized that because there was a new variable to keep track of, old save files that didn't have this variable in them would no longer load properly, since the game would look for data that didn't exist, and fail to load. This is bad! So I had to go back and encapsulate every property that was to be loaded within a safe chamber that could fail without blowing everything up. In the case of trying to load an "easy mode" variable when it didn't exist, it would fail to find it, set it to some default value (which in most cases just meant leaving it unchanged), log the failure, and continue on loading the rest of the save data. Along with future-proofing, this also would protect against chunks missing in save files for whatever reason.And that's my save/load system! I think it's alright. There are parts of it that I'm proud of and parts I'm kind of embarrased about. I think there are probably better solutions out there that I could have researched. I definitely don't think writing my own JSON encoder was the right move, but at the very least I understand the complete inner workings of it. I think the messiest part is probably the case-by-case state writing and setting in each Object of Multiplicity, but I think that's usually a just a part of the Memento pattern. I think there is maybe something you can do to just have a list of all properties to be saved and loaded, but that gets kind of tricky when dealing with object references and subsystems like animation. I think the SaverLoader script is kind of monolithic, and very tailored to the game. Each time we implement something new that would need to be saved, that file would need updating. Perhaps there's a way to extract that out into some sort of SavedObject component on everythiing that's saved, and then have the SaverLoader communicate with those SavedObject scripts instead of doing everything by itself. Then maybe for the case-by-case exceptions, we could inherit from SavedObject do make specific changes. We could also maybe do cleverer design around that to minimize the number of object references in our scripts, but that probably would come with other issues that make that not worth it.