Написание ИИ для хоккея. Часть 2

4190

Прежде чем читать эту часть, советуем вам взглянуть на предыдущий урок. А в этой статье мы продолжим реализацию искусственного интеллекта для игры в хоккей с использованием «рулевого поведения» (steering behaviors), основанного на конечном автомате (FSM – finite state machine). Сегодня мы будем акцентировать наше внимание на атаке, не забудем о перехвате шайбы и научим наших хоккеистов забивать гол в ворота соперников.

Несколько слов об атаке

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

Однако все действия игроков возможны лишь при анализе текущей ситуации на катке. Хоккеисты постоянно просчитывают возможные ходы на игровом поле. Человек в большинстве случаев может объяснить действия одного игрока относительно другого. Например, “этот хоккеист занял более выгодное положение, проанализировав ситуацию на катке”. Хоть для нас это и очевидно, объяснить такую тактику компьютеру совсем нетривиальная задача.

Как следствие, если мы попытаемся научить искусственный интеллект думать также, как и человек, то в итоге получим огромную и ужасную кучу кода. К тому же такой код будет трудночитаемым и уж точно сложно модифицируемым.

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

Организация атаки с помощью состояний

Мы разобьем всю атаку на несколько мелких частей, каждая из которых будет выполнять какую-то свою специфическую задачу. Все эти куски будут являться состояниями конечного автомата, основанного на стеке. Как было сказано ранее, каждое состояние будет прикладывать силу в «рулевом управлении» (steering force), вследствие чего хоккеист будет вести себя соответственно.

Управление этими состояниями, а также переключение между ними и определяет такое сложное явление, как атака. Изображение ниже иллюстрирует те самые состояния, на которые была разобрана вся атака:

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

Все эти состояния будут переключаться в зависимости от расстояния до шайбы и от того, кто является ее владельцем. Например, мы будем включать состояние attack, если выполняется условие team has the puck (наша команда владеет шайбой).

Как видно из изображения выше, атака делится на 4 стадии: idle, attack, stealPuck, и pursuePuck. Одно из них, состояние idle, уже было реализовано в прошлой статье. Оно является отправной точкой, с которой и начинается процесс атаки. Затем спортсмены переходят к:

  • attack, если команда уже владеет шайбой;
  • stealPuck, если соперники владеют шайбой
  • pursuePuck, если шайба никому не принадлежит и попросту скользит по льду

Давайте более подробно ознакомимся с этими состояниями.

attack описывает наступательные действия. Игрок, владеющий шайбой и именуемый лидером (leader), пытается продвинуться к вражеским воротам. Партнеры по команде тоже не стоят на месте. Они продвигаются вперед, чтобы оказать поддержку своему лидеру.

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

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

Обновим состояние idle

Когда мы писали функцию idle, у нас еще не было состояний, в которые мы могли перевести хоккеиста. Теперь ситуация изменилась, и, поскольку idle является отправной точкой почти для всех состояний, давайте обновим ее.

На изображении ниже вы можете наблюдать переходные состояния, в которые будут попадать игроки из idle:

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

Если команда спортсмена имеет при себе шайбу, то нужно переходить к атаке. Если же шайба у противников, то нужно переходить к состоянию stealPuck. В случае, когда шайба бесхозная и игрок находиться достаточно близко к ней, он переходит к состоянию pursuePuck. Вот и вся логика. А теперь приведем код:

			class Athlete {
    // (...)
    private function idle() :void {
        var aPuck :Puck = getPuck();
         
        stopAndlookAt(aPuck);
         
        // это простой хак, нужен для тестирования ИИ
        if (mStandStill) return;
         
        // есть ли у шайбы владелец?
        if (getPuckOwner() != null) {
            // да, есть!
            mBrain.popState();
 
            if (doesMyTeamHaveThePuck()) {
                // шайба у моей команды, время атаковать!
                mBrain.pushState(attack);
            } else {
                // шайба у соперников, надо ее отобрать
                mBrain.pushState(stealPuck);
            }
        } else if (distance(this, aPuck) < 150) {
            // шайба попросту катится по катку, надо подобрать ее
            mBrain.popState();
            mBrain.pushState(pursuePuck);
        }
    }
     
    private function attack() :void {
    }
     
    private function stealPuck() :void {
    }
     
    private function pursuePuck() :void {
    }
}
		

Как вы могли заметить, методы attack(), stealPuck() и pursuePuck() объявлены, но не реализованы. Давайте реализуем их!

Следование за шайбой — pursuePuck

Это состояние будет активно, когда шайба не будет принадлежать никому (начало игры тоже попадает под это описание, поскольку шайба без владельца будет находиться в центре поля).

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

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

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

А вот код состояния pursuePuck:

			class Athlete {
    // (...)
    private function pursuePuck() :void {
        var aPuck :Puck = getPuck();
	
        mBoid.steering = mBoid.steering + mBoid.separation();
	
        if (distance(this, aPuck) > 150) {
            // шайба слишком далеко, быть может, 
            // кто-то из моей команды ближе и он ее подберет
            mBrain.popState();
            mBrain.pushState(idle);
        } else {
            // шайба рядом, надо попытаться подобрать ее
            if (aPuck.owner == null) {
                // никто не подобрал шайбу, это наш шанс
                mBoid.steering = mBoid.steering + mBoid.seek(aPuck.position);
		
            } else {
                // кто-то уже подобрал шайбу;
                // если шайба принадлежит нашей команде, надо переходить к атаке,
                // иначе пытаемся отобрать ее у соперника
                mBrain.popState();
                mBrain.pushState(doesMyTeamHaveThePuck() ? attack : stealPuck);
            }
        }
    }
}
		

Обратите внимание на 6 строчку из приведенного выше кода. Она отвечает за то, чтобы игроки не оставались слишком близко друг к другу во время активного состояния pursuePuck, поскольку это будет выглядеть не естественно.

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

//

 

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

 

 

Проведение атаки

После того, как игрок получил шайбу, он должен стремиться к воротам соперника и забить гол. Это и будет целью состояния attack.

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

Атака имеет два переходных состояния: pursuePuck и stealPuck. Игроки, будучи в состоянии attack, должны будут бежать к воротам противников. Давайте реализуем это:

			class Athlete {
    // (...)
    private function attack() :void {
        var aPuckOwner :Athlete = getPuckOwner();
     
        // есть ли у шайбы владелец?
        if (aPuckOwner != null) {
            // да, есть. давайте проверим, кто же это
            if (doesMyTeamHaveThePuck()) {
                if (amIThePuckOwner()) {
                    // шайба у моей команды, более того - она у меня
                    // пытаемся прорваться к вражеским воротам
                    mBoid.steering = mBoid.steering + mBoid.seek(getOpponentGoalPosition());
                     
                } else {
                    // шайба у моей команды, но не у меня; 
                    // надо бежать за лидером и помогать ему
                    mBoid.steering = mBoid.steering + mBoid.followLeader(aPuckOwner.boid);
                    mBoid.steering = mBoid.steering + mBoid.separation();
                }
            } else {
                // шайба у противников, останавливаем атаку
                // и пытаемся отобрать шайбу
                mBrain.popState();
                mBrain.pushState(stealPuck);
            }
        } else {
            // шайба никому не принадлежит;
            // надо подобрать ее
            mBrain.popState();
            mBrain.pushState(pursuePuck);
        }
    }
}
		

Разберем приведенный код. Если у шайбы есть владелец, причем это игрок сам, то он несется к воротам противника (строка 13). Если же шайба принадлежит команде игрока, но он не является лидером, то следует бежать за нашим форвардом и помогать ему (строка 18). Обратите внимание на метод mBoid.separation() в строке 19, который мы использовали чуть ранее. На изображении ниже вы можете увидеть, как игроки помогают своему лидеру.

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

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

В случае, когда шайбой владеют противники, мы переводим игрока в состояние stealPuck. А если она никому не принадлежит, то игрок должен ее подобрать. Это делается переводом в состояние pursuePuck.

А теперь давайте посмотрим на результат:

//

 

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

 

 

Улучшение поддержки атаки

Текущая реализация атаки находится на достаточно хорошем уровне исполнения. Однако недостаток все же имеется. Проявляется он в том случае, когда лидер оказывается дальше от ворот соперников, чем кто-либо другой из его команды.

Присмотритесь на последний наш результат. В случае, когда какой-нибудь игрок A находится к чужим воротам ближе, чем лидер, то игрок A начинает вести себя немного неестественно, пока не окажется позади форварда.

Этот недостаток с легкостью можно исправить путем проверки положения игрока — находится он позади лидера или опережает его:

			class Athlete {
    // (...)
    private function isAheadOfMe(theBoid :Boid) :Boolean {
        var aTargetDistance :Number = distance(getOpponentGoalPosition(), theBoid);
        var aMyDistance :Number = distance(getOpponentGoalPosition(), mBoid.position);
     
        return aTargetDistance <= aMyDistance;
    }
 
    private function attack() :void {
        var aPuckOwner :Athlete = getPuckOwner();
     
        // есть ли у шайбы владелец?
        if (aPuckOwner != null) {
            // да, есть
            if (doesMyTeamHaveThePuck()) {
                if (amIThePuckOwner()) {
                    // шайба у моей команды, более того - она у меня
                    // пытаемся прорваться к вражеским воротам
                    mBoid.steering = mBoid.steering + mBoid.seek(getOpponentGoalPosition());
                     
                } else {
                    // шайба у моей команды, но не у меня; а лидер впереди меня?
                    if (isAheadOfMe(aPuckOwner.boid)) {
                        // да, он впереди; побегу за ним,
                        // чтобы помочь ему
                        mBoid.steering = mBoid.steering + mBoid.followLeader(aPuckOwner.boid);
                        mBoid.steering = mBoid.steering + mBoid.separation();
                    } else {
                        // нет, лидер позади меня; давайте добавим "разделение" (separation),
                        // чтобы предотвратить давку
                        mBoid.steering = mBoid.steering + mBoid.separation();
                    }
                }
            } else {
                // шайба у противников, останавливаем атаку
                // и пытаемся отобрать шайбу
                mBrain.popState();
                mBrain.pushState(stealPuck);
            }
        } else {
            // шайба никому не принадлежит;
            // надо подобрать ее
            mBrain.popState();
            mBrain.pushState(pursuePuck);
        }
    }
}
		

А теперь взглянем на полученный результат. Игроки ведут себя на порядок реалистичнее!

//

 

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

 

 

Отбор шайбы

Последним состоянием атаки является stealPuck, который становится активным, когда шайба принадлежит соперникам. Цель — отобрать шайбу для проведения собственной атаки.

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

А теперь перейдем к реализации:

			class Athlete {
    // (...)
    private function stealPuck() :void {
        // есть ли у шайбы владелец?
        if (getPuckOwner() != null) {
            // да, есть
            if (doesMyTeamHaveThePuck()) {
                // шайба у моей команды
                // время перейти в атаку
                mBrain.popState();
                mBrain.pushState(attack);
            } else {
                // шайба у противников
                var aOpponentLeader :Athlete = getPuckOwner();
             
                // попробуем отобрать шайбу у врага,
                // но попытаемся предсказать его положение
                // и перехватим его там
                mBoid.steering = mBoid.steering + mBoid.pursuit(aOpponentLeader.boid);
                mBoid.steering = mBoid.steering + mBoid.separation();
            }
        } else {
            // шайба никому не принадлежит;
            // надо подобрать ее
            mBrain.popState();
            mBrain.pushState(pursuePuck);
        }
    }
}
		

Алгоритм предельно прост. Если шайба принадлежит команде игрока, то мы переходим к состоянию attack (строка 11). Если же она у команды противника, то игрок попытается перехватить ее. Однако, обратите внимание. Мы не можем передавать нашему игроку координаты противника, поскольку это приведет к тому, что наш хоккеист будет просто его преследовать. Именно поэтому игрок будет предсказывать то положение, в котором окажется противник в ближайшее время, и будет стремиться перехватить своего соперника. Все это реализовано при помощи «поведения преследования» (pursue behavior) в строке 19.

А вот и результат. Обратите внимание на то, как игроки отбирают шайбу у соперников.

//

 

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

 

 

Улучшение отбора шайбы

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

			class Athlete {
    // (...)
    private function stealPuck() :void {
        // есть ли у шайбы владелец?
        if (getPuckOwner() != null) {
            // да, есть
            if (doesMyTeamHaveThePuck()) {
                // шайба у моей команды
                // время перейти в атаку
                mBrain.popState();
                mBrain.pushState(attack);
            } else {
                // шайба у противников
                var aOpponentLeader :Athlete = getPuckOwner();
             
                // лидер соперников близок ко мне?
                if (distance(aOpponentLeader, this) < 150) {
                    // да, он рядом; надо предугадать его положение и
                    // перехватить его
                    mBoid.steering = mBoid.steering.add(mBoid.pursuit(aOpponentLeader.boid));
                    mBoid.steering = mBoid.steering.add(mBoid.separation(50));
                 
                } else {
                    // нет, он слишком далеко; в скором времени на этом 
                    // месте мы будем переходить в защиту
                    // TODO: mBrain.popState();
                    // TODO: mBrain.pushState(defend);
                }
            }
        } else {
            // шайба никому не принадлежит;
            // надо подобрать ее
            mBrain.popState();
            mBrain.pushState(pursuePuck);
        }
    }
}
		

Теперь наши игроки не будут бежать сломя голову к противнику, пытаясь отобрать у него шайбу. Побегут только те, расстояние от которых до соперника не превышает 150.

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

Взгляните на полученный результат. Теперь игроки более адекватно ведут себя при отборе шайбы.

//

 

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

 

 

Избежание вражеских защитников

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

Мы можем использовать «избежание коллизий» (collision avoidance) для того, чтобы игроки могли уклоняться от противников. Вот, как это будет выглядеть:

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

Для реализации уклонений нам следует добавить лишь одну строку (14 строка) в наш код:

			class Athlete {
    // (...)
    private function attack() :void {
        var aPuckOwner :Athlete = getPuckOwner();
     
        // есть ли у шайбы владелец?
        if (aPuckOwner != null) {
            // да, есть
            if (doesMyTeamHaveThePuck()) {
                if (amIThePuckOwner()) {
                    // шайба у моей команды, более того - она у меня
                    // пытаемся прорваться к вражеским воротам и уклоняемся от врагов
                    mBoid.steering = mBoid.steering + mBoid.seek(getOpponentGoalPosition());
                    mBoid.steering = mBoid.steering + mBoid.collisionAvoidance(getOpponentTeam().members);
         
                } else {
                    // шайба у моей команды, но не у меня; а лидер впереди меня?
                    if (isAheadOfMe(aPuckOwner.boid)) {
                        // да, он впереди; побегу за ним,
                        // чтобы помочь ему
                        mBoid.steering = mBoid.steering + mBoid.followLeader(aPuckOwner.boid);
                        mBoid.steering = mBoid.steering + mBoid.separation();
                    } else {
                        // нет, лидер позади меня; давайте добавим "разделение" (separation),
                        // чтобы предотвратить давку
                        mBoid.steering = mBoid.steering + mBoid.separation();
                    }
                }
            } else {
                // шайба у противников, останавливаем атаку
                // и пытаемся отобрать шайбу
                mBrain.popState();
                mBrain.pushState(stealPuck);
            }
        } else {
            // шайба никому не принадлежит;
            // надо подобрать ее
            mBrain.popState();
            mBrain.pushState(pursuePuck);
        }
    }
}
		

Ниже вы сможете посмотреть на работу обновленного состояния attack. Противники обездвижены для большей наглядности.

//

 

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.

 

 

Вывод

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

В следующем уроке вы узнаете, как реализовать защиту. А в конце вы сможете посмотреть на игру, которая будет полностью под контролем искусственного интеллекта.

Перевод статьи «Create a Hockey Game AI Using Steering Behaviors: Attack»

4190