As I started on this part, I realized that AI was going to require at least 2 major parts:
- The ability for the computer to decide what a good move is
- The ability for the computer to execute that move, in an attractive way.
In this article I will mostly tackle the 2nd point, though the computer will decide what to do, there decision will be pretty lame. But it serves as a framework to expand later.
Part 1 - Deciding on a plan
Entities which are computer controlled will get a new Component called AI. AI will have a field of type Plan. Plan will contain the information about what the entity will do:
- Where will it move?
- Where will it attack?
- What attack will it be using?
- Will it move first, or will it attack first and then move?
- How "good" is this plan?
To determine the best Plan, the computer will loop over all possible combinations of what it can do. For now let's juts assume that they entity will move first, and act second. Basically, we must loop over:
- All possible cells you can move to
- All possible actions you can do
- All cells you can target with that action
- This may be a single cell, or it may hit a whole field of cells centered on a particular one
Array<Plan> plans = new Array<plan>(); moveFirst = true; for (Pair moveTarget : reachableCells) { for (Action action : availableActions) { for (Pair actionTarget : cellsWithinActionsRange) { Array<pair> field = getFieldFor(actionTarget, action); score = scorePlan(moveTarget, action, field); plans.add(new Plan(moveFirst, moveTarget, actionTarget, action, score); } } } plans.sort(); plan = plans.get(0);
(Note, in practice we will also want to consider that they can act first, then move later - the loop would look quite similar but there are some additional complications that might arise from this).
Let's look at these and figure out which ones we already have the foundation for, and which ones need to be developed.
- reachableCells - DONE
- Action and availableActions - We will need to design these
- actionTarget and cellsWithinActionsRange - should be easy to implement if we give Action something called range
- getFieldFor - in the simplest case, let's say that each action hits just the target cell, or the target cell + it's neighbors, or the target cell + 2 sets of neighbors, etc. If we give Action something called "field" where field=1 means just the center, field=2 means the center+neighbors, etc, this should be easy too!
- scorePlan - ????????
To accomplish this, I made a class called Action2 (Action is a class used in libgdx, so I went with Action2 to avoid any confusion) and a component called Abilities which holds the different actions an entity can do.
package com.blogspot.javagamexyz.gamexyz.abilities; public class Action2 { public int range; // How far from can this ability be targeted public int field; // How large an area does it hit (1 = single cell, 2 = middle+neighbors, etc) public float baseSucessProbability; // How likely is it to hit, on its own public float damage; // How much damage does it do, on its own }
package com.blogspot.javagamexyz.gamexyz.components; public class Abilities extends Component { Action2 attack_sword; Action2 attack_bow; public Abilities() { attack_sword = new Action2(); attack_sword.damage = 4; attack_sword.baseSucessProbability = 0.9f; attack_sword.range = 1; attack_sword.field = 1; attack_bow = new Action2(); attack_bow.damage = 3; attack_bow.baseSucessProbability = 0.7f; attack_bow.range = 4; attack_bow.field = 1; } public Array<action2> getAbilities() { Array<action2> abilities = new Array<action2>(); abilities.add(attack_sword); abilities.add(attack_bow); return abilities; } }
Each entity will have 2 attacks: sword and bow. The sword has a range and field of 1, a 90% chance of success, and does 4 damage. The bow has a range of 4, a field of 1, a 70% chance of success, and does 3 damage.
For now it's kind of silly, but getAbilities() returns an Array containing those two actions. With that, we can build our Plan class
package com.blogspot.javagamexyz.gamexyz.AI; import com.blogspot.javagamexyz.gamexyz.abilities.Action2; import com.blogspot.javagamexyz.gamexyz.custom.Pair; public class Plan implements Comparable<Plan> { public boolean moveFirst, moveDecided, moveDone, actionDone; public Action2 action; public Pair moveTarget, actionTarget; public int score; public Plan(boolean moveFirst, Pair moveTarget, Pair actionTarget, Action2 action, int score) { this.moveFirst = moveFirst; this.moveTarget = moveTarget; this.actionTarget = actionTarget; this.action = action; this.score = score; moveDecided = moveDone = actionDone = false; } @Override public int compareTo(Plan p) { if (score < p.score) return 1; // p was better else if (score > p.score) return -1; // p was worse return 0; // they were equal } }
moveFirst is the boolean which, again, lets us know what we're doing first (in the loop I outlined above, everything was predicated upon moving first). It also has an Action2, a Pair indicating where it will move, and a Pair indicating where the action will be targeted. The other booleans will come up later. moveDone and actionDone are to help us time things smoothly (part 2), and moveDecided will be used for when we act first, move second.
Also, notice it implements Comparable so that we can sort it to find which is the best plan. In reality, if we are strictly going with the BEST plan, then we don't need to sort it, we just need to find the maximum. But to shake things up later, we may want to pick one of the best strategies at random, so that we are harder to predict - in this case we may as well sort it.
Okay, now let's look at the AI component. It should hold a Plan, and probably a timer (so we can time the execution of the plan). Now, we don't want all of the entities with AI processing all the time, just on their own turn. This could be accomplished at least two ways I can think of:
- Give each AI a boolean flag for being active or not. Then, each turn during processTurn(), check to see if the next entity has AI - if so, set active to true. At the end of that entity's turn, before they call processTurn() again, set their AI to inactive.
- Have a dummy component (CPUControllable?) to indicate that the Entity is controlled by the computer, and each turn during processTurn() check to see if the it has CPUControllable, and if so, add a new AI component. Then, at the end of their turn, remove the AI component.
package com.blogspot.javagamexyz.gamexyz.components; import com.artemis.Component; import com.blogspot.javagamexyz.gamexyz.AI.Plan; public class AI extends Component { public Plan plan; public float timer; public boolean active; public boolean planDone; public void begin() { timer = 0; active = true; planDone = false; } }
The first 3 things we already talked about. We'll talk about planDone soon, but it becomes true as soon as the computer has figured out the plan. I kind of broke the whole "component-as-data-storage-only" paradigm - but it makes it easy for when I want to start someone's AI (i.e. in processTurn()).
Now we need to look at AISystem. It is responsible for a lot of different things, primarily deciding what Plan to do, but it's also in charge of pacing. We don't want to do everything every time, so it has a series of flags/stops along the way.
public class AISystem extends EntityProcessingSystem { @Mapper ComponentMapper<AI> AIm; @Mapper ComponentMapper<Stats> sm; @Mapper ComponentMapper<Movable> mm; @Mapper ComponentMapper<Abilities> am; private OverworldScreen screen; private GameMap gameMap; private ActionSequencer sequencer; private boolean startMove, startAction; @SuppressWarnings("unchecked") public AISystem(OverworldScreen screen, GameMap gameMap) { super(Aspect.getAspectForAll(AI.class, Stats.class, Movable.class, Abilities.class)); this.screen = screen; this.gameMap = gameMap; startMove = true; startAction = true; } @Override protected void process(Entity e) { AI ai = AIm.get(e); if (!ai.active) return; if (!ai.planDone) decidePlan(e, ai); else { // If moveFirst if (ai.plan.moveFirst) { // Move if (!ai.plan.moveDone) { if (startMove) { ai.timer = 0; startMove = false; } sequencer.move(gameMap, screen, ai.timer); } // Act else if (!ai.plan.actionDone) { if (startAction) { ai.timer = 0; startAction = false; } sequencer.act(gameMap, screen, ai.timer); } } // Else, actFirst else { // Act if (!ai.plan.actionDone) { if (startAction) { ai.timer = 0; startAction = false; } sequencer.act(gameMap, screen, ai.timer); } // Decide move location else if (!ai.plan.moveDecided) decideMovement(e, ai.plan); // Begin moving to target location else if (!ai.plan.moveDone) { if (startMove) { ai.timer = 0; startMove = false; } sequencer.move(gameMap, screen, ai.timer); } } } // Don't increment your counter while the camera is moving // This let's us focus on the character for a second (or so) // before they start acting. if (screen.cameraMoving()) return; ai.timer += world.getDelta(); // If everything is done, process the turn if (ai.plan.actionDone && ai.plan.moveDone) { ai.active = false; startMove = startAction = true; screen.processTurn(); } }First things first, for now it's kind of awkward, but I'm telling it to process things not just with AI, but with Stats,Movable, and Abilities (I don't even use stats yet).
ActionSequencer is a class we'll discuss next, which I use to actually pace things (Part 2). It has two methods right now: move() and act(). They, as you might guess, control the pacing of what those ought to look like.
In process() it first checks to see that this particular AI is active. If not, it just quits. Next it checks to see if it has already determined the plan. This check prevents us from determining the plan every time. Also, and I think this is a decent strength here, it may take a while to determine the plan. Even if it takes 1 second, this would be no big deal to the player - unless the graphics locked up for that whole second, which is exactly what would happen if we required the plan to be determined in a single go. We don't worry about it yet, but if our needs expand, this will help us so that every time AI is processed, it can refine the plan a little bit more until it has decided, at which point it is ready to act.
If the plan is decided, we begin enacting it. Here's a plain outline of what this section says:
- if moveFirst
- move
- act
- else
- act
- determine where to move
- move
Remember again, this thing will be processed hundreds to thousands of times. You don't want to act until you're done moving (or vice versa), and you don't want to determine the plan thousands of times, nor where to move. That is the purpose of those flags, plan.moveDone and plan.actionDone. It will do those things until they are done, then no more. startMove and startAct are flags that tell us that we are on the very first loop where we move (or act). This is helpful for us to reset the timer (so those will execute appropriately). Other than that, we ask our ActionSequencer to do the appropriate action.
After all that (starting around line 87) we need to increment the timer. However, I don't want the timer moving while the camera is still on its way to focus on the enemy - otherwise they may have already begun their turn and done things we didn't see. So I only update the timer when the camera is not moving.
Lastly, if both actionDone and moveDone are true, then we have completed the whole turn (timing and all): deactivate the AI, reset the AI system, and processTurn().
There are still a few things I left vague. For instance, on line 31 I call some mystery method decidePlan(), and on line 70 I call decideMovement(). We already went over the pseudo-code for decidePlan() at the beginning of this article, and for now, decideMovement() just picks a random cell from reachableCells. Here they are:
private void decidePlan(Entity e, AI ai) { Pair pos = gameMap.getCoordinatesFor(e.getId()); Movable movable = mm.get(e); Abilities abilities = am.get(e); Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable); Array<Action2> availableActions = abilities.getAbilities(); Array<Plan> plans = new Array<Plan>(); // Loop over all possible plans where the entity moves, then acts // If the action doesn't hit anyone, skip that plan boolean moveFirst = true; for (Pair moveTarget : reachableCells) { for (Action2 action : availableActions) { for (Pair actionTarget : MapTools.getNeighbors(moveTarget.x, moveTarget.y, action.range)) { Array<Pair> field = MapTools.getNeighbors(actionTarget.x, actionTarget.y, action.field-1); field.add(actionTarget); if (!gameMap.containsEntitiesOtherThan(field,e.getId())) continue; int score = scorePlan(moveTarget, action, field); plans.add(new Plan(moveFirst, moveTarget, actionTarget, action, score)); } } } // If there were no good plans there, add at least one plan // where you move at random, and do nothing if (plans.size == 0) plans.add(new Plan(moveFirst, reachableCells.get(MathUtils.random(reachableCells.size-1)), null, null, 0)); // Now loop over all possible plans where the entity doesn't // move anywhere, so they still have their move stored up moveFirst = false; for (Action2 action : availableActions) { for (Pair actionTarget : MapTools.getNeighbors(pos.x, pos.y, action.range)) { Array<Pair> field = MapTools.getNeighbors(actionTarget.x, actionTarget.y, action.field-1); field.add(actionTarget); if (!gameMap.containsEntitiesOtherThan(field,e.getId())) continue; int score = scorePlan(pos, action, field); plans.add(new Plan(moveFirst, pos, actionTarget, action, score)); } } plans.sort(); ai.plan = plans.get(0); ai.planDone = true; sequencer = new ActionSequencer(ai.plan, e); }
private void decideMovement(Entity e, Plan plan) { Movable movable = mm.get(e); Pair pos = gameMap.getCoordinatesFor(e.getId()); Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable); plan.moveTarget = reachableCells.get(MathUtils.random(reachableCells.size-1)); plan.moveDecided = true; }decidePlan has a few very cheap hacks for now. For instance, on lines 21 and 39, I say that if that particular move/act combo has no entities in the action field, skip that plan (this way I don't have to bother scoring or sorting it). Even worse, I had to look for targets other than the acting entity - just think, if they move one cell over and say "aha, there's someone right back where I moved from - I'll attack them!" it would be pretty silly. containsEntitiesOtherThan() is pretty simple - just check the field to see if anyone is there, but don't count it if it's the entity whose ID is being passed.
In case they don't find any actions that work, on line 30 we give them a dummy option to move to some random location and do nothing (with a default score of 0).
The next loop is over just the actions, because they will decide where to move later. Again, I don't know how to handle score discrepancies between these two types of Plans. The ability to move later is worth something... probably. But what?
After that we sort the plans, grab the best one, and tell our AISystem that were are done making the plan. We also create a new ActionSequencer to handle this plan (we'll get to that soon).
There's still some cop-out: on lines 22 and 40 I magically call some scorePlan() method. For now, this is as simple as can be:
Go with whichever plan has the higher potential for damage. Don't care who you're attacking. Don't care how safe you are. Don't care 'bout nuthin. In practice, this means that everyone will try to move toward and attack anyone at random who is in some range. They prefer sword (because it's more powerful), but they'll do bow if that's all they have. There's nothing that says if they go for bow range to try to come in close for sword, nor to keep a distance. They just choose some random cell where they can attack from - and if it's possible, it will be right next to them for some swift sword justice.
decideMove(), like I said before, doesn't factor in anything smart. It just picks some random place to move.
That's our AISystem. We'll talk about the ActionSequencer in a second, but first let's look at how we want to implement our AISystem. It's just another Artemis system, and we don't want to update it manually (it runs continuously through the enemy's turn) so just add it like a regular System. In OverworldScreen.processTurn(), we need to check the incoming players status: is it controlled by AI? If so, I'd like to disable player control entirely, and begin the AI processing.
public void processTurn() { turnManagementSystem.process(); activeEntity = unitOrder.get(0); activeEntityCell = gameMap.getCoordinatesFor(activeEntity); // As long as that entity has a location, focus the camera on them if (activeEntityCell != null) { cameraMovementSystem.move(activeEntityCell.x, activeEntityCell.y); } // Try to get the next entity's AI AI ai = world.getEntity(activeEntity).getComponent(AI.class); // If they don't have AI, give control to the human player if (ai == null) Gdx.input.setInputProcessor(inputSystem); // Otherwise take control away, and begin that AI's processing. else { Gdx.input.setInputProcessor(null); ai.begin(); }Whew, we're coming down to the end. That's how the AISystems gets integrated into the rest of the game. Now we just need to talk about the ActionSequencer (plus the few things it messed up).
The idea for ActionSequencer is to break things down into a few discrete steps. For instance, to handle move, we want to do a few things:
- Highlight the movable range
- Specially highlight the cell they choose to move to
- Begin the actual movement (remember, the camera will be moving during this period too, so the timer won't be ticking until it stops)
- Unhighlight the target cell
- Finish (wrap up anything else that needs to happen at the end)
Now, for each of these, the code only needs to run once (for instance, in step 1 you must figure out the reachableCells, then tell OverworldScreen to highlight them), so within each step, we do step++. To stop it from running directly into the next step, we also monitor the AI timer to keep it from getting ahead of itself. Here's how I did it:
package com.blogspot.javagamexyz.gamexyz.AI; import com.artemis.Entity; import com.badlogic.gdx.utils.Array; import com.blogspot.javagamexyz.gamexyz.components.Damage; import com.blogspot.javagamexyz.gamexyz.components.Movable; import com.blogspot.javagamexyz.gamexyz.components.Movement; import com.blogspot.javagamexyz.gamexyz.components.Stats; import com.blogspot.javagamexyz.gamexyz.custom.Pair; import com.blogspot.javagamexyz.gamexyz.maps.GameMap; import com.blogspot.javagamexyz.gamexyz.maps.MapTools; import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen; public class ActionSequencer { Plan plan; Entity e; private int step; private float m_t; private float a_t; private float[] moveTimer; private float[] actTimer; public ActionSequencer(Plan plan, Entity e) { this.plan = plan; this.e = e; float t1,t2,t3,t4; step = 0; // Move timing constants m_t = 0.75f; // Time to wait upon focus, before showing movement range t1 = 1f; t2 = 0.5f; t3 = 0.05f; t4 = 0.25f; moveTimer = new float[]{t1, t2, t3, t4}; // Act timing constants a_t = 0.75f; // Time to wait upon focus, before showing action range t1 = 1f; // Highlight attackable range t2 = 0.6f; // Highlight target cell t3 = 0.75f; // Linger to watch damage hit actTimer = new float[]{t1, t2, t3}; } ... }This thing has immediate access to the Plan, and the entity who is executing it. It has an int step to keep track of which step it's on (it will only ever either be moving or acting, so we only need one step counter). The moveTimer holds the duration for each step. Look at lines 34-40 in the constructor.
m_t will be the "accumulated" timer, but for now, it just holds how long the camera should wait before showing the movement range (remember, the timer isn't running at all while the camera is moving, so this is 0.75 seconds after the camera has settled on the entity). t1 says that we will then highlight reachableCells for 1 second. t2 is how long for the next step, etc. An array, moveTimer is created which holds those values and can conveniently be referenced by moveTimer[step] to get the appropriate time.
Below all that, we actually have the move() method:
public void move(GameMap gameMap, OverworldScreen screen, float time) { if (time < m_t) return; // Step 1 - Highlight the reachable cells if (step == 0) { Movable movable = e.getComponent(Movable.class); Pair pos = gameMap.getCoordinatesFor(e.getId()); Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable); screen.highlightedCells = reachableCells; screen.highlightMovementRange(); m_t += moveTimer[step]; step++; return; } // Step 2 - Highlight the cell we will move to else if (step == 1) { screen.highlightedCells.clear(); screen.setHighlightColor(0.05f, 0.05f, 0.2f, 0.8f); screen.highlightedCells.add(plan.moveTarget); m_t += moveTimer[step]; step++; return; } // Step 3 - Begin moving to target cell else if (step == 2) { Movable movable = e.getComponent(Movable.class); Pair pos = gameMap.getCoordinatesFor(e.getId()); e.addComponent(new Movement(gameMap.pathFinder.findPath(pos.x, pos.y, plan.moveTarget.x, plan.moveTarget.y, movable, false))); e.changedInWorld(); m_t += moveTimer[step]; step++; return; } // Step 4 - We have arrived (plus a brief waiting period), unhighlight the cell else if (step == 3) { screen.renderHighlighter = false; m_t += moveTimer[step]; step++; return; } // Finished: Tell the plan that the "move" is done else { plan.moveDone = true; m_t = 0; step = 0; } }The first thing (line 3) is the most important part. If the AI timer has not exceeded the time limit we have set, then don't even bother doing any more here. This is how we control to make sure we don't jump ahead to steps inappropriately. In the Step 1 block, we get the reachableCells and tell the OverworldScreen to highlight them (I slightly fudged with the way highlighting works - we'll talk about it at the very end).
Step 2 clears the highlightedCells and replaces them with the moveTarget, along with a less transparent, darker blue color.
Step 3 actually adds the Movement component.
Step 4 stops the highlighter.
Notice that in each step, we increment m_t by the timer for that step. This way, we are always getting caught by that line 3 code. At first, it needs timer to exceed 0.75. But after that, it must exceed 1.75 (1 second beyond what it already had - as added on in line 27). So each step calls m_t += moveTimer[step], and each step also calls step++. Those two lines keep the flow going.
After that, we're done, so we call plan.moveDone (which helps control how AISystem knows where to delegate its attention) .
Here's act():
public void act(GameMap gameMap, OverworldScreen screen, float time) { if (plan.action == null) { plan.actionDone = true; System.err.println("action = null"); return; } if (time < a_t) return; // Step 1 - Highlight attackable range if (step == 0) { Pair pos = gameMap.getCoordinatesFor(e.getId()); screen.highlightedCells = MapTools.getNeighbors(pos.x, pos.y, plan.action.range); screen.highlightAttackRange(); a_t += actTimer[step]; step++; } // Step 2 - Highlight the target cell (the whole field) else if (step == 1) { screen.highlightedCells = MapTools.getNeighbors(plan.actionTarget.x, plan.actionTarget.y, plan.action.field-1); screen.highlightedCells.add(plan.actionTarget); screen.setHighlightColor(0.2f, 0.05f, 0.05f, 0.6f); a_t += actTimer[step]; step++; } // Step 3 - Add damage and unhighlight target cells else if (step == 2) { Damage damage = new Damage(e.getComponent(Stats.class),plan.action); int entityId; Array<Pair> field = MapTools.getNeighbors(plan.actionTarget.x, plan.actionTarget.y, plan.action.field-1); field.add(plan.actionTarget); for (Pair target : field) { entityId = gameMap.getEntityAt(target.x, target.y); screen.addComponent(damage, entityId); } screen.renderHighlighter = false; a_t += actTimer[step]; step++; } // Finished else { plan.actionDone = true; a_t = 0; } }It basically works the same way as move(), but instead of moving, it handles acting. To add the damage component, it needs access to the target entity, but it only has the ID. It could get the entity from the World, but it doesn't have that either. So I made some generally accessible method on OverworldScreen called addComponent() which will try to add a component to a particular entity.
Here's the list of silly methods in OverworldScreen I had to add at the end to make some of this come together:
public boolean cameraMoving() { return cameraMovementSystem.active; } public void setHighlightColor(float r, float g, float b, float a) { mapHighlighter.setColor(r,g,b,a); } public void highlightMovementRange() { renderHighlighter = true; setHighlightColor(0f, 0f, 0.2f, 0.3f); } public void highlightAttackRange() { renderHighlighter = true; setHighlightColor(0.5f, 0f, 0f, 0.3f); } public void addComponent(Component component, int entityId) { if (entityId < 0) { System.err.println("No entity"); return; } Entity e = world.getEntity(entityId); e.addComponent(component); e.changedInWorld(); }I replaced renderAttackRange and renderMovementRange with a general renderHighlighter (it never worked well highlighting both anyway, because they both worked off of the same timer which then went on double speed). I also gave mapHighlighter a setColor method, and it in general remembers what color it's supposed to be working on.
I also felt like I had to tweak the Damage component a little bit, but with very minor changes. Really this should be another update, but here it is in craptacular form now:
package com.blogspot.javagamexyz.gamexyz.components; import com.artemis.Component; import com.blogspot.javagamexyz.gamexyz.abilities.Action2; public class Damage extends Component { public float baseDamage; public float baseAccuracy; public int power; public int accuracy; public Stats stats; public Damage(Stats stats) { this.stats = stats; power = stats.getStrength(); accuracy = stats.getAgility(); baseDamage = power; baseAccuracy = accuracy; } public Damage(Stats stats, Action2 action) { this.stats = stats; this.baseDamage = action.damage; this.baseAccuracy = action.baseSucessProbability; accuracy = (int)(baseAccuracy * 100); power = (int)(3*baseDamage); } }Holy crap that was a lot! I hope I got it all (or at least most of it so that it will make sense). Now you can make some computer controlled entities that will duke it out in a Battle Royale! And you can mix it up with a few of your own characters - really the sky is the limit!
I'd encourage you all to think about how you actually want to tweak the decidePlan() and scorePlan() methods. They are surely crap right now (I didn't really plan on making them perfect just yet though). But whatever I come up with probably won't be that great in the end either, so this is definitely a place for your own logic to shine through! What do you think is important for the computer to consider when deciding on a good plan?
You have gained 250 XP. Progress to Level 4: 700/700
DING! You have advanced to Level 4, congratulations!