Microactions, Parent Actions, Faction Actions, Family Actions
Microservices? Hah, just kidding. This isn't some web service SOA game. Because screw micropayment games. We're here to talk about the amazing and fascinating world of actions; the state machines that get your units doing the things you told them to do. In Cultura, it happens in a hierarchial manner: faction, family, unit. The short version is that when you queue up a big faction-wide action it then doles out unit actions to individual units. Some automated actions, like finding food for the family, are family actions which doles out actions to individual units within a family.
As it turns out, debugging this is super hard. A unit has a stack of actions to track what it should be doing, as each action finishes it pops and the next action in the stack is worked on. Every action has a simple shared interface so that the unit can processTick just by calling doAction on generic actions. Underneath, each action is a child class of UnitAction which has its own various states. But, big actions are complex. Something as simple as "harvest node" turns into a confusing mess of state transitions (what if my inventory is full? resource node is exhausted? I'm under attack? drop off location disappeared or is full?).
So, we go into refactoring code. The current round of refactoring involves devolving each action into "microactions". An action is now defined by three simple concepts: Exit Conditions, Stacking Conditions and the Action. A microaction is an action without Stacking Conditions.
Exit Conditions
In order to keep things simple, an action has clear exit conditions. By breaking things down to microactions, the exit conditions become very simple. Move action? If I'm there then exit. Build action? Apply enough labour and then exit. Drop off action? Drop off what I can and then exit. The larger actions usually wait until a microaction is finished for some its exit conditions and merely tracks the number of microactions successfully completed.
This requires a bit of paradigm shift within the code. Actions don't get wiped off the stack. There's a few edge cases where things get tricky (making sure that we keep track of resources used in the construction of an object don't get lost if a unit dies but they do get lost if the building they're in is destroyed/looted). Secondly, player initiated actions can get replaced or wiped but they need to only be of certain types of actions so that they don't hit those edge cases.
Stacking Conditions
For more complex actions, they are built using a variety of microactions. A single harvest action involves move actions, pick up actions, drop off actions and the actual harvesting itself. The core of a harvest action can be thought of as the "harvest" action. Everything else is a condition where we bump out of the action and do something else. In the case of microactions, we only ever exit; we never do any other action and therefore are atomic actions. In bigger actions, they may need to use a microaction to complete a task. By never wiping the stack, we can rely on the fact that no matter what happens between one action and the next, an action can be completed to the right conditions and we can safely pop.
A stack condition is something that is checked and if needed, a new action is stacked on the unit. For a harvest action, this may mean that a move action is triggered if the unit isn't close enough to a desired location. We can safely assume when this action completes we are at the right location. This is key. All actions can have any number of things happen before they actually complete. A move can be interrupted by another move, by a flee action or anything else but when the stack pops and goes back to the same move action, it does what we want it to do and when it is done we are assured we are in the right condition. And even if we are not, it'd hit the same stacking condition in the parent action.
As an example, say we are harvesting. We try to harvest but we hit "are we close enough" stacking condition. We aren't! So a move action is stacked. When that completes, we are at the node and can then do the harvest.
A more complex example: we are harvesting. We try to harvest but aren't close enough so we stack a move action. But then the player selects the unit to go chop a tree. Later when the tree has finished chopping, the unit is somewhere else entirely but it pops back to the original stacked move action. It then tries to move to the node before harvesting.
Action
Most of the actions involve some core action that it does. This is "harvest" or "drop off". However, some actions are merely just a collection of Exit Conditions and Stacking Conditions. Refactoring everything into simple if statements leaves the core of any action to be an incredibly simple and small amount of code. This is what makes the debugging super easy. State transitions are clearly defined.
The Future
At more well funded studios (ones with more than zero dollars) the state transitions and actions are usually data-driven. The engine defines a bunch of atomic actions and a whack of possible enter/exit conditions and/or triggers. Eventually, I would like to get Cultura to that point but the microaction refactoring is the first step. It makes a clear way for me to go ahead with the data-driven model. For instance, every "if statement" could instead be codified in a class with an interface that has a single bool function shouldContinue and the c++ code encompasses what it means but the data-driven part just uses labels like "check if inventory full/empty". Then actions are built dynamically on load time with arrays of exit and stacking conditions.
Huzzah.
No comments:
Post a Comment