Python AI в StarCraft II. Часть VI: побеждаем сильный AI

Предыдущая статья — Python AI в StarCraft II. Часть V: командование армией.

Добро пожаловать в шестую часть серии статей про использование искусственного интеллекта в игре Starcraft II. В этой статье мы рассмотрим, что нам может понадобиться для победы над компьютером с сильным AI при помощи нашего собственного искусственного интеллекта.

К этому моменту наш код выглядит вот так:

import sc2
from sc2 import run_game, maps, Race, Difficulty
from sc2.player import Bot, Computer
from sc2.constants import NEXUS, PROBE, PYLON, ASSIMILATOR, GATEWAY, \
 CYBERNETICSCORE, STALKER
import random


class SentdeBot(sc2.BotAI):
    async def on_step(self, iteration):
        await self.distribute_workers()
        await self.build_workers()
        await self.build_pylons()
        await self.build_assimilators()
        await self.expand()
        await self.offensive_force_buildings()
        await self.build_offensive_force()
        await self.attack()

    async def build_workers(self):
        for nexus in self.units(NEXUS).ready.noqueue:
            if self.can_afford(PROBE):
                await self.do(nexus.train(PROBE))

    async def build_pylons(self):
        if self.supply_left < 5 and not self.already_pending(PYLON):
            nexuses = self.units(NEXUS).ready
            if nexuses.exists:
                if self.can_afford(PYLON):
                    await self.build(PYLON, near=nexuses.first)

    async def build_assimilators(self):
        for nexus in self.units(NEXUS).ready:
            vaspenes = self.state.vespene_geyser.closer_than(15.0, nexus)
            for vaspene in vaspenes:
                if not self.can_afford(ASSIMILATOR):
                    break
                worker = self.select_build_worker(vaspene.position)
                if worker is None:
                    break
                if not self.units(ASSIMILATOR).closer_than(1.0, vaspene).exists:
                    await self.do(worker.build(ASSIMILATOR, vaspene))

    async def expand(self):
        if self.units(NEXUS).amount < 3 and self.can_afford(NEXUS):
            await self.expand_now()

    async def offensive_force_buildings(self):
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < 3:
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

    async def build_offensive_force(self):
        for gw in self.units(GATEWAY).ready.noqueue:
            if self.can_afford(STALKER) and self.supply_left > 0:
                await self.do(gw.train(STALKER))

    def find_target(self, state):
        if len(self.known_enemy_units) > 0:
            return random.choice(self.known_enemy_units)
        elif len(self.known_enemy_structures) > 0:
            return random.choice(self.known_enemy_structures)
        else:
            return self.enemy_start_locations[0]

    async def attack(self):
        if self.units(STALKER).amount > 15:
            for s in self.units(STALKER).idle:
                await self.do(s.attack(self.find_target(self.state)))

        elif self.units(STALKER).amount > 3:
            if len(self.known_enemy_units) > 0:
                for s in self.units(STALKER).idle:
                    await self.do(s.attack(random.choice(self.known_enemy_units)))


run_game(maps.get("AbyssalReefLE"), [
    Bot(Race.Protoss, SentdeBot()),
    Computer(Race.Terran, Difficulty.Easy)
    ], realtime=False)

Главная проблема, которая нам видится в этом коде, заключается в том, что наша армия, отправляясь в атаку, как правило, оказывается слишком слабой. И даже если мы нанесем противнику значительный урон, подкрепление, которое мы потом отправим, будет очень слабым. Отчасти проблема здесь в том, что наш AI не знает, сколько времени прошло. Если в первые пять минут у вас есть армия из 15 Сталкеров и трое Врат в придачу, то у вас все в порядке. А если это же, но через 15 минут? Тогда ваша армия весьма слаба.

Но мы не можем сразу строить 10 Врат, так как это сильно истощит наши ресурсы и не позволит расшириться на 2 — 3 локации.

Так что же нам делать? Мы предлагаем что-то типа линейного развития. Можно было бы отслеживать компьютерное время, чтобы увидеть, сколько времени прошло. Но нам кажется, что более важным показателем для любого AI является сама итерация как таковая. После определенного тестирования мы оценили минуту примерно в 165 итераций. Так что мы можем установить вот такую константу:

 ITERATIONS_PER_MINUTE = 165

Но при этом мы хотели бы иметь возможность импортировать этого бота, поэтому давайте лучше создадим метод __init__ для класса нашего бота:

    def __init__(self):
        self.ITERATIONS_PER_MINUTE = 165

Затем можно обратить внимание на саму итерацию, которая уже передана нашему методу on_step:

    async def on_step(self, iteration):
        self.iteration = iteration
        ...

Теперь мы можем постепенно строить все больше и больше Врат, чтобы по мере прохождения игры быстрее накапливать армию. В связи с этим давайте изменим код метода offensive_force_buildings, чтобы количество шлюзов зависело от итераций, а не было зафиксировано равным трем:

            elif len(self.units(GATEWAY)) < (self.iteration / self.ITERATIONS_PER_MINUTE):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

Таким образом, весь метод примет следующий вид:

    async def offensive_force_buildings(self):
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < (self.iteration / self.ITERATIONS_PER_MINUTE):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)
[machinelearning_ad_block]

Наконец, есть еще одна проблема, которую мы обнаружим, если игра будет длиться достаточно долго. Это количество рабочих: их мы производим слишком много. Добавим верхний лимит числа рабочих в нашем методе __init__:

    def __init__(self):
        self.ITERATIONS_PER_MINUTE = 165
        self.MAX_WORKERS = 50

Пятьдесят должно быть более чем достаточно, но может оказаться, что мы захотим настраивать это число динамически, в зависимости от итераций или функционирования нексусов. Мы предлагаем следующую логику для нашего метода build_workers:

if (len(self.units(NEXUS)) * 16) > len(self.units(PROBE)) and len(self.units(PROBE)) < self.MAX_WORKERS:

Итак если количество наших нексусов, умноженное на 16, больше количества рабочих и количество рабочих меньше максимального значения (50), мы можем создать еще одного рабочего:

    async def build_workers(self):
        if (len(self.units(NEXUS)) * 16) > len(self.units(PROBE)) and len(self.units(PROBE)) < self.MAX_WORKERS:
            for nexus in self.units(NEXUS).ready.noqueue:
                if self.can_afford(PROBE):
                    await self.do(nexus.train(PROBE))

Играя с этим кодом, мы добьемся уже большего успеха, чем раньше. Но создается впечатление, что мы просто размениваем нашу армию на вражескую, а это не совсем правильно. Вражеский AI, кажется, даже работает чуть лучше нашего. Мы, конечно, можем выиграть несколько матчей, но хотелось бы добиться более солидных результатов.

Сейчас во время наших с противником игр мы атакуем базы друг друга, и он чаще всего берет верх. Иногда наши силы просто недостаточны. Мы наносим серьезный урон, но не выигрываем сразу. Впоследствии мы начинаем отставать по количеству юнитов и ресурсов, а противник накапливает огромную армию и в результате уничтожает нас.

Мы могли бы попытаться в следующий раз собрать более значительную армию. Но наши сталкеры слабоваты для использования в дальнейшей игре. Почему бы не добавить к ним Лучи Бездны?

Наконец, возможно, мы все же слишком быстро расширяемся.

Вероятно, следует расширяться более осмысленно. Вместо того, чтобы сразу же пытаться занять три источника ресурсов, давайте поставим расширение в зависимость от времени. Мы можем попробовать что-то типа этого:

    async def expand(self):
        if self.units(NEXUS).amount < (self.iteration / self.ITERATIONS_PER_MINUTE) and self.can_afford(NEXUS):
            await self.expand_now()

И наконец, давайте строить более мощные юниты. У нас уже есть наземные юниты, можем теперь заняться воздушными. Воздушные юниты полезны для быстрой навигации по карте, обхода препятствий и так далее. У Протоссов есть Лучи Бездны, создание которых требует наличия Звездных Врат (Stargate). Конечно, еще нужно Кибернетическое Ядро, но оно у нас уже есть.

Нам нужно импортировать объекты STARGATE и VOIDRAY:

from sc2.constants import NEXUS, PROBE, PYLON, ASSIMILATOR, GATEWAY, \
 CYBERNETICSCORE, STALKER, STARGATE, VOIDRAY

После этого давайте изменим наш метод построения наступательной силы:

    async def offensive_force_buildings(self):
        print(self.iteration / self.ITERATIONS_PER_MINUTE)
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

            if self.units(CYBERNETICSCORE).ready.exists:
                if len(self.units(STARGATE)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                    if self.can_afford(STARGATE) and not self.already_pending(STARGATE):
                        await self.build(STARGATE, near=pylon)

Теперь мы пытаемся возводить оба эти строения с одинаковой скоростью. Следующий шаг — модификация метода создания наступательных сил:

async def build_offensive_force(self):
        for gw in self.units(GATEWAY).ready.noqueue:
            if not self.units(STALKER).amount > self.units(VOIDRAY).amount:

                if self.can_afford(STALKER) and self.supply_left > 0:
                    await self.do(gw.train(STALKER))

        for sg in self.units(STARGATE).ready.noqueue:
            if self.can_afford(VOIDRAY) and self.supply_left > 0:
                await self.do(sg.train(VOIDRAY))

Важно отметить, что мы можем построить гораздо больше Сталкеров и гораздо быстрее, чем Лучи Бездны. Кроме того, Сталкеры дешевле. Поэтому нам нужно проследить за тем, чтобы не произвести их слишком много, блокируя тем самым производство Лучей Бездны.

Наконец, нам нужно изменить метод attack:

async def attack(self):
        # {UNIT: [n to fight, n to defend]}
        aggressive_units = {STALKER: [15, 5],
                            VOIDRAY: [8, 3]}


        for UNIT in aggressive_units:
            if self.units(UNIT).amount > aggressive_units[UNIT][0] and self.units(UNIT).amount > aggressive_units[UNIT][1]:
                for s in self.units(UNIT).idle:
                    await self.do(s.attack(self.find_target(self.state)))

            elif self.units(UNIT).amount > aggressive_units[UNIT][1]:
                if len(self.known_enemy_units) > 0:
                    for s in self.units(UNIT).idle:
                        await self.do(s.attack(random.choice(self.known_enemy_units)))

Полный код к этому моменту принял следующий вид:

import sc2
from sc2 import run_game, maps, Race, Difficulty
from sc2.player import Bot, Computer
from sc2.constants import NEXUS, PROBE, PYLON, ASSIMILATOR, GATEWAY, \
 CYBERNETICSCORE, STALKER, STARGATE, VOIDRAY
import random


class SentdeBot(sc2.BotAI):
    def __init__(self):
        self.ITERATIONS_PER_MINUTE = 165
        self.MAX_WORKERS = 50

    async def on_step(self, iteration):
        self.iteration = iteration
        await self.distribute_workers()
        await self.build_workers()
        await self.build_pylons()
        await self.build_assimilators()
        await self.expand()
        await self.offensive_force_buildings()
        await self.build_offensive_force()
        await self.attack()

    async def build_workers(self):
        if (len(self.units(NEXUS)) * 16) > len(self.units(PROBE)) and len(self.units(PROBE)) < self.MAX_WORKERS:
            for nexus in self.units(NEXUS).ready.noqueue:
                if self.can_afford(PROBE):
                    await self.do(nexus.train(PROBE))


    async def build_pylons(self):
        if self.supply_left < 5 and not self.already_pending(PYLON):
            nexuses = self.units(NEXUS).ready
            if nexuses.exists:
                if self.can_afford(PYLON):
                    await self.build(PYLON, near=nexuses.first)

    async def build_assimilators(self):
        for nexus in self.units(NEXUS).ready:
            vaspenes = self.state.vespene_geyser.closer_than(15.0, nexus)
            for vaspene in vaspenes:
                if not self.can_afford(ASSIMILATOR):
                    break
                worker = self.select_build_worker(vaspene.position)
                if worker is None:
                    break
                if not self.units(ASSIMILATOR).closer_than(1.0, vaspene).exists:
                    await self.do(worker.build(ASSIMILATOR, vaspene))

    async def expand(self):
        if self.units(NEXUS).amount < (self.iteration / self.ITERATIONS_PER_MINUTE) and self.can_afford(NEXUS):
            await self.expand_now()

    async def offensive_force_buildings(self):
        #print(self.iteration / self.ITERATIONS_PER_MINUTE)
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

            if self.units(CYBERNETICSCORE).ready.exists:
                if len(self.units(STARGATE)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                    if self.can_afford(STARGATE) and not self.already_pending(STARGATE):
                        await self.build(STARGATE, near=pylon)

    async def build_offensive_force(self):
        for gw in self.units(GATEWAY).ready.noqueue:
            if not self.units(STALKER).amount > self.units(VOIDRAY).amount:
                if self.can_afford(STALKER) and self.supply_left > 0:
                    await self.do(gw.train(STALKER))

        for sg in self.units(STARGATE).ready.noqueue:
            if self.can_afford(VOIDRAY) and self.supply_left > 0:
                await self.do(sg.train(VOIDRAY))

    def find_target(self, state):
        if len(self.known_enemy_units) > 0:
            return random.choice(self.known_enemy_units)
        elif len(self.known_enemy_structures) > 0:
            return random.choice(self.known_enemy_structures)
        else:
            return self.enemy_start_locations[0]

    async def attack(self):
        # {UNIT: [n to fight, n to defend]}
        aggressive_units = {STALKER: [15, 5],
                            VOIDRAY: [8, 3]}


        for UNIT in aggressive_units:
            if self.units(UNIT).amount > aggressive_units[UNIT][0] and self.units(UNIT).amount > aggressive_units[UNIT][1]:
                for s in self.units(UNIT).idle:
                    await self.do(s.attack(self.find_target(self.state)))

            elif self.units(UNIT).amount > aggressive_units[UNIT][1]:
                if len(self.known_enemy_units) > 0:
                    for s in self.units(UNIT).idle:
                        await self.do(s.attack(random.choice(self.known_enemy_units)))


run_game(maps.get("AbyssalReefLE"), [
    Bot(Race.Protoss, SentdeBot()),
    Computer(Race.Terran, Difficulty.Hard)
    ], realtime=False)

И теперь мы без особой борьбы побеждаем крутых терранов. Были, конечно, и потери (в основном, из-за того, что юниты застревали). С этой стратегией нам удавалось довольно последовательно побеждать протоссов, зергов и терранов.

На этом этапе вам также стоит посоревноваться с другими ботами из пакета Python-sc2. Клонируйте его и найдите там каталог examples. Скопируйте его в вашу рабочую директорию, а затем напишите вот такой код:

from examples.terran.proxy_rax import ProxyRaxBot

Затем измените метод  run_game следующим образом:

run_game(maps.get("AbyssalReefLE"), [
    Bot(Race.Protoss, SentdeBot()),
    Bot(Race.Terran, ProxyRaxBot()),
    ], realtime=False)

Когда мы ставили двух ботов играть друг против друга, то довольно часто по непонятной причине появлялся черный экран и далее ничего не происходило. Чтобы это исправить, оказалось достаточно удалить из директории C:\Users\H\Documents\StarCraft II файл variables.txt.

В ботов из каталога examples встроено достаточно много общих стратегий. Например, популярная стратегия протоссов — пушечный натиск. Обратите на нее внимание.

На данный момент ваша стратегия создается просто набором условных выражений, который составляете вы. А мы собираемся включить в этот процесс методы глубокого обучения. По крайней мере, мы наверняка сможем использовать алгоритмы регрессии для подбора значений наших переменных, или классификатор — для определения целесообразности атаки.

Уверен, что многие захотят увидеть классический AI, которого пока еще нет для всей игры STarcraft II. Но мы можем попытаться! До встречи в следующей статье!

Следующая статья — Python AI в StarCraft II. Часть VII: введение в глубокое обучение.