Sunday, May 5, 2013

Artificial Intelligence Part II (Level 4)

In today's update we'll work on our AI to produce more complex behavior.  For starters, entities will be placed on teams and should not attack their own teammates (unless a lot of enemies happen to be caught in the crossfire too), and if there are no enemies in the near proximity, they will pick an enemy based on who is nearby and a high value target.

Furthermore, entities will come with customized abilities - including single range melee attacks (like a sword), long range single target attacks (like a bow or magic spell), long range multi-target attacks (like a bigger spell), or single/multi-target cure spells.  Spells will cost MP (which is tentatively figured into their calculation of what to do).

To see a glimpse of how it all looks, I tried to capture a video using CamStudio and the  Xvid mpeg-4 codec (supposedly a setup which yields smooth desktop videos) but it still came out pretty laggy.  Just bare in mind the actual game usually gets 400-500 fps (obviously silly - I need to limit fps at some point) on my little laptop.  Camstudio, apparently, does not.  The code has also been uploaded to the Google Code repository here.

Let's dive in!

First, I needed to assign entities to teams.  Artemis managers seemed like a good place to start, and TeamManager sounded just right.  It turned out that PlayerManager was closer to what I wanted, but really neither were perfect.  I wanted something which knew not only which team an entity was on, but could also return a list of all the teams, so I made my own class (which was based mostly on the TeamManager code, but because it's philosophically more like the PlayerManager I called it PlayerManager2).
package com.blogspot.javagamexyz.gamexyz;

import java.util.HashMap;
import java.util.Map;

import com.artemis.Entity;
import com.artemis.Manager;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;


/**
 * Designates player teams (e.g. Human, AI_Enemy, AI_Ally, etc...)
 * Based off of Artemis: TeamManager.java by Arni Arent
 */
public class PlayerManager2 extends Manager {
 private Map<String, Bag<Entity>> entitiesByPlayer;
 private Map<Integer, String> playerByEntity;
 private Bag<String> players;

 public PlayerManager2() {
  entitiesByPlayer = new HashMap<String, Bag<Entity>>();
  playerByEntity = new HashMap<Integer, String>();
  players = new Bag<String>();
 }
 
 public ImmutableBag<String> getPlayers() {
  return players;
 }
 
 @Override
 protected void initialize() {
 }
 
 public String getPlayer(int entityId) {
  return playerByEntity.get(entityId);
 }
 
 public void setPlayer(Entity e, String player) {
  removeFromPlayer(e);
  
  playerByEntity.put(e.getId(), player);
  
  Bag<Entity> entities = entitiesByPlayer.get(player);
  if(entities == null) {
   entities = new Bag<Entity>();
   entitiesByPlayer.put(player, entities);
   players.add(player);
  }
  entities.add(e);
 }
 
 public ImmutableBag<Entity> getEntities(String player) {
  return entitiesByPlayer.get(player);
 }
 
 public void removeFromPlayer(Entity e) {
  String player = playerByEntity.remove(e.getId());
  if(player != null) {
   Bag<Entity> entities = entitiesByPlayer.get(player);
   if(entities != null) {
    entities.remove(e);
   }
  }
 }
 
 @Override
 public void deleted(Entity e) {
  removeFromPlayer(e);
 }
}

The big thing I would note about managers is that you DEFINITELY need to remember to include the public void deleted(Entity e) method.  I didn't have this at first (because it wasn't in TeamManager - which is really based off of having a PlayerManager anyway) and it caused big trouble.

Teams (which are here referenced as Players) are denoted by Strings, so you could have a "Red" player, "Blue" player, "Human" player, etc.  To assign an entity to a "Player", in EntityFactory just say:
world.getManager(PlayerManager2.class).setPlayer(e, Players.Blue);

I made a class which would just hold constant Player names in EntityFactory.
public static class Players {
 public static final String Human = "HUMAN_TEAM";
 public static final String Computer = "COMPUTER_TEAM";
 public static final String Blue = "BLUE_TEAM";
 public static final String Red = "RED_TEAM";
 public static final String Green = "GREEN_TEAM";
 public static final String Yellow = "YELLOW_TEAM";
 public static final String Purple = "PURPLE_TEAM";
 public static final String Teal = "TEAL_TEAM";
}

What team an entity is on should influence the scorePlan() method from last time, such that hitting an ally with a damage (or cure) attack decreases (or increases) the plan score, whereas they will do the opposite to enemies.  The PlayerManager2 allows any number of players, so to make things simple a given entity will just have two lists: allies and enemies.  Whether or not some of their enemies are also enemies with each other won't be considered here.  Below we'll look at how to compute scores using team information.

First things first, I want to come up with a measure to score how important a given entity is.  On the surface, a good guess would be that entity's level, but consider that a high level enemy who is nowhere near your team members shouldn't really be considered that big a threat (at least not yet)

To account for that, I "divide" an entity's level by its "average distance" from its enemies.  To see how this works, imagine there are 4 teams: A, B, C, and D, and that somebody on team B is taking their turn now.  They compile both a list of enemies (everyone from teams A, C, and D) - let's say there are 7 enemies - and a list of allies (everyone else from team B) - let's say there are 3 allies (including the active entity).

From that we build a 3x7 2D array in which we store the distance from each enemy to each ally:

1234567
1
2
3

This distance is not based on the actual mobility of anyone - instead it is raw distance, so 5 cells over deep water is indistinguishable from 5 cells of grassy plains.

Actually, I don't just store the distance, I store (sort of) the "inverse" of the distance.  For distances 0 through 4, the entry is just 1/1, for distances 5-9, the entry is 1/2, 10-14 = 1/3, 15-29=1/4, etc...

Then we compute a weighted average of the "inverse distance", weighted by the level of the ally each distance is measured to.  For instance, let's say that enemy 5 is level 2, and its distance from each ally is given below:

8 cells from Ally 1 (level 3)
12 cells from Ally 2 (level 2)
5 cells from Ally 3 (level 2)

The inverse distance entries would then be 1/2, 1/3, 1/2 respectively.  So the weighted average inverse distances would be

(3 * 1/2) + (2 * 1/3) + (2 * 1/2) / (3 + 2 + 2) = 0.45

Finally we will take this score and multiply it by Enemy 5's level, to get a final score of 0.45 * 2 = 0.9.

We do this calculation for all enemies, and likewise for all allies (weighted average inverse distance from all enemies).  This score represents in some sense which enemies pose the greatest threat to your allies, and which allies pose the greatest threat to your enemies.  Find the greatest enemy score, and the greatest ally score, and normalize all enemies and allies by division, and all enemies and allies are now on a scale from 0 to 1 in terms of importance.

Actually, to make other things simpler, ally scores are made to be negative, so really their scores range from 0 (worst) to -1 (best).

Here's the code that gets this all done. GroupAI.java:

package com.blogspot.javagamexyz.gamexyz.AI;

import com.artemis.Entity;
import com.artemis.World;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.utils.ObjectMap;
import com.blogspot.javagamexyz.gamexyz.PlayerManager2;
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;

public class GroupAI {

 private PlayerManager2 playerManager;
 
 public ObjectMap<Integer,Float> entityScores;

 private float[][] invDistance;
 
 private GameMap gameMap;
 
 private Bag<Entity> allies;
 private Bag<Entity> enemies;
 private int[] allyLevels;
 private int[] enemyLevels;
 private int step;
 
 public GroupAI(World world, GameMap gameMap) {
  if (world == null) System.out.println("WTF");
  playerManager = world.getManager(PlayerManager2.class);
  entityScores = new ObjectMap<Integer,Float>();
  this.gameMap = gameMap;
  step = 0;
  allies = new Bag<Entity>();
  enemies = new Bag<Entity>();
 }
 
 public boolean processGroup(int entityId) {
  String group = playerManager.getPlayer(entityId);
  ImmutableBag<String> players = playerManager.getPlayers();
  
  if (step == 0) {
   // Load enemies and allies + levels
   
   // Reset stuff from previous runs
   allies.clear();
   enemies.clear();
   entityScores.clear();
   
   // First load all the entities
   for (int i = 0; i < players.size(); i++) {
    // Load allies
    if (players.get(i).compareTo(group) == 0) {
     allies.addAll(playerManager.getEntities(players.get(i)));
    }
    // Load enemies
    else {
     enemies.addAll(playerManager.getEntities(players.get(i)));
    }
   }
   
   // If there are no more enemies, this whole thing becomes kind of silly
   if (enemies.size() == 0) {
    for (int i = 0; i < allies.size(); i++) {
     entityScores.put(allies.get(i).getId(), -1f);
    }
    return true;
   }

   // Next store their levels
   allyLevels = new int[allies.size()];
   enemyLevels = new int[enemies.size()];
   for (int i = 0; i < allyLevels.length; i++) {
    allyLevels[i] = allies.get(i).getComponent(Stats.class).level;
   }
   for (int i = 0; i < enemyLevels.length; i++) {
    enemyLevels[i] = enemies.get(i).getComponent(Stats.class).level;
   }
   
   step++;
   return false;
  }
  
  else if (step == 1) {
   // Compute the distance from each enemy to each ally
   
   invDistance = new float[enemies.size()][allies.size()];
   Pair enemyPos, allyPos;
   
   // Loop over all enemies and allies, get distance between them
   for (int i = 0; i < enemies.size(); i++) {
    enemyPos = gameMap.getCoordinatesFor(enemies.get(i).getId());
    
    for (int j = 0; j < allies.size(); j++) {
     allyPos = gameMap.getCoordinatesFor(allies.get(j).getId());
     
     invDistance[i][j] = 1f / (1 + MapTools.distance(enemyPos.x, enemyPos.y, allyPos.x, allyPos.y) / 5);
    }
   }

   step++;
   return false;
  }
   
  else if (step == 2) {
   // Compute the weighted scores
   float sum;
   float weightSum;
   
   /*
    * Compute a weighted average for the "threat" posed by
    * each enemy (the average level your own allies, weighted
    * by their distance from each particular enemy)
    * For a given ally, the farther it is from the enemy, the less
    * it contributes to the enemy's score.
    * 
    * For 2 allies the same distance away, the higher level one
    * is deemed to contribute more to the enemies threat.
    * 
    * It may make sense to someday incorporate additional information,
    * like a more injured ally also makes the enemy seem more dangerous.
    * 
    * Also, it's possible to do this iteratively, i.e., say that the first
    * estimate of everyone's importance is their level, then we run through
    * this once to get a refined estimate of their importance, then run it
    * again, etc... until it converges to something?  Maybe, I'll consider
    * this in more detail another time.
   */
   
   for (int i = 0; i < enemies.size(); i++) {
    sum = 0f;
    weightSum = 0f;
    
    for (int j = 0; j < allies.size(); j++) {
     sum += invDistance[i][j] * allyLevels[j];
     weightSum += allyLevels[j];
    }
    entityScores.put(enemies.get(i).getId(), enemyLevels[i] * sum / weightSum); 
   }
   
   // Do the same for an allies.  This effectively measures the threat that
   // your enemy will detect from your allies.  The ones your enemies want dead
   // should be the ones you want to take care of!
   
   for (int i = 0; i < allies.size(); i++) {
    sum = 0f;
    weightSum = 0f;
    
    for (int j = 0; j < enemies.size(); j++) {
     sum += invDistance[j][i] * enemyLevels[j];
     weightSum += enemyLevels[j];
    }
    entityScores.put(allies.get(i).getId(), -1f*allyLevels[i] * sum / weightSum); 
   }
   
   step++;
   return false;
  }
  else {
   // Normalize the scores - all enemies (and allies) on range from 0 to 1 (or -1)
   
   float bestEnemy=0;
   float bestAlly=0;
   float score;
   
   for (int x : entityScores.keys()) {
    score = entityScores.get(x);
    if (score > 0) { //Enemy
     if (score > bestEnemy) bestEnemy = score;
    }
    else if (score < 0) { // Ally
     if (score < bestAlly) bestAlly = score;
    }
   }
   
   for (int x : entityScores.keys()) {
    score = entityScores.get(x);
    if (score > 0) entityScores.put(x, score/bestEnemy);
    else entityScores.put(x, -1f*score/bestAlly);
   }
   
   step = 0;
   return true;
  }
 }

 public ImmutableBag<Integer> getEnemies(Entity e) {
  String player = playerManager.getPlayer(e.getId());
  ImmutableBag<String> players = playerManager.getPlayers();
  Bag<Integer> enemies = new Bag<Integer>();
  for (int i = 0; i < players.size(); i++) {
   if (player.compareTo(players.get(i)) == 0) continue;
   else {
    ImmutableBag<Entity> enemyPlayer = playerManager.getEntities(players.get(i)); 
    for (int j = 0; j < enemyPlayer.size(); j++) enemies.add(enemyPlayer.get(j).getId());
   }
  }
  return enemies;
 }
}

The main stuff comes in .process() - you can see that once again we break it into steps, each of which does a particular part of the task:
  • Step 0 - Load all enemy and ally levels into Arrays
  • Step 1 - Fill in the inverse distance matrix
  • Step 2 - Compute the weighted scores
  • Step 3 - Normalize all enemy and ally scores based on the best enemy, and best ally
We'll discuss the getEnemies() method later, but first let's see how we can incorporate these scores into AISystem.  Previously, we split the AISystem into a series of steps - this GroupAI should count as another step which must be completed before moving on to deciding the plan.  The plan we choose should somehow depend on the importance of various entities.  To do this, we just make a new flag called groupAiDone, initialize it to false, and set it to true once the groupAI.process() has completed all its steps.  This is easy to check, because groupAI.process() returns false, until it's done at which point it returns true.
@Override
protected void process(Entity e) {
  
 AI ai = AIm.get(e);
 if (!ai.active) return;
 
 if (!groupAiDone) {
  groupAiDone = groupAI.processGroup(e.getId());
 }
  
 else if (!ai.planDone) decidePlan(e, ai);
  
 else { ...

Now we should use the entityScores to help us decide our plans, let's consider more carefully how scorePlan ought to work.  In general, different actions ought to have their scores calculated differently - for instance healing actions vs damage actions vs status ailment actions, etc, and should separately consider the caster (for whom there might be an MP cost or something) and the targets.  I offloaded this scoring to each individual Action2 using a ScoreCalculator (which we'll discuss later).  For now, recall that allies have a negative "importance" score, and enemies have a positive score.

To score the plan as a whole, we'll take the score from the ScoreCalculator and multiply it by the entityScore.  Thus, beneficial actions ought to have a negative score (so that when you cast it on an ally, (negative) x (negative) = positive score), and attack actions ought to have a positive score.
private int scorePlan(Entity e, Plan plan) {
 Action2 action = plan.action;
 
 // Get the stats of the entity doing this action
 Stats source = sm.get(e);
 
 // If the action costs too much, we won't even consider this plan
 if (source.magic < action.mpCost) return 0;
 
 // get the target field of this action
 Array<Pair> field = action.fieldCalculator.getField(plan.actionTarget, action);
 
 // Start a bag of stats of all the entities who will be targeted in this attack, and their IDs 
 Bag<Stats> targetBag = new Bag<Stats>();
 Bag<Integer> targetIds = new Bag<Integer>();
 int targetId;
 
 // Loop over all cells in the field
 for (Pair cell : field) {
  targetId = gameMap.getEntityAt(cell.x, cell.y);
  
  // If there is an entity at that cell, add it to the targetBag and targetIds bag 
  if (targetId > -1) {
   Entity target = world.getEntity(targetId);
   targetBag.add(sm.get(target));
   targetIds.add(targetId);
   plan.targetEntities.add(target);
  }
 }
 
 // If there are no targets, return 0
 if (targetBag.size() == 0) return 0;
 
 // Calculate the score of this action for each target in the target bag, as well as the
 // cost to the caster (attacker)
 ImmutableBag<Float> scoreBag = action.scoreCalculator.calculateScore(source, targetBag, action);
 
 // Null means that the action exceeded the casters MP, so this action gets a 0 score
 if (scoreBag == null) return 0;
 
 // scoreBag(0) is the cost to the caster, then multiplied by casters importance
 float score = scoreBag.get(0) * groupAI.entityScores.get(e.getId());
 
 // Add to that the scores*importance for all targets of the spell
 for (int i = 1; i < scoreBag.size(); i++) {
  score += scoreBag.get(i) * groupAI.entityScores.get(targetIds.get(i-1));
 }
 
 // Now we multiply by 100 and make it an int
 plan.score = (int)(100*score);
 
 // A negative score means something bad happened, so we don't really want to bother
 // with this plan.  Otherwise, if the score is positive, and we are acting first
 // (which means we get to move later) we should get a bonus of 5 points (to indicate
 // that there is some benefit to moving after we act)
 if (!plan.moveFirst && plan.score > 0) plan.score+=5; 
 return (int)plan.score;
}

The first thing I did was make sure the Action2's mpCost doesn't exceed the caster's MP.  If it does, the plan gets a score of 0.  Beyond that, I made the ScoreCalculator ask for everything up front - the caster's Stats and all the targets' Stats, so we had to make a Bag (I have typically been using the libgdx Array, but I just thought I'd go with the Artemis Bag this time) to hold all the targets' Stats.  Along with this, I made a Bag of target IDs so we could multiply the the Action2 score by the groupAI entityScore.

After we get the Action2 scores, we check to see if it's null (this would indicate that the action couldn't be done in this situation, for whatever reason).  If it's good, ScoreCalculator should return a Bag where the first element is the cost to the caster, and each subsequent element is the score for each target (in order).  We want to add these all up, multiplied by the entity's importance.  I then multiply it by 100 and return.

Action2

Let's take a closer look at the updated Action2 now.  It had to be extremely general so that different actions could do very different things.  Each action has an integer for mpCost, range, field, baseProbability (0% to 100%), and strength.  But in addition to that, they have special methods for process(), calculateScore(), calculateField(), and calculateRange().  The last 2 are useful for attacks that hit a series of cells in a straight line, or perhaps that can only hit targets 3-4 cells away, but not less.

In Action2 I defined interfaces for each of those methods, and each action will have instances of them:

package com.blogspot.javagamexyz.gamexyz.abilities;

import com.artemis.Entity;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.components.Damage;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;

public class Action2 {
  
 public int mpCost, range, field, baseProbability, strength;
 
 public ActionProcessor actionProcessor;
 public ScoreCalculator scoreCalculator;
 public FieldCalculator fieldCalculator;
 public RangeCalculator rangeCalculator;
 
 public interface ActionProcessor {
  public void process(Entity source, Array<Entity> targets, Action2 action);
 }
 
 public interface ScoreCalculator {
  public ImmutableBag<Float> calculateScore(Stats source, ImmutableBag<Stats> target, Action2 action);
 }
 
 public interface FieldCalculator {
  public Array<Pair> getField(Pair target, Action2 action);
 }
 
 public interface RangeCalculator {
  public Array<Pair> getRange(Pair source, Action2 action);
 }
}


I also built two constructors for Action2: one of which requires that the instance variables be passed in:

public Action2(int strength, int mpCost, int baseProbability, int range, int field,
  ActionProcessor actionProcessor,
  ScoreCalculator scoreCalculator,
  FieldCalculator fieldCalculator,
  RangeCalculator rangeCalculator) {
 this.strength = strength;
 this.baseProbability = baseProbability;
 this.mpCost = mpCost;
 this.range = range;
 this.field = field;
 this.actionProcessor = actionProcessor;
 this.scoreCalculator = scoreCalculator;
 this.fieldCalculator = fieldCalculator;
 this.rangeCalculator = rangeCalculator;
}

The other builds default interfaces based on mpCost, etc...

public Action2(int strength, int mpCost, int baseProbability, int range, int field) {
 this.mpCost = mpCost;
 this.range = range;
 this.field = field;
 this.strength = strength;
 this.baseProbability = baseProbability;
 
 actionProcessor = new ActionProcessor() {
  @Override
  public void process(Entity sourceE, Array<Entity> targets, Action2 action) {
   Stats source = sourceE.getComponent(Stats.class);
   source.magic -= action.mpCost;
   boolean hitOnce = false;
   
   for (Entity targetE : targets) {
    Stats target = targetE.getComponent(Stats.class);
    int damage = 0;
    int probability = action.baseProbability; // +source.agility() - target.agility() +blah blah blah...
    if (MathUtils.random(100) < probability) { //HIT
     if (!hitOnce) {
      source.xp += 10;
      hitOnce = true;
     }
     damage = (int)(MathUtils.random(0.8f,1.2f)*(action.strength + source.getStrength() - target.getHardiness()));
     if (damage < 1) damage = 1;
    }

    targetE.addComponent(new Damage(damage));
    targetE.changedInWorld();
   }
  }
 };
 
 scoreCalculator = new ScoreCalculator() {
  @Override
  public ImmutableBag<Float> calculateScore(Stats source, ImmutableBag<Stats> targets, Action2 action) {    
   // If we can't even cast it, then don't bother
   int MP = source.magic;
   if (action.mpCost > MP) return null;
   
   Bag<Float> scoreBag = new Bag<Float>();
   
   // Get the cost to the source
   scoreBag.add(0.1f*(float)action.mpCost / (float)MP);
   
   // Get the scores for each target
   for (int i = 0; i < targets.size(); i++) {
    Stats target = targets.get(i);
    int HP = target.health;
    int damage = (int)(MathUtils.random(0.8f,1.2f)*(action.strength + source.getStrength() - target.getHardiness()));
    if (damage < 1) damage = 1;
    scoreBag.add((float)action.baseProbability/100f * (float)Math.min(damage, HP) / (float)HP);
   }
   
   return scoreBag;
  }
 };
 
 fieldCalculator = new FieldCalculator() {
  @Override
  public Array<Pair> getField(Pair target, Action2 action) {
   Array<Pair> field = MapTools.getNeighbors(target.x, target.y, action.field-1);
   field.add(target);
   return field;
  }
 };
 
 rangeCalculator = new RangeCalculator() {
  @Override
  public Array<Pair> getRange(Pair source, Action2 action) {
   return MapTools.getNeighbors(source.x, source.y, action.range);
  }
 };
}

The default ActionProcessor reduces the caster's MP by mpCost, then goes through each entity in the target list.  If a random roll successfully hits them, the caster gets 10 xp (not that this does anything, and it only happens for the 1st target hit - subsequent hits from the same attack don't do diddly), and the action calculates how much damage is dealt (minimum of 1) and adds a Damage component to the target.  Since all the probabilities and stats have been taken into account, this new Damage component only carries how much damage was dealt, and is immediately delivered by the DamageSystem.  If damage=0, the DamageSystem will interpret it as a miss, damage < 0 is interpreted as a cure.  (Scroll down a bit to see the updated Damage and DamageSystem)

The default ScoreCalculator first checks that it can be cast (i.e. that it has enough MP in this case, but there may later be other restrictions).  The first score is equal to 1/10th of the fraction of remaining MP the action costs.  Thus, if an entity has 20 MP and checks an action which costs 12 MP, 1/10th of 12/20 = 0.06, which is what we take the "cost" to be.  Remember, that will be multiplied by the caster's "importance", which is negative, so ultimately it detracts from the plan's score.

Beyond that, each target's score becomes the expected value of what percent of the target's remaining HP will be lost.  Thus if a target has 20 HP, and will be dealt 13 damage with probability = 90%, the score is 0.90 * 13/20 = 0.9 * 0.65 = 0.585.  If it would deal 100 damage, the score is not 0.9 * 100/20 - it really only gets the benefit of 0.9 * 20/20 = 0.9 * 1 = 0.9.  For now, I'm just using baseProbability, but in reality it should be based also on the target's agility, etc...

The default FieldCalculator does exactly what we used to expect - it gets all the neighbors out to the field-1, and adds the central tile as well.

The default RangeCalcuator gets all cells a distance range away from the source, without including the source's own cell.

Aside: Updated Damage and DamageSystem
package com.blogspot.javagamexyz.gamexyz.components;

import com.artemis.Component;

public class Damage extends Component {

 public int damage;
 
 public Damage(int damage) {
  this.damage = damage;
 }
 
}

public class DamageSystem extends EntityProcessingSystem {
 @Mapper ComponentMapper<Damage> dm;
 @Mapper ComponentMapper<Stats> sm;
 @Mapper ComponentMapper<MapPosition> mpm;
 
 private GameMap gameMap;
 
 @SuppressWarnings("unchecked")
 public DamageSystem(GameMap gameMap) {
  super(Aspect.getAspectForAll(Damage.class, Stats.class));
  this.gameMap = gameMap;
 }

 @Override
 protected void process(Entity e) {
  Damage damage = dm.get(e);
  Stats stats = sm.get(e);
  MapPosition position = mpm.getSafe(e); // Useful for displaying damage on screen 
  
  if (damage.damage > 0) { // Successful attack
  
   // Update the target's health
   stats.health -= damage.damage;
  
   // Display a message
   if (position != null) {
    EntityFactory.createDamageLabel(world, ""+damage.damage, position.x, position.y).addToWorld();
   }
  }
  
  else if (damage.damage < 0) { // cure
   int cureAmt = MyMath.min(-1*damage.damage,stats.maxHealth-stats.health);
   stats.health += cureAmt;
   if (position != null) {
    EntityFactory.createDamageLabel(world, "+"+cureAmt, position.x, position.y).addToWorld();
   }
  }
 
  else { // Otherwise they missed   
   // Create a damage label of "MISS"
   if (position != null) {
    EntityFactory.createDamageLabel(world, "MISS", position.x, position.y).addToWorld();
   }
  }
  
  // We've processed the damage, now it's done
  e.removeComponent(damage);
  e.changedInWorld();
 }
 ...
}

To quickly create an Action2, I made an ActionFactory.

Right now it can make 3 types of actions - regular physical attacks, magic attacks (which cost MP, but are still based on the strength and hardiness stats because I was too lazy to make it better), and cure spells.  I made general cure and spell calculators which can be referenced from the ActionFactory.

Lastly, I tweaked decideMovement() which we use when we don't have an action (which implies that there are no interesting entity's around, so we need to hunt one down).  Using a similar method to groupAI, it looks at all enemies and rates them on their attractiveness to pursue, then finds the shortest path to them, and goes as far along that path as possible.

It bases its decision on who to chase on their proximity, their entityScore, and the number of other enemies within 6 cells of it.  It doesn't count allies within 6 cells, just enemies (using the getEnemies() method from groupAI to tell the difference).

/*
 * Call this when you have no good plans in your personal range.
 * Try to find an enemy to hunt down based on how far it is from you,
 * how powerful it is, and how many other enemies are near to it.
 */
private Pair decideMovement(Entity e) {
 Pair pos = gameMap.getCoordinatesFor(e.getId());
 Movable movable = mm.get(e);
 Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable);
 ImmutableBag<Integer> enemies = groupAI.getEnemies(e);
 if (enemies.size() == 0) return reachableCells.get(MathUtils.random(reachableCells.size-1));
 
 // The best enemy you are considering chasing and its score
 int targetEnemy = -1;
 float bestScore = 0f;
 
 // The current enemy you are checking out and its score
 int id;
 float score;
 
 // How far away is the enemy?  How many enemies are within a small radius of it?
 int distance, count;
 
 for (int i = 0; i < enemies.size(); i++) {
  count = 1;
  Pair target = gameMap.getCoordinatesFor(enemies.get(i));
  distance = MapTools.distance(pos.x, pos.y, target.x, target.y);
  for (Pair cell : MapTools.getNeighbors(target.x, target.y, 6)) {
   id = gameMap.getEntityAt(cell.x, cell.y);
   if (!enemies.contains(id)) continue;
   count++;
  }
  
  score = groupAI.entityScores.get(enemies.get(i)) * count / (1 + distance / 5);
  if (score > bestScore) {
   bestScore = score;
   targetEnemy = enemies.get(i);
  }
 }
 
 if (targetEnemy > -1) {
  Pair target = gameMap.getCoordinatesFor(targetEnemy);
  Path path = gameMap.pathFinder.findPath(pos.x, pos.y, target.x, target.y, movable, true);
  for (int i = 0; i < path.getLength(); i++) {
   Step step = path.getStep(i);
   Pair p = new Pair(step.getX(),step.getY());
   if (reachableCells.contains(p, false)) return p;
  }
 }
 return reachableCells.get(MathUtils.random(reachableCells.size-1)); 
}

With this, enemies that are far away from one another may decide to pursue each other if nobody else is around.

To finish up, and to give this a visual representation, I made a set of character graphics for Archer, Wizard, Fighter, and Healer.

In EntityFactory I can now createRed, which creates a random NPC for the Red player, and createBlue which creates a random NPC for the blue team.
public static Entity createBlue(World world, int x, int y, GameMap gameMap) {
 Entity e = world.createEntity();
 
 e.addComponent(new MapPosition(x,y));
 gameMap.addEntity(e.getId(), x, y);
 
 Sprite sprite = new Sprite("cylinder");
 sprite.r = 0;
 sprite.g = 0;
 sprite.b = 0.4f;
 sprite.a = 1f;
 sprite.rotation = 0f;
 sprite.scaleX = 1f;
 sprite.scaleY = 1f;
 e.addComponent(sprite);
 
 e.addComponent(new Movable(10f, 0.14f));
 
 Abilities abilities = new Abilities();
 int abilityRoll = MathUtils.random(0,3);
 if (abilityRoll == 0) { 
  sprite.name = "fighter";
  abilities.actions.add(ActionFactory.physicalAttack(5, 85, 1));
 }
 else if (abilityRoll == 1) {
  sprite.name = "archer";
  abilities.actions.add(ActionFactory.physicalAttack(4, 70, 4));
  abilities.actions.add(ActionFactory.physicalAttack(2, 80, 1));
 }
 else if (abilityRoll == 2) {
  sprite.name = "wizard";
  abilities.actions.add(ActionFactory.magicAttack(3, 4, 80, 3, 2));
  abilities.actions.add(ActionFactory.magicAttack(4, 4, 90, 3, 1));
  abilities.actions.add(ActionFactory.physicalAttack(2, 70, 1));
 }
 else {//if (abilityRoll == 3) {
  sprite.name = "healer";
  abilities.actions.add(ActionFactory.cure(4, 4, 3, 1));
  abilities.actions.add(ActionFactory.cure(3, 6, 3, 2));
  abilities.actions.add(ActionFactory.physicalAttack(1, 65, 1));
 }
 e.addComponent(abilities);
 e.addComponent(new Stats());
 e.addComponent(new AI());
 world.getManager(PlayerManager2.class).setPlayer(e, Players.Blue);
 
 return e;
}

public static Entity createRed(World world, int x, int y, GameMap gameMap) {
 Entity e = world.createEntity();
 
 e.addComponent(new MapPosition(x,y));
 gameMap.addEntity(e.getId(), x, y);
 
 Sprite sprite = new Sprite("cylinder");
 sprite.r = 0.4f;
 sprite.g = 0;
 sprite.b = 0f;
 sprite.a = 1f;
 sprite.rotation = 0f;
 sprite.scaleX = 1f;
 sprite.scaleY = 1f;
 e.addComponent(sprite);
 
 e.addComponent(new Movable(10f, 0.14f));
 
 Abilities abilities = new Abilities();
 int abilityRoll = MathUtils.random(0,3);
 if (abilityRoll == 0) { 
  sprite.name = "fighter";
  abilities.actions.add(ActionFactory.physicalAttack(5, 85, 1));
 }
 else if (abilityRoll == 1) {
  sprite.name = "archer";
  abilities.actions.add(ActionFactory.physicalAttack(4, 70, 4));
  abilities.actions.add(ActionFactory.physicalAttack(2, 80, 1));
 }
 else if (abilityRoll == 2) {
  sprite.name = "wizard";
  abilities.actions.add(ActionFactory.magicAttack(3, 4, 80, 3, 2));
  abilities.actions.add(ActionFactory.magicAttack(4, 4, 90, 3, 1));
  abilities.actions.add(ActionFactory.physicalAttack(2, 70, 1));
 }
 else {//if (abilityRoll == 3) {
  sprite.name = "healer";
  abilities.actions.add(ActionFactory.cure(4, 4, 3, 1));
  abilities.actions.add(ActionFactory.cure(3, 6, 3, 2));
  abilities.actions.add(ActionFactory.physicalAttack(1, 65, 1));
 }
 e.addComponent(abilities);
 e.addComponent(new Stats());
 e.addComponent(new AI());
 world.getManager(PlayerManager2.class).setPlayer(e, Players.Red);
 
 return e;
}

Now we have two teams which have specialized units which fight to the death, relatively intelligently!  I think it's pretty awesome!  You can check out all this code from the repository here.

You have gained 250 XP.  Progress to Level 5: 250/850

No comments:

Post a Comment