ビヘイビアツリー
C++で作る超簡単ビヘイビアツリー
ゲームエンジニア科向け・段階実装ワーク
1. この資料で学ぶこと
この資料では、敵AIをビヘイビアツリーで動かすための最小構造を、C++で段階的に作ります。
最初から完成コードを書き写すのではなく、次の順番で機能を増やします。
Step 1:ノードを1個だけ動かす
Step 2:Sequenceで「条件 → 行動」をつなぐ
Step 3:Selectorで行動を選ぶ
Step 4:攻撃・追跡・巡回を組み合わせる
Step 5:Runningで継続中の行動を表す
この資料のゴールは、次の木をC++で作れるようになることです。
Root
└─ Selector
├─ Sequence
│ ├─ 攻撃範囲内?
│ └─ 攻撃する
│
├─ Sequence
│ ├─ プレイヤーが見える?
│ └─ 追跡する
│
└─ 巡回する
この木は、次の優先順位を表しています。
1. 攻撃できるなら攻撃する
2. 攻撃できないが、プレイヤーが見えるなら追跡する
3. どちらもできないなら巡回する
2. ビヘイビアツリーとは
ビヘイビアツリーは、AIの条件と行動を木構造で並べる仕組みです。
AIが持っている情報
↓
木の一番上から確認する
↓
今できる行動を選ぶ
↓
選ばれた行動を実行する
Stateパターンでは、主に次のことを考えます。
今、何の状態なのか?
ビヘイビアツリーでは、主に次のことを考えます。
今できる行動の中で、どれを優先するのか?
3. ビヘイビアツリーを構成するもの
最小構成では、次の5つを使います。
1. Root
2. Node
3. Condition Node
4. Action Node
5. Composite Node
3.1 Root
Rootは木の入口です。
毎フレーム、Rootから処理を始めます。
ゲームのUpdate
↓
RootのTick
↓
子ノードのTick
今回の最小実装では、Root専用クラスは作りません。
一番上に置いたSelectorをRootとして扱います。
3.2 Node
Nodeは、木を構成する1個の部品です。
プレイヤーが見える?
攻撃範囲内?
追跡する
攻撃する
巡回する
これらをすべてNodeとして作ります。
C++では、すべてのNodeに共通する親クラスを作ります。
class BehaviorNode
{
public:
virtual ~BehaviorNode() = default;
virtual NodeResult Tick(AIContext& context) = 0;
};
Stateパターンの基底Stateクラスと似ています。
違いは、Tick()が実行結果を返すことです。
3.3 Condition Node
Condition Nodeは、条件を調べるノードです。
プレイヤーが見える?
攻撃範囲内?
HPが少ない?
条件が成立したときはSuccessを返します。
条件が成立しないときはFailureを返します。
条件成立 → Success
条件不成立 → Failure
Condition Nodeは、基本的に行動を実行しません。
3.4 Action Node
Action Nodeは、実際の行動を行うノードです。
攻撃する
追跡する
逃げる
巡回する
すぐに終わる行動はSuccessを返します。
まだ続いている行動はRunningを返します。
失敗した行動はFailureを返します。
行動完了 → Success
行動失敗 → Failure
行動継続中 → Running
3.5 Composite Node
Composite Nodeは、複数の子ノードを持つノードです。
今回使うComposite Nodeは2種類です。
Selector
Sequence
Composite Node自身が攻撃や移動をするわけではありません。
子ノードをどの順番で実行するかを決めます。
4. ノードが返す3つの結果
すべてのノードは、次のどれかを返します。
enum class NodeResult
{
Success,
Failure,
Running
};
Success
処理が成功した、または行動が完了したことを表します。
攻撃範囲内だった
攻撃が完了した
目的地に到着した
Failure
条件が成立しなかった、または行動できなかったことを表します。
攻撃範囲外だった
プレイヤーが見えなかった
移動経路が見つからなかった
Running
行動がまだ継続中であることを表します。
追跡中
逃走中
攻撃モーション再生中
Runningは「失敗」ではありません。
まだ終わっていないので、次のフレームも続ける
という意味です。
5. Selectorの構造
Selectorは、上から順番に成功する行動を探します。
Selector
├─ 攻撃を試す
├─ 追跡を試す
└─ 巡回を試す
Selectorのルールは次の通りです。
子がFailure
→ その候補は使えない
→ 次の子を見る
子がSuccess
→ その候補で成功した
→ そこで止まる
子がRunning
→ その候補を実行中
→ そこで止まる
すべてFailure
→ SelectorもFailure
Selectorでは、上に置いた子ほど優先順位が高くなります。
Selector
├─ 逃げる 最優先
├─ 攻撃する
├─ 追跡する
└─ 巡回する 最低優先
6. Sequenceの構造
Sequenceは、子ノードを上から順番に実行します。
全部成功したときだけ、Sequence全体が成功します。
Sequence
├─ 攻撃範囲内?
└─ 攻撃する
これは、次の意味です。
攻撃範囲内なら攻撃する
Sequenceのルールは次の通りです。
子がSuccess
→ 次の子を見る
子がFailure
→ 条件を満たせなかった
→ そこで止まる
子がRunning
→ 行動を実行中
→ そこで止まる
すべてSuccess
→ SequenceもSuccess
7. SelectorとSequenceの違い
覚え方は次の通りです。
Selector
→ どれか1つを選ぶ
Sequence
→ 必要な手順を順番に行う
次の木を見てください。
Selector
├─ Sequence
│ ├─ 攻撃範囲内?
│ └─ 攻撃する
│
├─ Sequence
│ ├─ プレイヤーが見える?
│ └─ 追跡する
│
└─ 巡回する
Selectorは、攻撃・追跡・巡回のどれを使うか選びます。
Sequenceは、条件と行動をセットにします。
8. 木の読み方
次の条件を考えます。
攻撃範囲内ではない
プレイヤーは見えている
木は上から確認します。
Selector
├─ 攻撃Sequence
│ ├─ 攻撃範囲内? → Failure
│ └─ 攻撃する → 実行しない
│
├─ 追跡Sequence
│ ├─ 見えている? → Success
│ └─ 追跡する → Running
│
└─ 巡回する → 実行しない
結果は次の通りです。
追跡が選ばれる
9. 今回のC++構造
今回のコードでは、次のクラスを作ります。
BehaviorNode
├─ CanSeePlayerNode
├─ IsInAttackRangeNode
├─ AttackNode
├─ ChaseNode
├─ PatrolNode
├─ SequenceNode
└─ SelectorNode
敵AIが判断に使う情報は、AIContextにまとめます。
struct AIContext
{
bool canSeePlayer;
bool isInAttackRange;
};
今回は本物のEnemyクラスを使わず、AIContextだけで動作確認します。
実際のゲームに組み込むときは、Enemyの位置、HP、Playerへの距離などを参照します。
10. Step 1:ノードを1個だけ動かす
使用するファイル:
step01_single_node/main.cpp
最初は、巡回ノードだけを作ります。
PatrolNode
└─ 巡回する
Step 1で作るもの
NodeResult
AIContext
BehaviorNode
PatrolNode
基底クラス
class BehaviorNode
{
public:
virtual ~BehaviorNode() = default;
virtual NodeResult Tick(AIContext& context) = 0;
};
すべてのノードはBehaviorNodeを継承します。
巡回ノード
class PatrolNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
std::cout << "巡回する\n";
return NodeResult::Success;
}
};
呼び出し
AIContext context;
PatrolNode patrol;
patrol.Tick(context);
実行結果
巡回する
確認問題
1. Tick()は何を返しているか。
2. PatrolNodeは条件ノードか、行動ノードか。
3. BehaviorNodeを継承する理由は何か。
11. Step 2:Sequenceを作る
使用するファイル:
step02_sequence/main.cpp
次の木を作ります。
Sequence
├─ プレイヤーが見える?
└─ 追跡する
Condition Node
class CanSeePlayerNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
if (context.canSeePlayer)
{
std::cout << "プレイヤーが見える\n";
return NodeResult::Success;
}
std::cout << "プレイヤーが見えない\n";
return NodeResult::Failure;
}
};
Action Node
class ChaseNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
std::cout << "プレイヤーを追跡する\n";
return NodeResult::Success;
}
};
Sequence
class SequenceNode : public BehaviorNode
{
private:
std::vector<BehaviorNode*> children_;
public:
void AddChild(BehaviorNode* child)
{
children_.push_back(child);
}
NodeResult Tick(AIContext& context) override
{
for (BehaviorNode* child : children_)
{
NodeResult result = child->Tick(context);
if (result == NodeResult::Failure)
{
return NodeResult::Failure;
}
if (result == NodeResult::Running)
{
return NodeResult::Running;
}
}
return NodeResult::Success;
}
};
Sequenceは、子がSuccessなら次へ進みます。
FailureまたはRunningなら、その場で止まります。
木の組み立て
CanSeePlayerNode canSeePlayer;
ChaseNode chase;
SequenceNode chaseSequence;
chaseSequence.AddChild(&canSeePlayer);
chaseSequence.AddChild(&chase);
この順番がそのまま木の順番になります。
chaseSequence
├─ canSeePlayer
└─ chase
12. Step 3:Selectorを作る
使用するファイル:
step03_selector/main.cpp
次の木を作ります。
Selector
├─ Sequence
│ ├─ プレイヤーが見える?
│ └─ 追跡する
└─ 巡回する
Selector
class SelectorNode : public BehaviorNode
{
private:
std::vector<BehaviorNode*> children_;
public:
void AddChild(BehaviorNode* child)
{
children_.push_back(child);
}
NodeResult Tick(AIContext& context) override
{
for (BehaviorNode* child : children_)
{
NodeResult result = child->Tick(context);
if (result == NodeResult::Success)
{
return NodeResult::Success;
}
if (result == NodeResult::Running)
{
return NodeResult::Running;
}
}
return NodeResult::Failure;
}
};
Selectorは、子がFailureなら次の候補を試します。
SuccessまたはRunningなら、その場で止まります。
木の組み立て
SelectorNode root;
root.AddChild(&chaseSequence);
root.AddChild(&patrol);
chaseSequenceを先に追加しているため、追跡のほうが巡回より優先されます。
13. Step 4:攻撃・追跡・巡回を組み合わせる
使用するファイル:
step04_complete/main.cpp
完成する木は次の通りです。
Root
└─ Selector
├─ Sequence
│ ├─ 攻撃範囲内?
│ └─ 攻撃する
│
├─ Sequence
│ ├─ プレイヤーが見える?
│ └─ 追跡する
│
└─ 巡回する
攻撃条件ノード
class IsInAttackRangeNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
if (context.isInAttackRange)
{
std::cout << "攻撃範囲内\n";
return NodeResult::Success;
}
std::cout << "攻撃範囲外\n";
return NodeResult::Failure;
}
};
攻撃行動ノード
class AttackNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
std::cout << "攻撃する\n";
return NodeResult::Success;
}
};
木の組み立て
SequenceNode attackSequence;
attackSequence.AddChild(&isInAttackRange);
attackSequence.AddChild(&attack);
SequenceNode chaseSequence;
chaseSequence.AddChild(&canSeePlayer);
chaseSequence.AddChild(&chase);
SelectorNode root;
root.AddChild(&attackSequence);
root.AddChild(&chaseSequence);
root.AddChild(&patrol);
ここで追加順が重要です。
attackSequence
chaseSequence
patrol
この順番が、そのまま行動の優先順位になります。
14. Step 4の動作確認
場面1:プレイヤーが見えない
context.canSeePlayer = false;
context.isInAttackRange = false;
結果:
攻撃範囲外
プレイヤーが見えない
巡回する
場面2:プレイヤーが見える
context.canSeePlayer = true;
context.isInAttackRange = false;
結果:
攻撃範囲外
プレイヤーが見える
追跡する
場面3:攻撃範囲内
context.canSeePlayer = true;
context.isInAttackRange = true;
結果:
攻撃範囲内
攻撃する
攻撃が成功したため、追跡と巡回は実行されません。
15. Step 5:Runningを使う
使用するファイル:
step05_running/main.cpp
これまでの追跡ノードは、呼び出した瞬間にSuccessを返していました。
しかし、実際の追跡は1フレームでは終わりません。
1フレーム目:追跡中
2フレーム目:追跡中
3フレーム目:到着
このような行動にはRunningを使います。
class ChaseNode : public BehaviorNode
{
private:
int progress_;
public:
ChaseNode()
: progress_(0)
{
}
NodeResult Tick(AIContext& context) override
{
++progress_;
if (progress_ < 3)
{
std::cout << "追跡中\n";
return NodeResult::Running;
}
std::cout << "追跡完了\n";
progress_ = 0;
return NodeResult::Success;
}
};
この最小実装では、毎フレームRootから評価し直します。
Root
↓
攻撃できるか再確認
↓
追跡できるか再確認
↓
ChaseNodeを続ける
そのため、追跡中に攻撃範囲へ入った場合、次のフレームでは攻撃を優先できます。
これが、優先順位の高い行動による割り込みです。
16. 毎フレームの呼び出し方
コンソールでは、次のように呼び出しています。
root.Tick(context);
ゲームでは、EnemyのUpdate()などから呼び出します。
void Enemy::Update()
{
root_->Tick(context_);
}
流れは次の通りです。
ゲームの1フレーム
↓
Enemy::Update()
↓
RootのTick()
↓
Selectorが行動を選ぶ
↓
Action Nodeが敵を動かす
17. 今回の実装で省略しているもの
今回の目的は、最小構造を理解することです。
そのため、次の機能はまだ入れていません。
・ノードの自動delete
・スマートポインタ
・ノードのReset
・ノードごとの開始処理と終了処理
・デコレータ
・並列ノード
・クールタイム
・外部ファイルからの木の読み込み
まずは次の構造を理解してください。
BehaviorNode
Selector
Sequence
Condition
Action
Success / Failure / Running
18. ワーク1:木を読もう
次の木を読んで、実行される行動を書きなさい。
Selector
├─ Sequence
│ ├─ HPが少ない?
│ └─ 逃げる
│
├─ Sequence
│ ├─ 攻撃範囲内?
│ └─ 攻撃する
│
└─ 巡回する
条件:
HPは十分にある
攻撃範囲内である
実行される行動:
19. ワーク2:優先順位を変えよう
現在の木では、攻撃が追跡より優先されています。
Selector
├─ 攻撃
├─ 追跡
└─ 巡回
追跡を攻撃より優先するには、C++のどの部分を変更すればよいか考えなさい。
変更する部分:
20. ワーク3:逃走を追加しよう
次の仕様を追加します。
HPが少ないときは、攻撃や追跡より先に逃げる
必要になるノードは次の2個です。
IsLowHpNode
FleeNode
完成する木を書きなさい。
Root
└─ Selector
21. ワーク4:条件ノードを書こう
次の仕様のCondition Nodeを書きなさい。
context.isLowHpがtrueならSuccess
falseならFailure
class IsLowHpNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
}
};
22. ワーク5:行動ノードを書こう
次の仕様のAction Nodeを書きなさい。
「逃げる」と表示してSuccessを返す
class FleeNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
}
};
23. ワーク6:木を組み立てよう
次のノードがすでに作られているものとします。
IsLowHpNode isLowHp;
FleeNode flee;
IsInAttackRangeNode isInAttackRange;
AttackNode attack;
CanSeePlayerNode canSeePlayer;
ChaseNode chase;
PatrolNode patrol;
次の優先順位になるように、SequenceとSelectorを組み立てなさい。
1. HPが少ないなら逃げる
2. 攻撃範囲内なら攻撃する
3. プレイヤーが見えるなら追跡する
4. 何もなければ巡回する
24. ワークの解答例
ワーク1
攻撃する
逃走Sequenceは、HPが少ない?がFailureになるため終了します。
次の攻撃Sequenceが成功します。
ワーク2
AddChild()の追加順を変更します。
root.AddChild(&chaseSequence);
root.AddChild(&attackSequence);
root.AddChild(&patrol);
ただし、この順番では攻撃範囲内でも追跡が先に選ばれる可能性があります。
ワーク3
Root
└─ Selector
├─ Sequence
│ ├─ HPが少ない?
│ └─ 逃げる
│
├─ Sequence
│ ├─ 攻撃範囲内?
│ └─ 攻撃する
│
├─ Sequence
│ ├─ プレイヤーが見える?
│ └─ 追跡する
│
└─ 巡回する
ワーク4
class IsLowHpNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
if (context.isLowHp)
{
return NodeResult::Success;
}
return NodeResult::Failure;
}
};
ワーク5
class FleeNode : public BehaviorNode
{
public:
NodeResult Tick(AIContext& context) override
{
std::cout << "逃げる\n";
return NodeResult::Success;
}
};
ワーク6
SequenceNode fleeSequence;
fleeSequence.AddChild(&isLowHp);
fleeSequence.AddChild(&flee);
SequenceNode attackSequence;
attackSequence.AddChild(&isInAttackRange);
attackSequence.AddChild(&attack);
SequenceNode chaseSequence;
chaseSequence.AddChild(&canSeePlayer);
chaseSequence.AddChild(&chase);
SelectorNode root;
root.AddChild(&fleeSequence);
root.AddChild(&attackSequence);
root.AddChild(&chaseSequence);
root.AddChild(&patrol);
25. 最終まとめ
ビヘイビアツリーは、条件と行動を木構造で組み合わせます。
Condition Node
→ 条件を調べる
Action Node
→ 行動する
Sequence
→ 必要な処理を順番につなぐ
Selector
→ 優先順位順に行動を選ぶ
ノードは、次の結果を返します。
Success
→ 成功・完了
Failure
→ 条件不成立・失敗
Running
→ まだ実行中
C++では、次の構造が中心になります。
BehaviorNode::Tick()
↓
Success / Failure / Running
↓
SelectorとSequenceが次の処理を決める
最初に覚えるべき完成形は次の木です。
Root
└─ Selector
├─ Sequence
│ ├─ 条件
│ └─ 優先度の高い行動
│
├─ Sequence
│ ├─ 条件
│ └─ 次の行動
│
└─ 何もないときの行動
No Comments