5
\$\begingroup\$

Recently I wanted to teach the AI of my castle game how to use the spells that I have added to the game. You can try out the game here: Castleparts

Initially I thought that I would have to add subclasses for each of the four playable races in order to account for the different spells that each one has available. However I ended up coming up with a more generic approach that works just as well.

I'm using the gdx-ai library for the AI. Initially (before spells added) the core AI logic looked like this:

@Override
public void update(AIPlayerConquer entity) {
    //this is the hierarchy of action priorities
    //when idle, this will be called to search for what state to select next

    //shoot ogres first because they will destroy walls
    entity.findPersons();
    if (entity.shouldAttackPersons()) {
        entity.stateMachine.changeState(SHOOT_PERSONS);
    }

    //shoot if the opponent is aggressive or controls a lot of the map
    else if (entity.isOpponentAggressive() ||
             entity.shouldAttackBasedOnOpponentPercentage() ||
             entity.shouldAttackBasedOnPercentOwned()) {

        entity.stateMachine.changeState(SHOOT);

    //otherwise try to build
    } else {
        entity.stateMachine.changeState(BUILD);
    }
}

With just some small changes, I added spells to the core logic. I wanted to put spells at a higher priority than regular actions because they make the gameplay more exciting. Here's the entire class:

public enum AIPlayerConquerState implements State<AIPlayerConquer> {
    IDLE,

    SHOOT {
        @Override
        public void enter(AIPlayerConquer entity) {

            entity.findOpponentWallTiles();

            if (!entity.doesOpponentHaveWalls()) {
                entity.stateMachine.changeState(BUILD);
            } else {
                entity.angryAsEnemy();
                entity.tryToShootWalls();
            }

            entity.stateMachine.changeState(IDLE);
        }
    },

    SHOOT_PERSONS {
        @Override
        public void enter(AIPlayerConquer entity) {

            entity.findPersons();

            if (!entity.shouldAttackPersons()) {
                entity.stateMachine.changeState(BUILD);
            } else {
                entity.tryToShootPersons();
            }

            entity.stateMachine.changeState(IDLE);
        }
    },

    BUILD {
        @Override
        public void enter(AIPlayerConquer entity) {
            entity.buildingAsEnemy();
            entity.doActionForConquerGame();

            entity.stateMachine.changeState(IDLE);
        }
    },

    AGGRESIVE_SPELL {
        @Override
        public void enter(AIPlayerConquer entity) {
            entity.tryToCastOffensiveSpell();

            entity.stateMachine.changeState(IDLE);
        }
    },

    DEFENSIVE_SPELL {
        @Override
        public void enter(AIPlayerConquer entity) {
            entity.tryToCastDefensiveSpell();

            entity.stateMachine.changeState(IDLE);
        }
    };

    @Override
    public void update(AIPlayerConquer entity) {
        //this is the hierarchy of action priorities
        //when idle, this will be called to search for what state to select next

        //shoot ogres first because they will destroy walls
        entity.findPersons();
        if (entity.shouldAttackPersons()) {
            entity.stateMachine.changeState(SHOOT_PERSONS);
        }

        //shoot if the opponent is aggressive or controls a lot of the map
        else if (entity.isOpponentAggressive() ||
                 entity.shouldAttackBasedOnOpponentPercentage() ||
                 entity.shouldAttackBasedOnPercentOwned()) {

            if (entity.shouldCastSpell(true)) {
                entity.stateMachine.changeState(AGGRESIVE_SPELL);
            } else {
                entity.stateMachine.changeState(SHOOT);
            }

        //otherwise try to build
        } else {
            if (entity.shouldCastSpell(false)) {
                entity.stateMachine.changeState(DEFENSIVE_SPELL);
            } else {
                entity.stateMachine.changeState(BUILD);
            }
        }
    }

    @Override
    public void enter(AIPlayerConquer entity) {
    }
    @Override
    public void exit(AIPlayerConquer entity) {
    }
    @Override
    public boolean onMessage(AIPlayerConquer entity, Telegram telegram) {
        return false;
    }
}

Each AI only has 3 spells available. The next task was to split them up into categories so that some general logic could be applied to decide when to use them. First I created a type to describe the target of a spell:

public enum SpellTarget {

    MY_CRYSTALS,
    MY_WALLS,
    MY_EMPTY_SPACE,
    MY_CANNON,
    OPPONENT_WALLS,
    OPPONENT_EMPTY_SPACE,
    OPPONENT_CANNON;

}

Then I added a new field to the SpellType enum and gave each spell type a target. Next I split the spells into aggressive and defensive spells by adding this to the SpellType enum.

public final static List<SpellType> aggresiveSpells = SpellType.getAggresiveSpells();
public final static List<SpellType> defensiveSpells = SpellType.getDefensiveSpells();

private static List<SpellType> getAggresiveSpells() {
    List<SpellType> types = new ArrayList<SpellType>();
    types.add(FIREBALL);
    types.add(FIRE_WALL);
    types.add(SKELETONS);
    types.add(OGRES);
    types.add(LIGHTNING);
    types.add(CANNON_CHARGE);
    types.add(ENERGIZE_CRYSTALS);
    return types;
}

private static List<SpellType> getDefensiveSpells() {
    List<SpellType> types = new ArrayList<SpellType>();
    types.add(SHIELD);
    types.add(STATIC_CHARGE);
    types.add(DIG);
    types.add(SHROUD);
    types.add(BONUS_WALLS);
    return types;
}

When the AIPlayer class is initialized, the available spells are added to local lists so that they know which spells they have:

for (SpellType type : playerType.spells) {
    if (SpellType.aggresiveSpells.contains(type)) {
        this.aggresiveSpells.add(type);
    } else if (SpellType.defensiveSpells.contains(type)) {
        this.defensiveSpells.add(type);
    }
}

Some simple code is used to determine whether or not the AI can or should try to cast a spell:

protected boolean shouldCastSpell(boolean offensive) {
    if (this.timeSinceSpellCast < this.difficulty.minSecondsBetweenSpells) {
        return false;
    }

    if (offensive) {
        return this.canAffordAggressiveSpell();
    } else {
        return this.canAffordDefensiveSpell();
    }
}

protected boolean canAffordAggressiveSpell() {
    if (this.aggresiveSpells.size() == 0) {
        return false;
    }

    this.spellsReadyToCast = this.getAffordableSpells(this.aggresiveSpells, this.spellsReadyToCast);
    return this.spellsReadyToCast.size() > 0;
}

protected boolean canAffordDefensiveSpell() {
    if (this.defensiveSpells.size() == 0) {
        return false;
    }

    this.spellsReadyToCast = this.getAffordableSpells(this.defensiveSpells, this.spellsReadyToCast);
    return this.spellsReadyToCast.size() > 0;
}

protected List<SpellType> getAffordableSpells(List<SpellType> availableSpells, List<SpellType> canAfford) {
    canAfford.clear();
    for (SpellType type : availableSpells) {
        if (this.hasEnergyForSpell(type)) {
            canAfford.add(type);
        }
    }
    return canAfford;
}

And finally, this code is called when the AI actually casts the spell:

public void tryToCastOffensiveSpell() {
    this.spellsReadyToCast = this.getAffordableSpells(this.aggresiveSpells, this.spellsReadyToCast);
    if (this.spellsReadyToCast.size() == 0) {
        return;
    }
    this.tryToCastSpell(this.spellsReadyToCast);
}

public void tryToCastDefensiveSpell() {
    this.spellsReadyToCast = this.getAffordableSpells(this.defensiveSpells, this.spellsReadyToCast);
    if (this.spellsReadyToCast.size() == 0) {
        return;
    }
    this.tryToCastSpell(this.spellsReadyToCast);
}

public void tryToCastSpell(List<SpellType> spells) {
    this.timeSinceSpellCast = 0;

    int randomIndex = this.random.nextInt(spells.size());
    SpellType randomSpell = spells.get(randomIndex);

    switch(randomSpell.target) {
    case MY_CANNON:
        this.castSpellOnMyCannon(randomSpell);
        break;
    case MY_CRYSTALS:
        this.castSpellOnMyCrystals(randomSpell);
        break;
    case MY_EMPTY_SPACE:
        this.castSpellOnMyEmptySpace(randomSpell);
        break;
    case MY_WALLS:
        this.castSpellOnMyWalls(randomSpell);
        break;
    case OPPONENT_CANNON:
        this.castSpellOnOpponentCannon(randomSpell);
        break;
    case OPPONENT_EMPTY_SPACE:
        this.castSpellOnOpponentEmpty(randomSpell);
        break;
    case OPPONENT_WALLS:
        this.castSpellOnOpponentWalls(randomSpell);
        break;
    }
}

I think that the logic is fairly clear here, but I'm looking for other approaches that might be less verbose, and as always I want to hear about any ways the code can be improved. Thanks!

Also, here is a screenshot of one AI casting spells on another one:

Castleparts Spells

\$\endgroup\$
2
\$\begingroup\$

AIPlayerConquerState is quite unusual - enums are not meant to hold "business" logic but it seems its enforced by used API.

General: prefer Collection over its specializations (List, Set) in public API; it gives you more flexibility for choosing/changing implementations (see below).

SpellType: 1) constants are missing in the post 2) collections will be much more efficient (and safer) when defined as:

public final static Collection<SpellType> defensiveSpells = Collections.unmodifiableSet(
        EnumSet.of( SHIELD, STATIC_CHARGE, DIG, SHROUD, BONUS_WALLS ) );
public final static Collection<SpellType> aggresiveSpells
        = Collections.unmodifiableSet( EnumSet.complementOf(defensiveSpells) );

Spells collections initialization could be then simplified as (removing both loops):

aggresiveSpells = EnumSet.copyOf( playerType.spells ).retainAll( SpellType.aggresiveSpells );
defensiveSpells = EnumSet.copyOf( playerType.spells ).retainAll( SpellType.defensiveSpells );

"some simple code" can be simplified (and speed up) further:

protected boolean shouldCastSpell(boolean offensive) {
    if (timeSinceSpellCast < difficulty.minSecondsBetweenSpells) {
        return false;
    }
    return offensive ? hasAffordableSpells(aggresiveSpells) : hasAffordableSpells(defensiveSpells);
}

protected boolean hasAffordableSpells(Collection<SpellType> availableSpells) {
    return availableSpells.stream().anyMatch( s -> hasEnergyForSpell(s) );
}

The final part can be simplified as well:

public void tryToCastOffensiveSpell() {
    List<SpellType> affordableSpells = aggresiveSpells.stream()
            .filter( s -> hasEnergyForSpell(s) )
            .collect( Collectors.toList() );
    if (!affordableSpells.isEmpty()) { tryToCastSpell(affordableSpells); }
}

Good luck with casting spells :)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.