Featured image of post 【第6回】敵のAIを切り替えたい - PerlとMooでテキストRPG戦闘エンジンを作ろう

【第6回】敵のAIを切り替えたい - PerlとMooでテキストRPG戦闘エンジンを作ろう

RPG戦闘エンジン第6回。Strategyパターンを導入し、敵のAIを攻撃的・防御的・ランダムに切り替えられるようにします。アルゴリズムの交換可能性を学びましょう。

前回、Stateパターンで戦闘フェーズを管理できるようになりました。プレイヤーターンと敵ターンが交互に進行し、戦闘開始から終了まで状態遷移で制御しています。

しかし、現在の敵はとても単純です。毎ターン攻撃するだけ。本物のRPGでは、もっと賢い敵がいますよね。

  • 攻撃的な敵: とにかく攻撃し続ける
  • 防御的な敵: HPが減ると回復を優先する
  • ランダムな敵: 行動が読めない

今回は、Strategyパターンを使って敵のAIを切り替えられるようにしましょう。

Strategyパターンで切り替え可能なAI

Strategyパターンとは

Strategyパターンは、アルゴリズムをカプセル化して交換可能にするデザインパターンです。同じ問題に対して複数の解法がある場合、それぞれをStrategyとして定義し、状況に応じて切り替えます。

例えば、ソートアルゴリズムを考えてみてください。

  • クイックソート: 平均的に高速
  • マージソート: 安定ソート
  • バブルソート: 実装が簡単

どれも「配列をソートする」という同じ目的を持ちますが、アルゴリズムが異なります。Strategyパターンでは、これらを交換可能なオブジェクトとして扱います。

AIStrategyロールを定義する

すべてのAI戦略が実装すべきインターフェースを定義します。

1
2
3
4
5
package AIStrategy;
use v5.36;
use Moo::Role;

requires 'decide_action';

decide_actionメソッドは、現在の状況を考慮して次の行動(コマンド)を決定します。

攻撃的AIを実装する

とにかく攻撃し続けるAIを作ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package AggressiveAI;
use v5.36;
use Moo;

with 'AIStrategy';

sub decide_action($self, $actor, $target) {
    return AttackCommand->new(
        actor  => $actor,
        target => $target,
    );
}

シンプルですね。どんな状況でも攻撃コマンドを返します。

防御的AIを実装する

HPが半分以下になったら回復を優先するAIを作ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package DefensiveAI;
use v5.36;
use Moo;

with 'AIStrategy';

sub decide_action($self, $actor, $target) {
    # HPが半分以下なら回復
    if ($actor->hp <= $actor->max_hp / 2) {
        return ItemCommand->new(
            actor       => $actor,
            item_name   => 'ポーション',
            heal_amount => 20,
        );
    }
    
    # そうでなければ攻撃
    return AttackCommand->new(
        actor  => $actor,
        target => $target,
    );
}

HPの残量に応じて行動を変えています。これだけで、敵がより賢く見えます。

ランダムAIを実装する

行動をランダムに選ぶAIを作ります。次に何をするか分からない、不気味な敵です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package RandomAI;
use v5.36;
use Moo;

with 'AIStrategy';

sub decide_action($self, $actor, $target) {
    my @actions = (
        sub { AttackCommand->new(actor => $actor, target => $target) },
        sub { DefendCommand->new(actor => $actor) },
        sub { ItemCommand->new(actor => $actor, item_name => 'ポーション', heal_amount => 10) },
    );
    
    my $chosen = $actions[int(rand(@actions))];
    return $chosen->();
}

攻撃、防御、回復の中からランダムに選びます。予測不能な動きが、プレイヤーを翻弄します。

CharacterにAIを持たせる

キャラクターにAI戦略を持たせます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package Character;
use v5.36;
use Moo;

has name => (
    is       => 'ro',
    required => 1,
);

has hp => (
    is      => 'rw',
    default => 100,
);

has max_hp => (
    is      => 'ro',
    default => 100,
);

has attack_power => (
    is      => 'ro',
    default => 10,
);

has is_defending => (
    is      => 'rw',
    default => 0,
);

has ai_strategy => (
    is      => 'rw',
    default => sub { undef },
);

sub is_alive($self) {
    return $self->hp > 0;
}

sub take_damage($self, $damage) {
    my $actual_damage = $self->is_defending ? int($damage / 2) : $damage;
    my $new_hp = $self->hp - $actual_damage;
    $self->hp($new_hp < 0 ? 0 : $new_hp);
    $self->is_defending(0);
    return $actual_damage;
}

sub decide_action($self, $target) {
    die "No AI strategy set" unless $self->ai_strategy;
    return $self->ai_strategy->decide_action($self, $target);
}

ai_strategy属性とdecide_actionメソッドを追加しました。

EnemyTurnStateの修正

敵ターンでAI戦略を使うように修正します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package EnemyTurnState;
use v5.36;
use Moo;

with 'BattleState';

sub enter($self, $context) {
    say "";
    say "--- " . $context->enemy->name . "のターン ---";
}

sub execute($self, $context) {
    my $command = $context->enemy->decide_action($context->player);
    $command->execute();
    
    unless ($context->player->is_alive) {
        $context->change_state(BattleEndState->new(winner => 'enemy'));
        return;
    }
    
    $context->change_state(PlayerTurnState->new());
}

sub exit($self, $context) {}

直接AttackCommandを作っていた部分を、decide_actionに置き換えました。

AIの動的な切り替え

Strategyパターンの強みは、実行時にアルゴリズムを切り替えられることです。例えば、HPが25%以下になったら攻撃的に変わる「暴走モード」を実装できます。

1
2
3
4
5
# 敵の状態に応じてAIを切り替える
if ($enemy->hp <= $enemy->max_hp * 0.25) {
    say $enemy->name . "は暴走し始めた!";
    $enemy->ai_strategy(AggressiveAI->new());
}

完成コード

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
#!/usr/bin/env perl
use v5.36;

package Command {
    use Moo::Role;
    requires 'execute';
    
    has actor => (
        is       => 'ro',
        required => 1,
    );
    
    has target => (
        is       => 'ro',
        required => 0,
    );
}

package Character {
    use Moo;
    
    has name => (
        is       => 'ro',
        required => 1,
    );
    
    has hp => (
        is      => 'rw',
        default => 100,
    );
    
    has max_hp => (
        is      => 'ro',
        default => 100,
    );
    
    has attack_power => (
        is      => 'ro',
        default => 10,
    );
    
    has is_defending => (
        is      => 'rw',
        default => 0,
    );
    
    has ai_strategy => (
        is      => 'rw',
        default => sub { undef },
    );
    
    sub is_alive($self) {
        return $self->hp > 0;
    }
    
    sub take_damage($self, $damage) {
        my $actual_damage = $self->is_defending ? int($damage / 2) : $damage;
        my $new_hp = $self->hp - $actual_damage;
        $self->hp($new_hp < 0 ? 0 : $new_hp);
        $self->is_defending(0);
        return $actual_damage;
    }
    
    sub decide_action($self, $target) {
        die "No AI strategy set" unless $self->ai_strategy;
        return $self->ai_strategy->decide_action($self, $target);
    }
}

package AttackCommand {
    use Moo;
    with 'Command';
    
    sub execute($self) {
        my $actor = $self->actor;
        my $target = $self->target;
        my $damage = $actor->attack_power;
        my $actual = $target->take_damage($damage);
        
        my $msg = $actor->name . "の攻撃! " . $target->name . "に " . $actual . " のダメージ!";
        $msg .= "(防御で軽減)" if $actual < $damage;
        say $msg;
    }
}

package DefendCommand {
    use Moo;
    with 'Command';
    
    sub execute($self) {
        my $actor = $self->actor;
        $actor->is_defending(1);
        say $actor->name . "は防御の構えをとった!";
    }
}

package ItemCommand {
    use Moo;
    with 'Command';
    
    has item_name => (
        is      => 'ro',
        default => 'ポーション',
    );
    
    has heal_amount => (
        is      => 'ro',
        default => 30,
    );
    
    sub execute($self) {
        my $actor = $self->actor;
        my $max_hp = $actor->max_hp;
        my $new_hp = $actor->hp + $self->heal_amount;
        $actor->hp($new_hp > $max_hp ? $max_hp : $new_hp);
        
        say $actor->name . "は" . $self->item_name . "を使った! HPが " . $self->heal_amount . " 回復!";
    }
}

package AIStrategy {
    use Moo::Role;
    requires 'decide_action';
}

package AggressiveAI {
    use Moo;
    with 'AIStrategy';
    
    sub decide_action($self, $actor, $target) {
        return AttackCommand->new(
            actor  => $actor,
            target => $target,
        );
    }
}

package DefensiveAI {
    use Moo;
    with 'AIStrategy';
    
    sub decide_action($self, $actor, $target) {
        if ($actor->hp <= $actor->max_hp / 2) {
            return ItemCommand->new(
                actor       => $actor,
                item_name   => 'ポーション',
                heal_amount => 20,
            );
        }
        return AttackCommand->new(
            actor  => $actor,
            target => $target,
        );
    }
}

package RandomAI {
    use Moo;
    with 'AIStrategy';
    
    sub decide_action($self, $actor, $target) {
        my @actions = (
            sub { AttackCommand->new(actor => $actor, target => $target) },
            sub { DefendCommand->new(actor => $actor) },
            sub { ItemCommand->new(actor => $actor, item_name => 'ポーション', heal_amount => 10) },
        );
        
        my $chosen = $actions[int(rand(@actions))];
        return $chosen->();
    }
}

# デモ: 各AIの動作確認
say "=== AI戦略デモ ===";
say "";

my $hero = Character->new(
    name         => '勇者',
    hp           => 100,
    max_hp       => 100,
    attack_power => 15,
);

# 攻撃的AI
say "【攻撃的AI】";
my $goblin = Character->new(
    name         => 'ゴブリン',
    hp           => 50,
    max_hp       => 50,
    attack_power => 8,
    ai_strategy  => AggressiveAI->new(),
);
$goblin->decide_action($hero)->execute();

say "";

# 防御的AI(HPが半分以下)
say "【防御的AI(HP低下時)】";
my $orc = Character->new(
    name         => 'オーク',
    hp           => 20,  # 半分以下
    max_hp       => 50,
    attack_power => 12,
    ai_strategy  => DefensiveAI->new(),
);
$orc->decide_action($hero)->execute();

say "";

# ランダムAI
say "【ランダムAI(3回実行)】";
my $slime = Character->new(
    name         => 'スライム',
    hp           => 30,
    max_hp       => 30,
    attack_power => 5,
    ai_strategy  => RandomAI->new(),
);
for (1..3) {
    $slime->decide_action($hero)->execute();
}

実行結果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
=== AI戦略デモ ===

【攻撃的AI】
ゴブリンの攻撃! 勇者に 8 のダメージ!

【防御的AI(HP低下時)】
オークはポーションを使った! HPが 20 回復!

【ランダムAI(3回実行)】
スライムの攻撃! 勇者に 5 のダメージ!
スライムは防御の構えをとった!
スライムはポーションを使った! HPが 10 回復!

各AIが異なる戦略で行動していることが確認できます。

今回のポイント

  • Strategyパターンはアルゴリズムをカプセル化して交換可能にする
  • AIStrategyロールで共通インターフェースを定義
  • AggressiveAI、DefensiveAI、RandomAIを実装
  • 実行時にAI戦略を切り替えられる
  • 新しいAI戦略は新クラスを追加するだけで実現(OCP準拠)

次回は、ダメージを受けたときや戦闘が終了したときに通知を送るObserverパターンを導入します。


前回:

次回:

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。