Featured image of post 【第4回】行動をコマンドにしよう - PerlとMooでテキストRPG戦闘エンジンを作ろう

【第4回】行動をコマンドにしよう - PerlとMooでテキストRPG戦闘エンジンを作ろう

RPG戦闘エンジン第4回。Commandパターンを導入し、各行動をコマンドオブジェクトとしてカプセル化します。if/else地獄から脱出し、拡張性の高い設計を実現しましょう。

前回、if/elseによる行動分岐が破綻する様子を見ました。機能を追加するたびに分岐が増え、コードが読みにくくなり、テストも困難になっていく。

今回は、Commandパターンを導入してこの問題を解決します。

Commandパターンのコンベアベルト

Commandパターンとは

Commandパターンは、操作をオブジェクトとしてカプセル化するデザインパターンです。GoF(Gang of Four)の23パターンの1つで、以下の特徴を持ちます。

  • 操作(コマンド)をオブジェクトとして表現する
  • コマンドの実行者と実行内容を分離する
  • コマンドを保存、キューイング、取り消しできる

実は、前回まで作ってきたActionクラスは、すでにCommandパターンの基本形になっています。今回はこれをより本格的なCommand設計に発展させます。

Commandロールを定義する

MooではRoleを使って共通のインターフェースを定義できます。すべてのコマンドが持つべきメソッドをCommandロールとして定義しましょう。

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

requires 'execute';

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

has target => (
    is       => 'ro',
    required => 0,
);

requires 'execute'により、このロールを使うクラスは必ずexecuteメソッドを実装する必要があります。actorは行動者、targetは行動の対象です。

各コマンドを実装する

Commandロールを使って各行動を再実装します。

AttackCommand

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package AttackCommand;
use v5.36;
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;
}

with 'Command'でロールを取り込み、executeメソッドを実装しています。

DefendCommand

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

with 'Command';

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

ItemCommand

 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 ItemCommand;
use v5.36;
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 . " 回復!";
}

MagicCommand

 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
package MagicCommand;
use v5.36;
use Moo;

with 'Command';

has spell_name => (
    is      => 'ro',
    default => 'ファイアボール',
);

has damage => (
    is      => 'ro',
    default => 25,
);

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

sub execute($self) {
    my $actor = $self->actor;
    my $target = $self->target;
    
    if ($actor->mp < $self->mp_cost) {
        say $actor->name . "はMPが足りない!";
        return;
    }
    
    $actor->mp($actor->mp - $self->mp_cost);
    my $actual = $target->take_damage($self->damage);
    
    say $actor->name . "は" . $self->spell_name . "を唱えた! " . $target->name . "に " . $actual . " のダメージ!";
}

コマンドの実行

コマンドを実行する側のコードはシンプルになります。

1
2
3
4
5
6
7
8
# コマンドを作成
my $attack = AttackCommand->new(
    actor  => $hero,
    target => $slime,
);

# コマンドを実行
$attack->execute();

コマンドオブジェクトを作成し、executeを呼ぶだけです。どんな行動でも同じインターフェースで実行できます。

CommandInvoker: コマンドの実行者

コマンドを実行する責務を持つクラスを作ります。これにより、コマンドの実行前後に共通の処理を挟めるようになります。

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

has history => (
    is      => 'ro',
    default => sub { [] },
);

sub invoke($self, $command) {
    push $self->history->@*, $command;
    $command->execute();
}

sub get_history($self) {
    return $self->history->@*;
}

invokeメソッドでコマンドを実行し、履歴に記録します。この履歴があれば、後で「何が起きたか」を振り返ったり、リプレイ機能を実装したりできます。

完成コード

  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
#!/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 mp => (
        is      => 'rw',
        default => 50,
    );
    
    has attack_power => (
        is      => 'ro',
        default => 10,
    );
    
    has is_defending => (
        is      => 'rw',
        default => 0,
    );
    
    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;
    }
}

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 MagicCommand {
    use Moo;
    with 'Command';
    
    has spell_name => (
        is      => 'ro',
        default => 'ファイアボール',
    );
    
    has damage => (
        is      => 'ro',
        default => 25,
    );
    
    has mp_cost => (
        is      => 'ro',
        default => 10,
    );
    
    sub execute($self) {
        my $actor = $self->actor;
        my $target = $self->target;
        
        if ($actor->mp < $self->mp_cost) {
            say $actor->name . "はMPが足りない!";
            return;
        }
        
        $actor->mp($actor->mp - $self->mp_cost);
        my $actual = $target->take_damage($self->damage);
        
        say $actor->name . "は" . $self->spell_name . "を唱えた! " . $target->name . "に " . $actual . " のダメージ!";
    }
}

package CommandInvoker {
    use Moo;
    
    has history => (
        is      => 'ro',
        default => sub { [] },
    );
    
    sub invoke($self, $command) {
        push $self->history->@*, $command;
        $command->execute();
    }
}

# メイン処理
my $hero = Character->new(
    name         => '勇者',
    hp           => 100,
    max_hp       => 100,
    mp           => 50,
    attack_power => 15,
);

my $slime = Character->new(
    name         => 'スライム',
    hp           => 50,
    max_hp       => 50,
    attack_power => 10,
);

my $invoker = CommandInvoker->new();

say "=== Commandパターンによる戦闘デモ ===";
say "勇者 HP: " . $hero->hp . " MP: " . $hero->mp;
say "スライム HP: " . $slime->hp;
say "";

# 各種コマンドを実行
$invoker->invoke(AttackCommand->new(actor => $hero, target => $slime));
$invoker->invoke(DefendCommand->new(actor => $slime));
$invoker->invoke(MagicCommand->new(actor => $hero, target => $slime, spell_name => 'ファイアボール', damage => 25, mp_cost => 10));

say "";
say "=== 戦闘履歴 ===";
say "実行されたコマンド数: " . scalar($invoker->history->@*);

say "";
say "最終状態:";
say "勇者 HP: " . $hero->hp . " MP: " . $hero->mp;
say "スライム HP: " . $slime->hp;

実行結果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
=== Commandパターンによる戦闘デモ ===
勇者 HP: 100 MP: 50
スライム HP: 50

勇者の攻撃! スライムに 15 のダメージ!
スライムは防御の構えをとった!
勇者はファイアボールを唱えた! スライムに 12 のダメージ!(防御で軽減)

=== 戦闘履歴 ===
実行されたコマンド数: 3

最終状態:
勇者 HP: 100 MP: 40
スライム HP: 23

Commandパターンにより、行動がオブジェクトとして管理され、履歴も記録できるようになりました。

if/elseとの比較

観点if/elseによる分岐Commandパターン
新しい行動の追加分岐を追加新しいクラスを追加
既存コードの変更必要不要
テストのしやすさ全分岐をテストクラスごとにテスト
履歴・取り消し困難容易

今回のポイント

  • Commandパターンは操作をオブジェクトとしてカプセル化する
  • Moo::Roleで共通インターフェースを定義できる
  • CommandInvokerでコマンドの実行と履歴管理を行う
  • 新しい行動はクラスを追加するだけで実現できる(OCP準拠)

次回は、戦闘のフェーズ(開始→プレイヤーターン→敵ターン→終了)を管理するStateパターンを導入します。


前回:

次回:

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