Featured image of post 【第3回】if/elseで行動を振り分けると破綻 - PerlとMooでテキストRPG戦闘エンジンを作ろう

【第3回】if/elseで行動を振り分けると破綻 - PerlとMooでテキストRPG戦闘エンジンを作ろう

RPG戦闘エンジン第3回。行動選択にif/elseを使うと、条件分岐が爆発的に増えて破綻します。この問題を実際のコードで体験し、なぜリファクタリングが必要なのかを理解します。

前回は、攻撃・防御・アイテム使用をそれぞれ独立したアクションクラスとして実装しました。クラスは整理されましたが、実際の戦闘では「プレイヤーがどの行動を選んだか」に応じて処理を分岐させる必要があります。

今回は、この分岐処理を素直にif/elseで実装してみましょう。そしてその先に待っている「破綻」を体験します。

if/elseの迷路

行動選択の実装

プレイヤーの入力を受け取り、対応するアクションを実行する関数を作ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
sub get_player_action($player, $enemy, $choice) {
    if ($choice eq 'attack') {
        return AttackAction->new(actor => $player);
    }
    elsif ($choice eq 'defend') {
        return DefendAction->new(actor => $player);
    }
    elsif ($choice eq 'item') {
        return ItemAction->new(actor => $player);
    }
    else {
        die "Unknown action: $choice";
    }
}

シンプルですね。$choiceに応じて適切なアクションオブジェクトを返しています。

魔法を追加してみる

RPGには魔法も必要です。ファイアボールを追加してみましょう。

 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
package MagicAction {
    use Moo;
    extends 'Action';
    
    has spell_name => (
        is      => 'ro',
        default => 'ファイアボール',
    );
    
    has damage => (
        is      => 'ro',
        default => 25,
    );
    
    has mp_cost => (
        is      => 'ro',
        default => 10,
    );
    
    sub execute($self, $target) {
        my $actor = $self->actor;
        
        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 . " のダメージ!";
    }
}

Characterクラスにもmp属性を追加する必要があります。

1
2
3
4
has mp => (
    is      => 'rw',
    default => 50,
);

前回導入したmax_hpなどの属性はそのまま残し、mpだけを増設すればOKです。

そして行動選択関数にも条件を追加します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
sub get_player_action($player, $enemy, $choice) {
    if ($choice eq 'attack') {
        return AttackAction->new(actor => $player);
    }
    elsif ($choice eq 'defend') {
        return DefendAction->new(actor => $player);
    }
    elsif ($choice eq 'item') {
        return ItemAction->new(actor => $player);
    }
    elsif ($choice eq 'magic') {
        return MagicAction->new(actor => $player);
    }
    else {
        die "Unknown action: $choice";
    }
}

まだ大丈夫そうです。では、さらに機能を追加していきましょう。

逃走を追加

戦闘から逃げる機能も欲しいですね。

1
2
3
elsif ($choice eq 'escape') {
    return EscapeAction->new(actor => $player);
}

アクション本体は最低限でも用意しておきます。

1
2
3
4
5
6
7
8
package EscapeAction {
    use Moo;
    extends 'Action';
    sub execute($self, $target) {
        say $self->actor->name . "は逃げ出した……!";
        # ここでは成功・失敗判定を省略し、if/elseの肥大化だけに注目
    }
}

複数の魔法を追加

ファイアボールだけでなく、ブリザードやサンダーも使いたい。

1
2
3
4
5
6
7
8
9
elsif ($choice eq 'fire') {
    return MagicAction->new(actor => $player, spell_name => 'ファイアボール', damage => 25, mp_cost => 10);
}
elsif ($choice eq 'ice') {
    return MagicAction->new(actor => $player, spell_name => 'ブリザード', damage => 30, mp_cost => 15);
}
elsif ($choice eq 'thunder') {
    return MagicAction->new(actor => $player, spell_name => 'サンダー', damage => 35, mp_cost => 20);
}

複数のアイテムを追加

ポーションだけでなく、ハイポーションやエリクサーも。

1
2
3
4
5
6
7
8
9
elsif ($choice eq 'potion') {
    return ItemAction->new(actor => $player, item_name => 'ポーション', heal_amount => 30);
}
elsif ($choice eq 'hi_potion') {
    return ItemAction->new(actor => $player, item_name => 'ハイポーション', heal_amount => 100);
}
elsif ($choice eq 'elixir') {
    return ItemAction->new(actor => $player, item_name => 'エリクサー', heal_amount => 999);
}

破綻したコード

ここまで追加した結果、行動選択関数は以下のようになりました。

 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
sub get_player_action($player, $enemy, $choice) {
    if ($choice eq 'attack') {
        return AttackAction->new(actor => $player);
    }
    elsif ($choice eq 'defend') {
        return DefendAction->new(actor => $player);
    }
    elsif ($choice eq 'item') {
        return ItemAction->new(actor => $player);
    }
    elsif ($choice eq 'magic') {
        return MagicAction->new(actor => $player);
    }
    elsif ($choice eq 'escape') {
        return EscapeAction->new(actor => $player);
    }
    elsif ($choice eq 'fire') {
        return MagicAction->new(actor => $player, spell_name => 'ファイアボール', damage => 25, mp_cost => 10);
    }
    elsif ($choice eq 'ice') {
        return MagicAction->new(actor => $player, spell_name => 'ブリザード', damage => 30, mp_cost => 15);
    }
    elsif ($choice eq 'thunder') {
        return MagicAction->new(actor => $player, spell_name => 'サンダー', damage => 35, mp_cost => 20);
    }
    elsif ($choice eq 'potion') {
        return ItemAction->new(actor => $player, item_name => 'ポーション', heal_amount => 30);
    }
    elsif ($choice eq 'hi_potion') {
        return ItemAction->new(actor => $player, item_name => 'ハイポーション', heal_amount => 100);
    }
    elsif ($choice eq 'elixir') {
        return ItemAction->new(actor => $player, item_name => 'エリクサー', heal_amount => 999);
    }
    else {
        die "Unknown action: $choice";
    }
}

問題点が見えてきました。

何が問題なのか

単一責任原則(SRP)違反

この関数は「行動の種類を判定する」「アクションオブジェクトを生成する」「パラメータを設定する」という複数の責務を持っています。新しい行動を追加するたびに、この関数を修正しなければなりません。

開放閉鎖原則(OCP)違反

機能を追加するたびに既存のコードを変更しています。理想的には、既存コードを変更せずに新機能を追加できるべきです。

switchやif/elseの悪臭

if/elseの連鎖は、オブジェクト指向設計における代表的な「コードの臭い(code smell)」です。条件分岐が増えるほど、コードは読みにくく、変更しにくくなります。

テストの困難さ

すべての分岐を1つの関数でテストする必要があります。分岐が増えるほどテストケースも増え、テストコードが肥大化して維持が困難になります。

問題の整理

現状の問題をまとめると以下のようになります。

問題影響
分岐の肥大化可読性の低下
SRP違反変更理由が複数ある
OCP違反拡張のたびに既存コードを変更
テスト困難テストケースの爆発

解決の方向性

この問題を解決するには、「行動の種類」と「行動の実行」を分離する必要があります。具体的には以下の方針が考えられます。

  • 行動をオブジェクトとしてカプセル化する
  • 行動の生成を一箇所にまとめない
  • 新しい行動を追加しても既存コードを変更しない

次回は、Commandパターンを導入してこの問題を解決します。行動をコマンドオブジェクトとして扱うことで、if/elseの連鎖から解放されるのです。

今回のポイント

  • if/elseによる行動分岐は、機能追加とともに破綻する
  • 単一責任原則(SRP): 1つの関数は1つの責務だけを持つべき
  • 開放閉鎖原則(OCP): 拡張に対して開き、変更に対して閉じるべき
  • 条件分岐の連鎖は「コードの臭い」であり、リファクタリングのサイン

次回、Commandパターンの導入でこれらの問題を一気に解決しましょう。


前回:

次回:

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