Featured image of post 第4回-通知を受け取る約束を決めよう - Perlでローグライク通知システムを作ろう

第4回-通知を受け取る約束を決めよう - Perlでローグライク通知システムを作ろう

全Observerがupdateメソッドを持つ約束をMoo::Roleで定義。requiresでインターフェースを設計し、統一的な通知の仕組みを構築します。

@nqounetです。

「Perlでローグライク通知システムを作ろう」シリーズの第4回です。前回は、LogObserverAchievementObserverを独立したクラスとして作成しました。今回は、これらのObserverが共通して持つべき「約束」をMoo::Roleで定義します。

前回の振り返り

前回は、ログと実績を独立したクラスに分離しました。両方のクラスには共通点がありました。

  • updateという名前のメソッドを持つ
  • GameEventを受け取って処理を行う

なぜ「約束」が必要なのか

現在のコードでは、LogObserverAchievementObserverupdateメソッドを持っています。しかし、もし新しいObserverを作るとき、updateメソッドを付け忘れたらどうなるでしょうか。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# updateメソッドを忘れた新しいObserver
package SoundObserver {
    use Moo;
    use v5.36;

    # update メソッドがない!
    sub play_sound ($self, $sound_name) {
        say "[SOUND] $sound_name を再生";
    }
}

このクラスを作ってしまうと、後で「全てのObserverに通知する」ときにエラーになります。実行時にエラーが出るまで気づけないのは困りますね。

Moo::Role と requires

Moo::Rolerequiresを使うと、「このRoleを使うクラスは、必ずこのメソッドを実装すること」と宣言できます。

詳しくは「Mooで覚えるオブジェクト指向プログラミング」シリーズの第10回をご覧ください。

GameEventObserverロールを作成する

それでは、「ゲームイベントを受け取るObserver」のルールを定義するRoleを作成しましょう。

  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() },
    );

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

# Observerが持つべきメソッドを定義するRole
package GameEventObserver {
    use Moo::Role;
    use v5.36;

    # このRoleを使うクラスは、必ずupdateメソッドを実装すること
    requires 'update';
}

# ログ出力Observer(Roleを適用)
package LogObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';  # Roleを適用

    has 'prefix' => (
        is      => 'ro',
        default => '[LOG]',
    );

    # requiresで要求されているupdateメソッドを実装
    sub update ($self, $event) {
        say $self->prefix . " " . $event->message;
    }
}

# 実績管理Observer(Roleを適用)
package AchievementObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';  # Roleを適用

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

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

    # requiresで要求されているupdateメソッドを実装
    sub update ($self, $event) {
        my $type = $event->type;

        if ($type eq 'enemy_defeated') {
            my $count = $self->defeated_count + 1;
            $self->defeated_count($count);

            if ($count == 1) {
                $self->_unlock('はじめての勝利');
            }
            if ($count == 10) {
                $self->_unlock('ハンター');
            }
        }
        elsif ($type eq 'level_up') {
            my $level = $event->data->{level} // 0;
            if ($level == 5) {
                $self->_unlock('成長');
            }
        }
    }

    sub _unlock ($self, $name) {
        push @{$self->unlocked}, $name;
        say "[ACHIEVEMENT] 実績解除: $name";
    }
}

# 使用例
package main {
    use v5.36;

    my $log_observer = LogObserver->new();
    my $achievement_observer = AchievementObserver->new();

    my $event = GameEvent->new(
        type    => 'enemy_defeated',
        message => 'スライムを倒した!',
    );

    $log_observer->update($event);
    $achievement_observer->update($event);
}

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

1
2
[LOG] スライムを倒した!
[ACHIEVEMENT] 実績解除: はじめての勝利

updateメソッドを忘れるとどうなるか

では、updateメソッドを実装せずにRoleを適用したらどうなるでしょうか。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# updateメソッドを忘れたObserver
package BrokenObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';  # Roleを適用

    # update メソッドがない!
}

# 使おうとすると...
my $broken = BrokenObserver->new();

このコードを実行すると、エラーになります。

1
Can't apply GameEventObserver to BrokenObserver - missing update

updateメソッドが実装されていないため、クラスのロード時(=実行時の早い段階)にエラーが発生します。実行時にメソッドを呼び出すまで気づかないよりも、はるかに早く問題を発見できます。

新しいObserverを追加してみよう

Roleの仕組みがあれば、新しいObserverを安心して追加できます。サウンドエフェクト用のObserverを作成してみましょう。

 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
# サウンドエフェクトObserver
package SoundObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';  # Roleを適用

    # イベント種類とサウンド名のマッピング
    has 'sound_map' => (
        is      => 'ro',
        default => sub {{
            enemy_defeated => 'victory.wav',
            item_acquired  => 'pickup.wav',
            level_up       => 'levelup.wav',
        }},
    );

    # requiresで要求されているupdateメソッドを実装
    sub update ($self, $event) {
        my $type = $event->type;
        my $sound = $self->sound_map->{$type};

        if ($sound) {
            say "[SOUND] $sound を再生";
        }
    }
}

with 'GameEventObserver'を書くことで、「このクラスはGameEventObserverとして機能します」と宣言しています。もしupdateメソッドを忘れたら、すぐにエラーで気づけます。

Roleを使うメリット

Moo::Rolerequiresを使うことで、以下のメリットがあります。

メリット説明
契約の明示「Observerは必ずupdateを持つ」という約束が明確
早期エラー発見実装漏れがあればクラスロード時にエラー
ドキュメント効果「このRoleをwithすればObserverになれる」とわかる
拡張の安全性新しいObserverを追加するときの指針になる

今回のまとめ

今回は、Observerが共通して持つべきメソッドをMoo::Rolerequiresで定義しました。

  • GameEventObserverロール: updateメソッドを要求
  • 各Observerクラス: with 'GameEventObserver'でRoleを適用
  • 実装漏れ防止: updateを忘れるとクラスロード時にエラー

完成コード

今回の完成コードは以下の通りです。

  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
#!/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() },
    );

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

package GameEventObserver {
    use Moo::Role;
    use v5.36;

    requires 'update';
}

package LogObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

    has 'prefix' => (
        is      => 'ro',
        default => '[LOG]',
    );

    sub update ($self, $event) {
        say $self->prefix . " " . $event->message;
    }
}

package AchievementObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

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

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

    sub update ($self, $event) {
        my $type = $event->type;

        if ($type eq 'enemy_defeated') {
            my $count = $self->defeated_count + 1;
            $self->defeated_count($count);

            if ($count == 1) {
                $self->_unlock('はじめての勝利');
            }
            if ($count == 10) {
                $self->_unlock('ハンター');
            }
        }
        elsif ($type eq 'level_up') {
            my $level = $event->data->{level} // 0;
            if ($level == 5) {
                $self->_unlock('成長');
            }
        }
    }

    sub _unlock ($self, $name) {
        push @{$self->unlocked}, $name;
        say "[ACHIEVEMENT] 実績解除: $name";
    }
}

package SoundObserver {
    use Moo;
    use v5.36;

    with 'GameEventObserver';

    has 'sound_map' => (
        is      => 'ro',
        default => sub {{
            enemy_defeated => 'victory.wav',
            item_acquired  => 'pickup.wav',
            level_up       => 'levelup.wav',
        }},
    );

    sub update ($self, $event) {
        my $type = $event->type;
        my $sound = $self->sound_map->{$type};

        if ($sound) {
            say "[SOUND] $sound を再生";
        }
    }
}

package main {
    use v5.36;

    my $log_observer = LogObserver->new();
    my $achievement_observer = AchievementObserver->new();
    my $sound_observer = SoundObserver->new();

    my @events = (
        GameEvent->new(
            type    => 'enemy_defeated',
            message => 'スライムを倒した!',
        ),
        GameEvent->new(
            type    => 'item_acquired',
            message => '薬草を手に入れた!',
        ),
        GameEvent->new(
            type    => 'level_up',
            message => 'レベルが5になった!',
            data    => { level => 5 },
        ),
    );

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

    for my $event (@events) {
        $log_observer->update($event);
        $achievement_observer->update($event);
        $sound_observer->update($event);
        say "";
    }

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

次回予告

次回は「イベント発生元を管理しよう」です。複数のObserverへの通知を一元管理するGameEventEmitterクラスを作成します。

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