uzutaka blog

Game Engineering and Programming

UnityでゲームAIを作るチュートリアル(遊べるサンプルゲームとプロジェクト付き)

ゲームAIを作るときによく利用されるステートマシンについて、サンプルゲームを使いながら説明していきます。

最終的に出来上がるゲームは以下URLで遊べます。
http://uzutaka.com/Projects/StateMachineSample

UnityのプロジェクトファイルはそのままGithubに置いてあります。Unityフリー版で起動できますので、ぜひcloneして動作をいじってみてください。
https://github.com/takanori/StateMachineSample

自律エージェント

今回作成するゲームでは、赤い戦車は敵で、プレイヤーの青い戦車を見つけると近づいて攻撃してきます。青い戦車は矢印キーかWASDキーで動かし、マウスクリックで弾丸を発車することができます。

敵は、以下の4つの行動を取ることができます。

  • 徘徊: フィールド内のランダムな位置に向かって移動する
  • 追跡: プレイヤーに近づくように移動する
  • 攻撃: プレイヤーに向かって弾丸を発射する
  • 爆発: 吹き飛んでから、消滅する

さらに敵は、行動を切り替えるためのセンサとして「聴覚」を持っています。とはいっても「プレイヤーとの距離が小さければエンジン音で気づく」というだけの簡単な仕組みです。
このように「環境を知覚し、自分の目的を達成するため、環境に働きかけを行うもの」を自律エージェントと言います。

以下が自律エージェントの模式図です。「センサ」によって環境を知覚し「エフェクタ」によって環境に働きかけます。

f:id:uzutaka:20151014030349p:plain

自律エージェントの行動

徘徊行動

徘徊行動として、今回のサンプルでは「フィールド内のランダムな位置を目標地点として設定し、そこに向かって進む。ターゲットに近づいたら、新しい目標地点を設定し、同じことを繰り返す」という行動を実装しています。

public class Enemy : MonoBehaviour
{
    private float speed = 10f;
    private float rotationSmooth = 1f;

    private Vector3 targetPosition;

    private float changeTargetSqrDistance = 40f;

    private void Start()
    {
        targetPosition = GetRandomPositionOnLevel();
    }

    private void Update()
    {
        // 目標地点との距離が小さければ、次のランダムな目標地点を設定する
        float sqrDistanceToTarget = Vector3.SqrMagnitude(transform.position - targetPosition);
        if (sqrDistanceToTarget < changeTargetSqrDistance)
        {
            targetPosition = GetRandomPositionOnLevel();
        }

        // 目標地点の方向を向く
        Quaternion targetRotation = Quaternion.LookRotation(targetPosition - transform.position);
        transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotationSmooth);

        // 前方に進む
        transform.Translate(Vector3.forward * speed * Time.deltaTime);
    }

    public Vector3 GetRandomPositionOnLevel()
    {
        float levelSize = 55f;
        return new Vector3(Random.Range(-levelSize, levelSize), 0, Random.Range(-levelSize, levelSize));
    }
}

追跡行動

追跡行動は「プレイヤーの方向に向かって進む」という行動です。

public class Enemy : MonoBehaviour
{
    private float speed = 10f;
    private float rotationSmooth = 1f;

    private Transform player;

    private void Start()
    {
        // 始めにプレイヤーの位置を取得できるようにする
        player = GameObject.FindWithTag("Player").transform;
    }

    private void Update()
    {
        // プレイヤーの方向を向く
        Quaternion targetRotation = Quaternion.LookRotation(player.position - transform.position);
        transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotationSmooth);

        // 前方に進む
        transform.Translate(Vector3.forward * speed * Time.deltaTime);
    }
}

攻撃行動

攻撃行動は「プレイヤーの方向に砲身を動かしつつ、弾丸を発射する」という行動です。

public class Enemy : MonoBehaviour
{
    public Transform turret;
    public Transform muzzle;
    public GameObject bulletPrefab;

    private float attackInterval = 2f;
    private float turretRotationSmooth = 0.8f;
    private float lastAttackTime;

    private Transform player;

    private void Start()
    {
        // 始めにプレイヤーの位置を取得できるようにする
        player = GameObject.FindWithTag("Player").transform;
    }

    private void Update()
    {
        // 砲台をプレイヤーの方向に向ける
        Quaternion targetRotation = Quaternion.LookRotation(player.position - turret.position);
        turret.rotation = Quaternion.Slerp(turret.rotation, targetRotation, Time.deltaTime * turretRotationSmooth);

        // 一定間隔で弾丸を発射する
        if (Time.time > lastAttackTime + attackInterval)
        {
            Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
            lastAttackTime = Time.time;
        }
    }
}

爆発行動

爆発行動は「吹き飛ぶような動きをしてから、1秒後に自身を消去する」という行動です。

public class Enemy : MonoBehaviour
{
    private void Start()
    {
        // ランダムな吹き飛ぶ力を加える
        Vector3 force = Vector3.up * 1000f + Random.insideUnitSphere * 300f;
        rigidbody.AddForce(force);

        // ランダムに吹き飛ぶ回転力を加える
        Vector3 torque = new Vector3(Random.Range(-10000f, 10000f), Random.Range(-10000f, 10000f), Random.Range(-10000f, 10000f));
        rigidbody.AddTorque(torque);

        // 1秒後に自身を消去する
        Destroy(gameObject, 1.0f);
    }
}

ここでは体力という概念をまだ作っていないので、シーンを起動した直後に吹き飛んでしまいます。

この後、最初にライフポイントが3あり、弾丸を食らうごとに1減っていき、0になると爆発行動を起こすようにしていきます。

センサ

続いて、自律エージェントが環境を知覚する部分「センサ」についてです。

追跡行動や攻撃行動の中でプレイヤーの位置を毎フレーム取得していましたが、実はあれもセンサの一種と言えます。 しかし、フィールド内に存在するプレイヤーの位置をどこにいても正確に把握できる、いわば千里眼のようなものになってしまっています。

今後は、プレイヤーが十分に近くにいるときだけ位置を取得できるようにすることで、より自然なセンサ「聴覚」を実装していきます。

また、今回は実装しませんが

  • 自分の前方にいるときだけ位置がわかる
  • 壁に遮られていないときだけ位置がわかる

といった仕組みにすることで「視覚」のようなセンサを作ることもできます。

自律エージェントのまとめ

ここまでで、環境を知覚し、何らかの目的を達成するために動くことのできる自律エージェントを作ることができました。

しかし、同じ行動をずっと繰り返すだけではAIとして面白みがありません。ステートマシンの仕組みを使って、異なる行動を取る状態へと遷移できるようにしましょう。

ステートマシン

有限ステートマシンとは?

有限ステートマシン (Finite State Machine : FSM、日本語で有限状態機械) とは

「有限個のステート(=状態)を持ち、入力を処理することで、あるステートから別のステートへ遷移したり、出力を引き起こしたりすることができるもの」

です。

ステートマシンは「複数の状態と動作を持つものをわかりやすく抽象化して表現するためのモデル」と考えた方がわかりやすいと思います。

この世の中にある、状態を持つものはたいてい有限ステートマシンとみなすことができます。

例えば、スイッチ付きの電球はオン・オフの2つのステートを持つステートマシンとみなせます。

f:id:uzutaka:20151014030526p:plain

ステートマシンはAI実装で長らく利用されてきたアーキテクチャです。それには以下の様な理由があります。

  • 実装が容易
    • 多種多様な実装方法があるが、どれもシンプルである
  • デバッグが容易
    • コードが行動ごとにまとまりとなるので、不具合が起きた時はそのときのステートが参照する部分のみ見れば良くなる
  • 計算のオーバーヘッドが小さい
    • 原則的に遷移の規則がハードコードされているため、遷移先を取得するために必要な計算量はとても小さい
  • 柔軟性がある
    • 新しいステートや遷移を追加するのが容易である
    • ニューラルネットワークやBehaviour Treeといった他のAIの仕組みと簡単に組み合わせられる

これらの利便性から、AIに限らず状態を持つもの全般を実装する際に使われます。

ステートマシンの実装

ステートマシンはif-else文やswitch文でも作ることができますが、ステートが少し増えてくるだけで保守するのが辛くなります。

色々と応用できるので、一度フレームワークを作ってしまうのがおすすめです。

ここでは、一つのステートを一つのクラスとして扱う、単純なステートマシンの実装を見てみましょう。

ステートの基底クラス

public class State<T>
{
    // このステートを利用するインスタンス
    protected T owner;

    public State(T owner)
    {
        this.owner = owner;
    }

    // このステートに遷移する時に一度だけ呼ばれる
    public virtual void Enter() {}

    // このステートである間、毎フレーム呼ばれる
    public virtual void Execute() {}

    // このステートから他のステートに遷移するときに一度だけ呼ばれる
    public virtual void Exit() {}
}

ステートに入ったときと出るときに特定の行動を行わせたいという場面が多いので、あらかじめ呼ばれるメソッドを用意してあります。

ステートマシンクラス

public class StateMachine<T>
{
    private State<T> currentState;

    public StateMachine()
    {
        currentState = null;
    }

    public State<T> CurrentState
    {
        get { return currentState; }
    }

    public void ChangeState(State<T> state)
    {
        if (currentState != null)
        {
            currentState.Exit();
        }
        currentState = state;
        currentState.Enter();
    }

    public void Update()
    {
        if (currentState != null)
        {
            currentState.Execute();
        }
    }
}

ChangeStateの中で

  • 現在のステートのExit関数を呼び出す
  • 現在のステートを新しいステートに変更する
  • 現在のステートのEnter関数を呼び出す

という処理を行っています。

また、毎フレーム呼ばれるUpdate関数の中で、Execute関数を読んでいます。

ステートを持つオブジェクトの基底クラス

これらStateクラス、StateMachineクラスをそのまま使うこともできますが、準備のためのコードを毎回繰り返し書くことになります。

そこでステートマシンを使うクラスの基底を先に用意しておきます。

public abstract class StatefulObjectBase<T, TEnum> : MonoBehaviour
    where T : class where TEnum : System.IConvertible
{
    protected List<State<T>> stateList = new List<State<T>>();

    protected StateMachine<T> stateMachine;

    public virtual void ChangeState(TEnum state)
    {
        if (stateMachine == null)
        {
            return;
        }

        stateMachine.ChangeState(stateList[state.ToInt32(null)]);
    }

    public virtual bool IsCurrentState(TEnum state)
    {
        if (stateMachine == null)
        {
            return false;
        }

        return stateMachine.CurrentState == stateList[state.ToInt32(null)];
    }

    protected virtual void Update()
    {
        if (stateMachine != null)
        {
            stateMachine.Update();
        }
    }
}

StatefulObjectBaseクラスは、取り得るステートのリストを持ち、enum型を引数とするChangeState関数で、他のステートに遷移することができます。

戦車のAI

ステートマシンのフレームワークを作ったので、いよいよ戦車ゲームの敵AIを実装していきます。

敵戦車のAIは、自律エージェントのところで作った4つのステートを持ちます。

f:id:uzutaka:20151014030636p:plain

  • 徘徊: フィールド内のランダムな位置に向かって移動する
  • 追跡: プレイヤーに近づくように移動する
  • 攻撃: プレイヤーに向かって弾丸を発射する
  • 爆発: 吹き飛んでから、消滅する

ここからは敵AIクラスの重要な部分のみ取り上げていきます。全体を見たい方は https://github.com/takanori/StateMachineSample を参照してください。

取りうるステートの宣言

まず、敵AIの取りうるステートをenum型で宣言します。

public enum EnemyState
{
    Wander,
    Pursuit,
    Attack,
    Explode,
}

ステートマシンの初期化

クラスの初期化関数の中でステートマシンを初期化します。

    public void Initialize()
    {
        // 始めにプレイヤーの位置を取得できるようにする
        player = GameObject.FindWithTag("Player").transform;

        life = maxLife;

        // ステートマシンの初期設定
        stateList.Add(new StateWander(this));
        stateList.Add(new statePursuit(this));
        stateList.Add(new StateAttack(this));
        stateList.Add(new StateExplode(this));

        stateMachine = new StateMachine<Enemy>();

        ChangeState(EnemyState.Wander);
    }

ステート実装

Stateを継承した、敵AI用のステートを宣言します。

ここでは、4つのステートの内、例として追跡ステートを挙げています。

    /// <summary>
    /// ステート: 追跡
    /// </summary>
    private class statePursuit : State<Enemy>
    {
        public statePursuit(Enemy owner) : base(owner) {}

        public override void Enter() {}

        public override void Execute()
        {
            // プレイヤーとの距離が小さければ、攻撃ステートに遷移
            float sqrDistanceToPlayer = Vector3.SqrMagnitude(owner.transform.position - owner.player.position);
            if (sqrDistanceToPlayer < owner.attackSqrDistance - owner.margin)
            { 
                owner.ChangeState(EnemyState.Attack);
            }

            // プレイヤーとの距離が大きければ、徘徊ステートに遷移
            if (sqrDistanceToPlayer > owner.pursuitSqrDistance + owner.margin)
            { 
                owner.ChangeState(EnemyState.Wander);
            }

            // プレイヤーの方向を向く
            Quaternion targetRotation = Quaternion.LookRotation(owner.player.position - owner.transform.position);
            owner.transform.rotation = Quaternion.Slerp(owner.transform.rotation, targetRotation, Time.deltaTime * owner.rotationSmooth);

            // 前方に進む
            owner.transform.Translate(Vector3.forward * owner.speed * Time.deltaTime);
        }

        public override void Exit() {}
    }

追跡ステートでは、Enter関数とExit関数は何もしていません。
少し前に自律エージェントのところで書いたコードのUpdate関数と比べて

  • プレイヤーとの距離が小さければ、攻撃ステートに遷移
  • プレイヤーとの距離が大きければ、徘徊ステートに遷移

という処理が増えていることと、owner変数からメソッドを呼び出すようになっている点が異なります。

ライフと爆発行動

プレイヤーの弾丸が当たるとライフが減り、ライフが0になると爆発行動に遷移する処理は、以下のようになります。

public void TakeDamage()
{
    life--;
    if (life <= 0)
    {
        ChangeState(EnemyState.Explode);
    }
}

爆発行動への遷移は、現在の状態に依らず行われるようになっています。

まとめ

以上で戦車ゲームの敵AIが完成しました。
一見難しそうなAIという領域ですが、案外簡単じゃないかと感じていただけたら嬉しいです。
興味を持たれた方は、以下の参考文献なども参照して、ぜひぜひ面白いAIを使ったゲームを作ってみてください。

参考文献

ゲーム開発者のためのAI入門

実例で学ぶゲームAIプログラミング

Unity 4.x Game AI Programming

PerlのWAF,Amon2の使い方とサンプルアプリケーション

同期との勉強会で発表した,Amon2の導入についての資料をまとめました.

  • Amon2を初めて利用する人向け
  • 単純なWebアプリを動かすところまでを説明します

Amon2とは

Amon2とはPSGI/PlackベースのWeb Application Frameworkで,非常に薄く軽量でありながら高い拡張性を持ち,Web系企業の大規模なシステムでの使用実績があります.
公式サイトはこちら Amon2 - Web application framework for Rapid web development

Amon2のインストール

以下,Amon2のバージョンは5.16です.執筆時最新版の6.00でも正常に動作することを確認しています.

$ cpanm Amon2 Carton
()

$ amon2-setup.pl AmonSample
-- Running flavor: Basic --
... (中略) ...
Setup script was done! You are ready to run the skelton.

You need to install the dependencies by:

    > carton install

And then, run your application server:

    > carton exec perl -Ilib script/amonsample-server

起動

carton installはしばらく時間がかかります.

$ cd AmonSample
$ carton install
Installing modules using /Users/takanori/AmonSample/cpanfile
Successfully installed Test-Harness-3.29
.. (中略) ...
Complete! Modules were installed into /Users/takanori/AmonSample/local

$ carton exec perl -Ilib script/amonsample-server
AmonSample: http://127.0.0.1:5000/

ブラウザで確認

localhost:5000へアクセス

f:id:uzutaka:20131127015642p:plain

ディレクトリ構成

builder      (Module::Build用設定)
config       データベースなどの設定ファイル
db           sqliteなどのデータベースファイル(mysqlなら使わない)
lib          perlファイル 主にこの中身をいじる 
local        (Cartonがインストールした依存モジュール)
script       起動スクリプト
sql          sqlファイル
static       js, css, image などの静的ファイル
t            テストファイル
tmpl         Xslateテンプレート
xt           (モジュールの作者専用テスト)

Router

# lib/AmonSample/Web/Dispatcher.pm
package AmonSample::Web::Dispatcher;
use strict;
use warnings;
use utf8;
use Amon2::Web::Dispatcher::RouterBoom;

any '/' => sub {
    my ($c) = @_;
    return $c->render('index.tx');
};

post '/account/logout' => sub { 
    my ($c) = @_;
    $c->session->expire();
    return $c->redirect('/');
};

1;

Template

<!-- tmpl/index.tx -->
: cascade "include/layout.tx"

: override content -> {

<h1>Hello, Amon2 world!</h1>

: }
<!-- tmpl/include/layout.tx -->
<!doctype html>
<html>
<head>...(中略)...</head>
<body>
    <div class="container">
        <div id="main">
            <: block content -> { } :>
        </div>
    </div>
</body>
</html>

Webアプリを作ってみる

  • 機能
    • テキストを挿入できる
    • 最新の1件を閲覧できる
  • 構成

変更する点を順番に見ていきましょう.

db設定

# config/development.pl
+{
    'DBI' => [
        'dbi:mysql:amonsample', 'YourUserName', 'YourPassword',
        +{ mysql_enable_utf8 => 1 },
    ],
};
-- sql/mysql.sql
CREATE TABLE IF NOT EXISTS memos (
    id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    text TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# lib/AmonSample/DB/Schema.pm
table {
    name 'memos';
    pk 'id';
    columns qw(id text);
};
# root以外のユーザの場合,適切な権限が必要
$ mysqladmin -uYourUserName create amonsample -p
$ mysql -uYourUserName amonsample < sql/mysql.sql -p  

Model設定

# lib/AmonSample/DB.pm
...(略)...
sub latest_text {
    my ($self) = @_;
    my ($row) = $self->search(
        'memos',
        {},
        { order_by => {'id' => 'DESC'}, limit => 1 }
    );
    return $row->get_column('text') if ($row);
}

sub insert_memo {
    my ( $self, $text ) = @_;
    $self->insert( 'memos', { text => $text } );
}

1;

Router設定

# lib/AmonSample/Web/Dispatcher.pm
...(略)...
get '/memo' => sub {
    my ($c) = @_;
    my $latest_text = $c->db->latest_text;
    $latest_text //= "No comment";
    return $c->render( 'memo.tx', { latest_text => $latest_text } );
};

post '/memo/insert' => sub {
    my ($c) = @_;
    my $text = $c->req->param('text');
    $c->db->insert_memo($text);
    return $c->redirect('/memo');
};

1;

Template設定

<!-- tmpl/memo.tx -->
: cascade "include/layout.tx"

: override content -> {

<h2><: $latest_text :></h2>

<form method="post" action="<: uri_for('memo/insert') :>">
  <textarea name="text" rows="3"></textarea>
  <input type="submit" value="Submit">
</form>

: }

動作確認

localhost:5000/memo へアクセス

f:id:uzutaka:20131127021343p:plainf:id:uzutaka:20131127021352p:plain


Amon2のごく基本的な使い方を紹介しました.とても使いやすいフレームワークですので,ぜひ一度試してみてください.

OS X 10.9 Mavericks上でzshの動作が極端に遅くなる問題を解決

OS X Mavericks上でzshの動作が異常に遅くなる現象が起きました.
以下のgifのように,入力したコマンドの反映が追いつかない状況です.

f:id:uzutaka:20131024050827g:plain

.zshrcを始めとする設定ファイルはdotfilesとしてgitで管理していたので,ユーザ側の設定はOS X Mountain Lionで正常に動いていたときと全く同じです.

調査したところoh-my-zshのvi-modeプラグインの中で呼ばれているzle reset-promptの処理が原因であるとわかりました.

# $HOME/.oh-my-zsh/plugins/vi-mode/vi-mode.plugin.zsh

function zle-keymap-select zle-line-init zle-line-finish {
   # The terminal must be in application mode when ZLE is active for $terminfo
   # values to be valid.
    if (( ${+terminfo[smkx]} )); then
    printf '%s' ${terminfo[smkx]}
    fi
    if (( ${+terminfo[rmkx]} )); then
    printf '%s' ${terminfo[rmkx]}
    fi

    zle reset-prompt     # コマンド1行ごとに大きな遅延を起こしている
    zle -R
}

zle -N zle-line-init
zle -N zle-line-finish
zle -N zle-keymap-select

vi-modeプラグイン自体も正常に動作しておらず,vimのmodeに応じたインジケータが表示されなかったのでプラグインを.zshrcから外すことで解決としました.
なお oh-my-zsh内で''zle reset-prompt''が使われているのは2013年10月現在ではvi-modeプラグインのみでした.

$ ag reset-prompt $HOME/.oh-my-zsh
plugins/vi-mode/vi-mode.plugin.zsh
13:  # zle reset-prompt

プラグインを追加する際はご注意ください.