Creating a Starcraft AI – Part 30: Figuring outing the hurting

(This is an ongoing series. This part is enjoyable without previous knowledge, but if you’re new, I suggest you start from the beginning – although this part is somewhat digestible without previous knowledge)

First part of this series | Previous | Next | Index

Acquiring a target to attack, as I elaborated on it in the previous part, is a hard and complex problem. I decided to tackle the obvious parts of it first. I use enums for unit behavior, for example, FIGHT and SCOUT. My plan is to have these in the UnitManager class, and write the exact behavior of them on a case by case basis. This does not mean that every unit should have custom logic, but many of them will.

Speaking of zerglings, this is the barebones logic I’ll start with. It’s a spooky, scary skeleton of a unit micro at this point.

        } else if (this.role == Role.FIGHT) {
            if (targets.isEmpty()) {
                acquireTarget();
            } else if (unit.isIdle()) {
                unit.attack(targetPosition);
            }

The unit.isIdle() part needs a little explanation. Idle basically means the unit is not doing anything at the moment, and will default into the built-in behavior. It’s also important, because I can’t just spam the attack command without repercussions – If I order the unit to attack every frame, it will likely do nothing. Many units have a certain attack animation, which is reset when issuing a new attack command. You can see this in bot vs. bot matches sometimes. Units close in, then don’t attack at all.

But back to target acquisition. The basic logic for getting the unit that is the closest is the following:

    public void acquireTarget() {
        for (Unit enemyUnit: Main.enemyUnits) {
            if (!enemyUnit.getType().isFlyer() 
                    && !enemyUnit.isStasised() 
                    && !enemyUnit.isInvincible() 
                    && enemyUnit.isTargetable()
            ) {
                int unitDist = Cartography.getWPDistanceFastSqrt(unit.getPosition().toWalkPosition(), enemyUnit.getPosition().toWalkPosition());
                targets.add(new Target(enemyUnit.getID(), unitDist, enemyUnit.getPosition().getX(), enemyUnit.getPosition().getY(), enemyUnit.getType()));
            }
        }
    }

When implementing this, I realized that the unit.attack() method accepts a Unit, or a Position object as argument. I’m using the BWAPI4J library, which is kind of deprecated now – If you are following from the start, I’m terribly sorry, but I have to switch libraries again, this time to JBWAPI, which is done by some of the same people, and more importantly, supports the latest version of BWAPI, which is 4.4. It supports integers as arguments, too. But this is just a side note.

Képtalálat a következőre: „dancing skeletons”
Spooky, scary sidenotes

IsFlyer() is self-explanatory, so is isStasised(). Invincible units are for example mineral fields, and geysers which you can target, but not attack.

Side note: The actual pathfinding only comes into play when we selected our target, at the very end. Bear with me until then.

While assigning the importance, I encountered an interesting problem. I wanted to scale the importance inversely with distance, so the closest unit has the lowest value. But that means that I have to scale the other properties in a similar fashion, but how do you assign the weights? How many importance points is one unit of distance vs. unit destroyability? Manually tuning these values is a hopeless affair. Machine learning has a definite use case here. I’m not really an expert in that field, but I would definitely use reinforcement learning here. If I had just two factors, I could use one as the main value, and the other as a sort of tiebreaker – like I did with the JPS target direction. But come think of it… I can extend the concept to multiple factors. They have to have different magnitudes. And since the computer is somewhat good at storing large numbers (source: trust me dude), I can just use exponentials here. Let’s assume I have three factors to consider:

a. Distance
b. Destroyability
c. Phase of the moon

I would start from the bottom, would do something like importance = c + b^2 + a^3. (I know, in real life, the moon is more important)

Képtalálat a következőre: „husky in husky hat”
And of course, Moon Moon is even more important.

The real order of importance will be: Distance, target destroyability, and target type. The distance is given already (Sadly, since this will be on the 3rd power, I can’t readily use the squared distance).

Onto the target destroyability, then. I will simply calculate the number of hits required to destroy the target, and square that. At first glance, it seems like this is basically that:

    public void calculateHitsNeeded(Unit targetUnit) {
        int dmg = Main.unitStatCalculator.damage(unit.getGroundWeapon().type());
        int targetArmor = Main.unitStatCalculator.armor(unit.getType());
        
        int hits = targetUnit.getHitPoints() / (targetArmor-dmg);

But not so fast! Most SC players know the following facts, but a refresher: There are different damage types in the game. The first three are pretty well known, the others are not necessarily. (With a little help from here)

  • Normal (does full damage to everything)
  • Concussive (100%-50%-25% to small, medium, and large units, respectively), Vultures, Ghosts, Firebats for example.
  • Explosive (50%-75%-100% to small, medium, and large units). E.g. Siege tank, Dragoon.
  • Ability/Ignore armor. Psionic storm, or Plague.
  • (Special) used by Devourers.

In the source code, there are the “None” and “Unknown” values as well, but let’s not deal with those. The attacking weapon’s damage stat is deceptive too, if there are multiple projectiles (Like 2 cones with the firebat, or the goliath’s double rockets), the enemy’s armor gets deducted twice.

Units also can have shields, and everything deals full damage to those (reduced by the shield upgrade, of course). And that can go into “‘overkill”. Also, they regenerate, although usually that is not enough to count in a single battle. Same goes for Zerg health, and there is Medic healing, but I decided that’s outside of the scope of this functionality – I’m focusing on unit vs. unit battle evaluation, and that involves multiple units. Eventually, I want to evolve into something resembling a combat simulator, but this is not the point right now.

Lastly, there is defensive matrix. It is a 250 HP ablative shield, that takes full damage from everything. Luckily, there is a unit.getDefenseMatrixPoints() method to deal with this. But You might have noticed that some damage leaks through defensive matrices. The exact amount is 0.5 HP – but it’s not that simple.

In Starcraft, the unit HP displayed is not the full information the game has about the unit’s HP. The exact precision of the data stored is 1/256, but 0.5 is the smallest amount of damage that can be inflicted. The precision comes to play when calculating stuff like regeneration.

In BWAPI, we get an integer value, so – meh?. Plagued, then defensive-matrixed units are definitely a thing, so this is a thing that happens. According to Liquipedia, even if a unit has shields, the leak-through damage will apply to HP. The quote is ” Note that 0,5 damage “leaks” through the Defensive Matrix from every hit as in the case where armor of the target is bigger than damage. ” – This is not entirely correct, but close enough. According to Ankmairdor, the great knower of things, and Undermind guest: ” “so far as I can tell, 0.5 leak through should occur for any non-protoss unit that has d-matrix and at least 1 armor”. I tested this out, and the Liquipedia article seems wrong, order is always dmatrix->shield->armor, Also, since shields regenerate, it’s even less damage. I will just skip this case.

Update: I spoke with Ank recently, and here is the more correct version: “When an attack is completely absorbed by D-Matrix, 0.5 damage is done to the unit’s shields, if present, otherwise the health. For hits not completely absorbed by D-Matrix, but damage is less than or equal to shield armor or normal armor, will deal 0.5 damage to shields or health corresponding to the armor that blocked it.” – Left the old one correctly, but for all intents and purposes, refer to this.

Képtalálat a következőre: „leek skyrim”
You can take the leek, though.

(You probably saw a unit taking 1 HP from 2 hits before). There is a small chance of the defensive matrix just timing out while the unit attacks as well.

Bearing all these in mind, the final order of evaluation is:

  • Defensive matrix points, with the leak-through hp damage. Full damage.
  • Shield damage, reduced by shield upgrade level. Any overkill
  • HP damage, reduced by appropriate armor value. Considering number of projectiles, and damage type.

The damage our unit deals are composed of four factors: the unit.getGroundWeapon().type().damageAmount() returns the base damage (5 for a zergling). In the same class, there is a damageBonus() amount, which returns the bonus amount of damage provided by one level of upgrade. This is important, this is not the total amount of bonus damage. Finding out the level of the upgrade is done by calling the Player.getUpgradeLevel(), as it is tied to player data, not unit data. We have to multiply these two values. Summing up, with code (This is not the final version, just added for clarity)

        int dmatrixPoints = unit.getDefenseMatrixPoints();
        int targetHP = targetUnit.getHitPoints();
        int targetShields = targetUnit.getShields();

        int dmgAmount = unit.getGroundWeapon().type().damageAmount();
        int dmgBonus = unit.getGroundWeapon().type().damageBonus();
        int upgLevel = Main.self.getUpgradeLevel(unit.getGroundWeapon().type().upgradeType());

        int effectiveDamage = dmgAmount + (dmgBonus*upgLevel);
        int dmgFactor = unit.getGroundWeapon().type().damageFactor();

The damage factor is the number of attacks/projectiles. I used the term projectile because I’m very smart it applied to my example, but zealots have this as well. And yes, the leak through calculation applies twice in this case. Okay, so there is HP and shield leak through, like this:

  double leakThrough = 0;
        if (targetUnit.getType().maxShields() == 0 ) { //HP leak through in effect
            leakThrough = dmatrixHits*0.5;
            targetHP = targetHP - leakThrough;
            if (targetHP <= 0) {
                return dmatrixHits;
            }
        } else { //Shield leak through in effect.
            targetShields = targetShields - leakThrough;
        }

        //todo Apply shield regeneration.

        int dmatrixOverflow = 0;
        if (dmatrixPoints % effectiveDamage != 0) {
            dmatrixHits++;
            dmatrixOverflow = dmatrixHits % effectiveDamage;
        }

I switched some values from integer to double. I need to be aware of widening primitive conversions here. But since I’m dividing one integer value with another (even thought it’s represented with a double), no need to worry right here.

Also, I left a todo about shield regeneration there, because this article is getting too long – and again, it’s just a subproblem of a subproblem! I will pick this up from there next time.

Thanks for reading! If you liked this article, you can subscribe to my mailing list (in the box on the right), follow me on Twitter, or Facebook, or check the RSS feed on the top right. You can also find a shop with SC-related (and other) goodies there.

Also, I’d like to share an affiliate link with you – I’m not directly sponsored by Hired.com, but I found their services useful. It costs you nothing to check them out, but you can support me this way. They are basically a very good unified job search platform, where you apply once (with salary request, and conditions), then employers can make offers. It is hidden from your current employer, absolutely free to use and if you get hired, you even get a (not insignificant) cash bonus. I encourage you to check them out. Here is the link.

Leave a Reply