Featured image of post 第2回-実績システムも追加したい! - Perlでローグライク通知システムを作ろう

第2回-実績システムも追加したい! - Perlでローグライク通知システムを作ろう

実績システムを追加したい!ログと実績の両方に通知するコードが複雑化。if/elseの増殖問題を体感し、設計改善の必要性を学びます。

@nqounetです。

「Perlでローグライク通知システムを作ろう」シリーズの第2回です。前回はログ出力の仕組みを作りました。今回は「実績システム」を追加したくなったとき、どんな問題が起きるかを見ていきます。

前回の振り返り

前回は、プレイヤーが敵を倒したらログに記録する仕組みを作りました。

実績システムを追加したい

ローグライクゲームに、実績システムを追加してみましょう。

  • 初めて敵を倒した → 「はじめての勝利」実績を解除
  • 10体の敵を倒した → 「ハンター」実績を解除
  • レベル5に到達した → 「成長」実績を解除

これらの実績を、既存のコードに追加してみます。

素朴な実装:if/elseで条件分岐

まずは素朴に、各メソッド内で実績のチェックと解除を行ってみましょう。

  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
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo(cpanmでインストール)

use v5.36;

package GameEvent {
    use Moo;
    use v5.36;

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

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

    has 'timestamp' => (
        is      => 'ro',
        default => sub { time() },
    );
}

package Game {
    use Moo;
    use v5.36;

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

    # 倒した敵の数
    has 'defeated_count' => (
        is      => 'rw',
        default => 0,
    );

    # 解除した実績のリスト
    has 'achievements' => (
        is      => 'ro',
        default => sub { [] },
    );

    sub defeat_enemy ($self, $enemy_name) {
        my $event = GameEvent->new(
            type    => 'enemy_defeated',
            message => "${enemy_name}を倒した!",
        );

        # ログに出力
        say "[LOG] " . $self->player_name . ": " . $event->message;

        # 実績チェック
        my $count = $self->defeated_count + 1;
        $self->defeated_count($count);

        if ($count == 1) {
            push @{$self->achievements}, 'はじめての勝利';
            say "[ACHIEVEMENT] 実績解除: はじめての勝利";
        }
        if ($count == 10) {
            push @{$self->achievements}, 'ハンター';
            say "[ACHIEVEMENT] 実績解除: ハンター";
        }
    }

    sub acquire_item ($self, $item_name) {
        my $event = GameEvent->new(
            type    => 'item_acquired',
            message => "${item_name}を手に入れた!",
        );

        # ログに出力
        say "[LOG] " . $self->player_name . ": " . $event->message;

        # ここにも実績チェックを追加するかも...
    }

    sub level_up ($self, $new_level) {
        my $event = GameEvent->new(
            type    => 'level_up',
            message => "レベルが${new_level}になった!",
        );

        # ログに出力
        say "[LOG] " . $self->player_name . ": " . $event->message;

        # 実績チェック
        if ($new_level == 5) {
            push @{$self->achievements}, '成長';
            say "[ACHIEVEMENT] 実績解除: 成長";
        }
        if ($new_level == 10) {
            push @{$self->achievements}, '熟練者';
            say "[ACHIEVEMENT] 実績解除: 熟練者";
        }
    }
}

package main {
    use v5.36;

    my $game = Game->new(player_name => '勇者');

    say "=== ダンジョン探索開始 ===";
    say "";

    $game->defeat_enemy('スライム');    # 初勝利!
    $game->acquire_item('薬草');
    $game->defeat_enemy('ゴブリン');
    $game->level_up(5);                  # 成長実績!

    say "";
    say "=== 解除した実績 ===";
    say "- $_" for @{$game->achievements};
}

実行結果は以下のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
=== ダンジョン探索開始 ===

[LOG] 勇者: スライムを倒した!
[ACHIEVEMENT] 実績解除: はじめての勝利
[LOG] 勇者: 薬草を手に入れた!
[LOG] 勇者: ゴブリンを倒した!
[LOG] 勇者: レベルが5になった!
[ACHIEVEMENT] 実績解除: 成長

=== 解除した実績 ===
- はじめての勝利
- 成長

動いていますね。しかし、このコードには問題があります。

問題点を整理する

このコードの問題点を考えてみましょう。

問題1: 各メソッドが肥大化する

defeat_enemyメソッドを見てください。

  1. イベントを作成する
  2. ログに出力する
  3. 撃破数を更新する
  4. 実績をチェックする

1つのメソッドが複数の責務を持っています。これは「単一責任の原則(SRP)」に反しています。

問題2: 新しいシステムを追加するたびに修正が必要

もし「サウンドエフェクト」システムを追加したくなったらどうでしょうか。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sub defeat_enemy ($self, $enemy_name) {
    my $event = GameEvent->new(...);

    # ログに出力
    say "[LOG] ...";

    # 実績チェック
    if ($count == 1) { ... }
    if ($count == 10) { ... }

    # サウンドエフェクト(新規追加!)
    say "[SOUND] 敵撃破音を再生";
}

さらに「統計システム」を追加したければ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
sub defeat_enemy ($self, $enemy_name) {
    my $event = GameEvent->new(...);

    # ログに出力
    say "[LOG] ...";

    # 実績チェック
    if ($count == 1) { ... }
    if ($count == 10) { ... }

    # サウンドエフェクト
    say "[SOUND] ...";

    # 統計記録(新規追加!)
    say "[STATS] 撃破数を記録";
}

どんどんメソッドが長くなっていきます。これは「開放閉鎖原則(OCP)」に反しています。

問題3: 変更の影響範囲が大きい

実績システムのロジックを変更したい場合、defeat_enemylevel_upなど、複数のメソッドを修正する必要があります。

問題4: テストが困難

ログ出力と実績システムが密結合しているため、実績システムだけをテストすることが難しくなります。

問題のまとめ

今回発覚した問題を表にまとめます。

問題原因関連する原則
メソッドの肥大化1つのメソッドが複数の責務を持つ単一責任の原則(SRP)
機能追加のたびに既存コード修正システムが直接埋め込まれている開放閉鎖原則(OCP)
変更の影響範囲が大きい責務が分離されていない関心の分離
テストが困難システム間が密結合疎結合

今回のまとめ

今回は、実績システムを追加しようとしたときに発生する問題点を確認しました。

  • 各メソッドに複数の処理が混在している
  • 新しいシステムを追加するたびに既存コードを修正する必要がある
  • 変更の影響範囲が大きく、テストも困難

次回は、これらの問題を解決するために「通知を受け取るクラス」を独立させる方法を学びます。

完成コード

今回のコード(問題を含む状態)は以下の通りです。

  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
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo(cpanmでインストール)

use v5.36;

package GameEvent {
    use Moo;
    use v5.36;

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

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

    has 'timestamp' => (
        is      => 'ro',
        default => sub { time() },
    );
}

package Game {
    use Moo;
    use v5.36;

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

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

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

    sub defeat_enemy ($self, $enemy_name) {
        my $event = GameEvent->new(
            type    => 'enemy_defeated',
            message => "${enemy_name}を倒した!",
        );

        say "[LOG] " . $self->player_name . ": " . $event->message;

        my $count = $self->defeated_count + 1;
        $self->defeated_count($count);

        if ($count == 1) {
            push @{$self->achievements}, 'はじめての勝利';
            say "[ACHIEVEMENT] 実績解除: はじめての勝利";
        }
        if ($count == 10) {
            push @{$self->achievements}, 'ハンター';
            say "[ACHIEVEMENT] 実績解除: ハンター";
        }
    }

    sub acquire_item ($self, $item_name) {
        my $event = GameEvent->new(
            type    => 'item_acquired',
            message => "${item_name}を手に入れた!",
        );
        say "[LOG] " . $self->player_name . ": " . $event->message;
    }

    sub level_up ($self, $new_level) {
        my $event = GameEvent->new(
            type    => 'level_up',
            message => "レベルが${new_level}になった!",
        );

        say "[LOG] " . $self->player_name . ": " . $event->message;

        if ($new_level == 5) {
            push @{$self->achievements}, '成長';
            say "[ACHIEVEMENT] 実績解除: 成長";
        }
        if ($new_level == 10) {
            push @{$self->achievements}, '熟練者';
            say "[ACHIEVEMENT] 実績解除: 熟練者";
        }
    }
}

package main {
    use v5.36;

    my $game = Game->new(player_name => '勇者');

    say "=== ダンジョン探索開始 ===";
    say "";

    $game->defeat_enemy('スライム');
    $game->acquire_item('薬草');
    $game->defeat_enemy('ゴブリン');
    $game->level_up(5);

    say "";
    say "=== 解除した実績 ===";
    say "- $_" for @{$game->achievements};
}

次回予告

次回は「通知を受け取るクラスを作ろう」です。ログと実績をそれぞれ独立したクラスに分離し、責務を明確にします。

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