Featured image of post 【第9回】完成!テキストRPG戦闘エンジン - PerlとMooでテキストRPG戦闘エンジンを作ろう

【第9回】完成!テキストRPG戦闘エンジン - PerlとMooでテキストRPG戦闘エンジンを作ろう

RPG戦闘エンジン第9回。これまで作成した全パターンを統合し、プレイヤーがコマンドを入力して敵と戦う対話型のREPLバトルシステムを完成させます。

いよいよ最終段階です。これまで作成してきた4つのデザインパターンを統合し、プレイヤーが実際に操作できる対話型バトルシステムを完成させましょう。

完成したRPGバトルシステム

完成イメージ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
=== 戦闘開始! ===
オークが現れた!

勇者 HP:100 MP:50  vs  オーク HP:80

あなたのターン。行動を選んでください。
1: 攻撃  2: 防御  3: 魔法  4: アイテム
> 1

勇者の攻撃! オークに 20 のダメージ!

--- オークのターン ---
オークの攻撃! 勇者に 12 のダメージ!

プレイヤー入力の処理

PlayerTurnStateを修正し、プレイヤーからの入力を受け付けます。

 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
package PlayerTurnState {
    use Moo;
    with 'BattleState';
    
    sub enter($self, $ctx) {
        say "";
        say "あなたのターン。行動を選んでください。";
        say "1: 攻撃  2: 防御  3: 魔法  4: アイテム";
    }
    
    sub execute($self, $ctx) {
        print "> ";
        my $choice = <STDIN>;
        chomp $choice;
        
        my $command = $self->create_command($choice, $ctx);
        unless ($command) {
            say "無効な選択です。";
            return;
        }
        
        $command->execute();
        
        unless ($ctx->enemy->is_alive) {
            $ctx->change_state(BattleEndState->new(winner => 'player'));
            return;
        }
        
        $ctx->change_state(EnemyTurnState->new());
    }
    
    sub create_command($self, $choice, $ctx) {
        my $p = $ctx->player;
        my $e = $ctx->enemy;
        my $b = $ctx;
        
        return AttackCommand->new(actor => $p, target => $e, battle => $b) if $choice eq '1';
        return DefendCommand->new(actor => $p, battle => $b) if $choice eq '2';
        return MagicCommand->new(actor => $p, target => $e, battle => $b) if $choice eq '3';
        return ItemCommand->new(actor => $p, battle => $b) if $choice eq '4';
        return undef;
    }
    
    sub exit($self, $ctx) {}
}

ステータス表示の追加

戦闘中のステータスを分かりやすく表示します。

1
2
3
4
5
6
7
8
9
sub show_status($ctx) {
    my $p = $ctx->player;
    my $e = $ctx->enemy;
    say "";
    say sprintf("%s HP:%d/%d MP:%d  vs  %s HP:%d/%d",
        $p->name, $p->hp, $p->max_hp, $p->mp,
        $e->name, $e->hp, $e->max_hp
    );
}

完成コード

  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
#!/usr/bin/env perl
use v5.36;

# === ロール定義 ===
package Subject {
    use Moo::Role;
    has observers => (is => 'ro', default => sub { [] });
    sub attach($self, $o) { push $self->observers->@*, $o }
    sub notify($self, $ev, $d = {}) { $_->update($ev, $d) for $self->observers->@* }
}

package Observer { use Moo::Role; requires 'update' }
package Command {
    use Moo::Role;
    requires 'execute';
    has actor  => (is => 'ro', required => 1);
    has target => (is => 'ro');
    has battle => (is => 'ro');
}
package BattleState { use Moo::Role; requires 'enter', 'execute', 'exit' }
package AIStrategy { use Moo::Role; requires 'decide_action' }

# === キャラクター ===
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 max_mp       => (is => 'ro', default => 50);
    has attack_power => (is => 'ro', default => 10);
    has is_defending => (is => 'rw', default => 0);
    has ai_strategy  => (is => 'rw');
    
    sub is_alive($self) { $self->hp > 0 }
    
    sub take_damage($self, $dmg, $battle = undef) {
        my $actual = $self->is_defending ? int($dmg / 2) : $dmg;
        my $new = $self->hp - $actual;
        $self->hp($new < 0 ? 0 : $new);
        $self->is_defending(0);
        $battle->notify('damage', { target => $self, amount => $actual }) if $battle;
        return $actual;
    }
}

# === コマンド ===
package AttackCommand {
    use Moo; with 'Command';
    sub execute($self) {
        my ($a, $t) = ($self->actor, $self->target);
        my $d = $t->take_damage($a->attack_power, $self->battle);
        my $m = $a->name . "の攻撃! " . $t->name . "に " . $d . " のダメージ!";
        $m .= "(軽減)" if $d < $a->attack_power;
        say $m;
    }
}

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

package MagicCommand {
    use Moo; with 'Command';
    has damage  => (is => 'ro', default => 30);
    has mp_cost => (is => 'ro', default => 15);
    
    sub execute($self) {
        my ($a, $t) = ($self->actor, $self->target);
        if ($a->mp < $self->mp_cost) {
            say $a->name . "はMPが足りない!";
            return;
        }
        $a->mp($a->mp - $self->mp_cost);
        my $d = $t->take_damage($self->damage, $self->battle);
        say $a->name . "はファイアボールを唱えた! " . $t->name . "に " . $d . " のダメージ!";
    }
}

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

# === AI ===
package DefensiveAI {
    use Moo; with 'AIStrategy';
    sub decide_action($self, $a, $t, $b) {
        return ItemCommand->new(actor => $a, battle => $b) if $a->hp <= $a->max_hp / 2;
        return AttackCommand->new(actor => $a, target => $t, battle => $b);
    }
}

# === Observer ===
package DamageEffect {
    use Moo; with 'Observer';
    sub update($self, $ev, $d) {
        return unless $ev eq 'damage';
        my $t = $d->{target};
        my $r = $t->hp / $t->max_hp;
        say "  ★★★ " . $t->name . "は倒れた!" if $r <= 0;
        say "  ★ " . $t->name . "は瀕死!" if $r > 0 && $r <= 0.25;
    }
}

package BattleReward {
    use Moo; with 'Observer';
    sub update($self, $ev, $d) {
        return unless $ev eq 'battle_end' && $d->{winner} eq 'player';
        say "\n " . (20 + int rand 40) . " EXP獲得! ";
    }
}

# === 戦闘状態 ===
package BattleContext {
    use Moo; with 'Subject';
    has player        => (is => 'ro', required => 1);
    has enemy         => (is => 'ro', required => 1);
    has current_state => (is => 'rw');
    has is_finished   => (is => 'rw', default => 0);
    
    sub change_state($self, $s) {
        $self->current_state->exit($self) if $self->current_state;
        $self->current_state($s);
        $s->enter($self);
    }
    sub update($self) { $self->current_state->execute($self) unless $self->is_finished }
    sub show_status($self) {
        my ($p, $e) = ($self->player, $self->enemy);
        say sprintf("\n%s HP:%d/%d MP:%d  vs  %s HP:%d/%d",
            $p->name, $p->hp, $p->max_hp, $p->mp, $e->name, $e->hp, $e->max_hp);
    }
}

package BattleStartState {
    use Moo; with 'BattleState';
    sub enter($self, $c) { say "=== 戦闘開始! ===\n" . $c->enemy->name . "が現れた!" }
    sub execute($self, $c) { $c->show_status; $c->change_state(PlayerTurnState->new()) }
    sub exit($self, $c) {}
}

package PlayerTurnState {
    use Moo; with 'BattleState';
    sub enter($self, $c) { say "\n行動を選択: 1:攻撃 2:防御 3:魔法 4:アイテム" }
    sub execute($self, $c) {
        print "> ";
        chomp(my $ch = <STDIN>);
        my $cmd = $self->_cmd($ch, $c);
        unless ($cmd) { say "無効"; return }
        $cmd->execute();
        return $c->change_state(BattleEndState->new(winner => 'player')) unless $c->enemy->is_alive;
        $c->change_state(EnemyTurnState->new());
    }
    sub _cmd($self, $ch, $c) {
        my ($p, $e, $b) = ($c->player, $c->enemy, $c);
        return AttackCommand->new(actor => $p, target => $e, battle => $b) if $ch eq '1';
        return DefendCommand->new(actor => $p, battle => $b) if $ch eq '2';
        return MagicCommand->new(actor => $p, target => $e, battle => $b) if $ch eq '3';
        return ItemCommand->new(actor => $p, battle => $b) if $ch eq '4';
        undef;
    }
    sub exit($self, $c) {}
}

package EnemyTurnState {
    use Moo; with 'BattleState';
    sub enter($self, $c) { say "\n--- " . $c->enemy->name . "のターン ---" }
    sub execute($self, $c) {
        my $cmd = $c->enemy->ai_strategy->decide_action($c->enemy, $c->player, $c);
        $cmd->execute();
        return $c->change_state(BattleEndState->new(winner => 'enemy')) unless $c->player->is_alive;
        $c->show_status;
        $c->change_state(PlayerTurnState->new());
    }
    sub exit($self, $c) {}
}

package BattleEndState {
    use Moo; with 'BattleState';
    has winner => (is => 'ro', required => 1);
    sub enter($self, $c) { say "\n=== 戦闘終了! ===" }
    sub execute($self, $c) {
        say $self->winner eq 'player' ? $c->enemy->name . "を倒した!" : $c->player->name . "は倒れた...";
        $c->notify('battle_end', { winner => $self->winner });
        $c->is_finished(1);
    }
    sub exit($self, $c) {}
}

# === メイン ===
my $hero = Character->new(name => '勇者', hp => 100, max_hp => 100, mp => 50, attack_power => 20);
my $orc = Character->new(name => 'オーク', hp => 80, max_hp => 80, attack_power => 15, ai_strategy => DefensiveAI->new());

my $battle = BattleContext->new(player => $hero, enemy => $orc);
$battle->attach(DamageEffect->new());
$battle->attach(BattleReward->new());

$battle->change_state(BattleStartState->new());
$battle->update() until $battle->is_finished;

今回のポイント

  • STDINからプレイヤー入力を受け付けるREPLを実装
  • 4つのパターンすべてが連携して動作する完成版
  • ステータス表示、行動選択、AI応答、イベント通知が統合

次回(最終回)では、今回使用した4つのデザインパターンの組み合わせ方を振り返り、応用例を紹介します。


前回:

次回:

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