ドラクエ風RPGを作る (第十九回) イベントスクリプトの導入

# はじめに

今回は前回実装した戦闘処理をイベントスクリプトを使用して書き直します。
前回は、状態によって条件分岐が大量に発生し、コードが見づらくなっていました。
イベントスクリプトを導入する事によって、コードの見通しがどう改善するかを検討します。

# コードの場所

GitHub に置いています。

[作成したスクリプト](https://github.com/PythonMan3/PythonGames/tree/master/ShortTaleStory/19)

# 実行画面

![screen.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629087/008dfd61-0777-00a6-6544-6e77b8e1e4fd.jpeg)

# 今回の内容

## イベントスクリプトの管理クラスの実装

今回はイベントスクリプトを管理するクラスを実装します。
イベントスクリプトは、次のようなコードです。

```event_manager.py
class EventManager:
    def __init__(self):
        self.events = {}

    def subscribe(self, event_name, handler):
        # イベントが未登録の場合のみ、ハンドラーを追加
        if event_name not in self.events:
            self.events[event_name] = handler
        else:
            print(f"Handler already registered for event: {event_name}")

    def trigger(self, event_name, *args, **kwargs):
        # ハンドラーが登録されている場合のみ実行
        if event_name in self.events:
            ret = self.events[event_name](*args, **kwargs)
            if ret is None:
                return None
            elif ret == "":
                return event_name
            else:
                return ret
        return None

if __name__ == "__main__":
    # 使用例
    def on_player_attack(enemy):
        print(f"Player attacks {enemy}!")
    
    event_manager = EventManager()
    event_manager.subscribe("player_attack", on_player_attack)
    
    # イベントをトリガー
    enemy = "Enemy"
    event_manager.trigger("player_attack", enemy)
```

使用例もコードに付けています。
以下の手順により使用します。

1. イベントを登録
1. イベントコードの呼び出し

イベント名をキーとして、処理が値の辞書になっています。
戻り値が、次のイベントの文字列です。空文字("")の場合、現在のイベントと同じイベントが返されます。None の場合、イベントの終了を意味します。

(補足) あとで気付きましたが、戻り値を event_manager.py で次のように定数を定義しておけば良かったです。次回、修正します。
CONTINUE_EVENT = ""
FINISH_EVENT = None

イベントスクリプトの呼び出し部分のコードは後で見てみます。

## ファイルの分割

まず戦闘に関する処理を battle_event.py ファイルに移動します。
これに伴い、Window クラスや load_image() 関数を main.py と battle_event.py の２つのファイルで使用する必要があるため、その２つの定義を window.py ファイルに移動します。
これは、もし main.py で Window クラスを定義した場合、main.py 内で battle_event.py を import して、さらに　battle_event.py 内で Window クラスが定義されている main.py を import する必要があり、相互 import になってエラーになるからです。

結果的に、以下のファイルに分割されました。
- battle_event.py
- event_manager.py
- main.py
- window.py

## イベントスクリプトの定義

では、ちょっと長いですが、戦闘に関する処理のコードを見てみます。

```battle_event.py
class Battle:
    """戦闘画面"""
    def __init__(self, player, msgwnd, msg_engine):
        self.player = player
        msgwnd = msgwnd
        self.msg_engine = msg_engine
        # 戦闘コマンドウィンドウ
        self.cmdwnd = BattleCommandWindow(Rect(96, 338, 136, 136), self.msg_engine)
        # モンスターのステータス
        self.monster_status = None
        # 戦闘ステータスウィンドウ
        self.status_wnd = BattleStatusWindow(Rect(90, 8, 104+32, 136), self.player, self.msg_engine)
        self.monster_img = window.load_image(os.path.join("data", "pipo-enemy046.png"))
        self.monster_img = pygame.transform.scale(self.monster_img, (240, 240))  # 縮小後のサイズを指定
        self.is_player_dead = False     # プレイヤー死亡
        self.return_field = False
        self.return_title = False
    def check_return_field(self):
        return self.return_field
    def check_return_title(self):
        return self.return_title
    def start(self):
        """戦闘の開始処理、モンスターの選択、配置など"""
        self.cmdwnd.hide()
        self.status_wnd.hide()
        self.is_player_dead = False
        self.return_field = False
        self.return_title = False
        # 戦闘ごとにモンスターのステータスをリセット
        self.monster_status = {"name": "とんでスライム",
            "hp": 6, "mp": 0,
            "attack": 2, "defense": 1, "speed": 5}  # HPや攻撃力をリセット
        msgwnd.set(f"{self.monster_status['name']}が　あらわれた！")
        self.play_bgm()
    def update(self):
        pass
    def draw(self, screen):
        screen.fill((0,0,0))
        screen.blit(self.monster_img, (200, 100))
        self.cmdwnd.draw(screen)
        self.status_wnd.draw(screen)
    def play_bgm(self):
        #bgm_file = "battle.mp3"
        #bgm_file = os.path.join("bgm", bgm_file)
        #pygame.mixer.music.load(bgm_file)
        #pygame.mixer.music.play(-1)
        pass

class BattleCommandWindow(window.Window):
    """戦闘のコマンドウィンドウ"""
    LINE_HEIGHT = 8  # 行間の大きさ
    ATTACK, SPELL, ITEM, ESCAPE = range(4)
    COMMAND = ["たたかう", "じゅもん", "どうぐ", "にげる"]
    def __init__(self, rect, msg_engine):
        window.Window.__init__(self, rect)
        self.text_rect = self.inner_rect.inflate(-32, -16)
        self.command = self.ATTACK  # 選択中のコマンド
        self.msg_engine = msg_engine
        self.cursor = window.load_image(os.path.join("data", "cursor2.png"))
        self.frame = 0
    def draw(self, screen):
        window.Window.draw(self, screen)
        if self.is_visible == False: return
        # コマンドを描画
        for i in range(0, 4):
            dx = self.text_rect[0] + MessageEngine.FONT_WIDTH
            dy = self.text_rect[1] + (self.LINE_HEIGHT+MessageEngine.FONT_HEIGHT) * (i % 4)
            self.msg_engine.draw_string(screen, (dx,dy), self.COMMAND[i])
        # 選択中のコマンドの左側に▶を描画
        dx = self.text_rect[0]
        dy = self.text_rect[1] + (self.LINE_HEIGHT+MessageEngine.FONT_HEIGHT) * (self.command % 4)
        screen.blit(self.cursor, (dx,dy))
    def show(self):
        """オーバーライド"""
        self.command = self.ATTACK  # 追加
        self.is_visible = True

class BattleStatusWindow(window.Window):
    """戦闘画面のステータスウィンドウ"""
    LINE_HEIGHT = 8  # 行間の大きさ
    def __init__(self, rect, player, msg_engine):
        window.Window.__init__(self, rect)
        self.text_rect = self.inner_rect.inflate(-32, -16)
        self.player = player
        self.msg_engine = msg_engine
        self.frame = 0
    def draw(self, screen):
        window.Window.draw(self, screen)
        if self.is_visible == False: return
        # ステータスを描画
        status_str = [self.player.player_status["name"],
            "H  %3d" % self.player.player_status["hp"],
            "M  %3d" % self.player.player_status["mp"],
            "LV %3d" % self.player.player_status["lv"]]
        for i in range(0, 4):
            dx = self.text_rect[0]
            dy = self.text_rect[1] + (self.LINE_HEIGHT+MessageEngine.FONT_HEIGHT) * (i % 4)
            self.msg_engine.draw_string(screen, (dx,dy), status_str[i])
            
def subscribe(event_manager):
    event_manager.subscribe("battle_update", on_battle_update)
    event_manager.subscribe("battle_draw", on_battle_draw)
    event_manager.subscribe("battle_start", on_battle_start)
    event_manager.subscribe("battle_init", on_battle_init)
    event_manager.subscribe("battle_command", on_battle_command)
    event_manager.subscribe("battle_proc", on_battle_proc)
    event_manager.subscribe("battle_player_dead", on_battle_player_dead)
    event_manager.subscribe("battle_monster_dead", on_battle_monster_dead)
    event_manager.subscribe("battle_end", on_battle_end)

def on_battle_update(battle):
    battle.update()
    return ""

def on_battle_draw(battle, screen):
    battle.draw(screen)
    return ""

def on_battle_start(battle, msgwnd):
    """戦闘の開始処理、モンスターの選択、配置など"""
    print("battle start---")
    battle.return_field = False
    battle.return_title = False
    battle.cmdwnd.hide()
    battle.status_wnd.hide()
    # 戦闘ごとにモンスターのステータスをリセット
    battle.monster_status = {"name": "とんでスライム",
        "hp": 6, "mp": 0,
        "attack": 2, "defense": 1, "speed": 5}  # HPや攻撃力をリセット
    msgwnd.set(f"{battle.monster_status['name']}が　あらわれた！")
    return "battle_init"
    
def on_battle_init(event, battle, msgwnd):
    """戦闘開始のイベントハンドラ"""
    print("battle init---")
    if event.type == KEYDOWN and event.key == K_SPACE:
        if not msgwnd.next():
            msgwnd.hide()
            #sounds["pi"].play()
            battle.cmdwnd.show()
            battle.status_wnd.show()
            ###game_state = BATTLE_COMMAND
            return "battle_command"
    return ""
    
def calculate_damage(attack, defense):
    # 基本ダメージ = (攻撃力 / 2) - (守備力 / 4)
    # 最終ダメージ = 基本ダメージ ± (0～4のランダムな値)
    # 但し、0以下にならないようにする
    damage = attack // 2 - defense // 4
    damage = max(damage + random.randint(0, 4) * random.choice([-1, 1]), 1)
    return damage
    
def on_battle_command(event, battle, msgwnd):
    """戦闘コマンドウィンドウが出ているときのイベントハンドラ"""
    print("battle command---")
    ###global game_state
    # バトルコマンドのカーソル移動
    if event.type == KEYUP and event.key == K_UP:
        if battle.cmdwnd.command == 0: return ""
        battle.cmdwnd.command -= 1
    elif event.type == KEYDOWN and event.key == K_DOWN:
        if battle.cmdwnd.command == 3: return ""
        battle.cmdwnd.command += 1
    # バトルコマンドの決定
    if event.type == KEYDOWN and event.key == K_SPACE:
        if not msgwnd.next():
            #sounds["pi"].play()
            if battle.cmdwnd.command == BattleCommandWindow.ATTACK:  # たたかう
                damage = calculate_damage(battle.player.player_status["attack"],
                    battle.monster_status["defense"])
                battle.monster_status["hp"] -= damage
                if battle.monster_status["hp"] < 0:  # 負にならないようにする
                    battle.monster_status["hp"] = 0
                msgwnd.set(f"{battle.monster_status['name']}に{damage}のダメージ！")
                if battle.monster_status["hp"] <= 0:
                    battle.is_player_turn = False
                    battle.cmdwnd.hide()
                    ###game_state = BATTLE_PROCESS
                    return "battle_monster_dead"
            elif battle.cmdwnd.command == BattleCommandWindow.SPELL:  # じゅもん
                msgwnd.set("じゅもんを　おぼえていない。")
            elif battle.cmdwnd.command == BattleCommandWindow.ITEM:  # どうぐ
                msgwnd.set("どうぐを　もっていない。")
            elif battle.cmdwnd.command == BattleCommandWindow.ESCAPE:  # にげる
                msgwnd.set("ゆうしゃは　にげだした。")
            battle.is_player_turn = False
            battle.cmdwnd.hide()
            ###game_state = BATTLE_PROCESS
            return "battle_proc"
    return ""
    
def on_battle_proc(event, battle, msgwnd):
    """戦闘の処理"""
    print("battle proc---")
    ###global game_state
    if event.type == KEYDOWN and event.key == K_SPACE:
        if not msgwnd.next():
            msgwnd.hide()
            if battle.monster_status["hp"] <= 0:
                msgwnd.set(f"{battle.monster_status['name']}をたおした！")
                ###game_state = BATTLE_END  # 戦闘終了してフィールドに戻る
                return "battle_end"
            elif battle.cmdwnd.command == BattleCommandWindow.ESCAPE:
                # フィールドへ戻る
                #self.fieldMap.play_bgm()
                ###game_state = FIELD
                battle.return_field = True
            else:
                if battle.is_player_turn:
                    ###game_state = BATTLE_COMMAND
                    battle.cmdwnd.show()
                    return "battle_command"
                else:
                    # モンスターが攻撃してくる
                    damage = calculate_damage(battle.monster_status["attack"],
                        battle.player.player_status["defense"])
                    battle.player.player_status["hp"] -= damage  # プレイヤーのHPを減らす
                    if battle.player.player_status["hp"] < 0:  # HPが負にならないようにする
                        battle.player.player_status["hp"] = 0
                    msgwnd.set(f"{battle.monster_status['name']}のこうげき！ ゆうしゃに{damage}のダメージ！")
                    if battle.player.player_status["hp"] <= 0:
                        ###game_state = BATTLE_END
                        battle.is_player_dead = False
                        return "battle_player_dead"
                    battle.is_player_turn = True
                    return ""
    return ""
    
def on_battle_player_dead(event, battle, msgwnd):
    """プレイヤーの死亡処理"""
    print("battle player dead---")
    if event.type == KEYDOWN and event.key == K_SPACE:
        if not msgwnd.next():
            if battle.is_player_dead:
                msgwnd.hide()
                ###game_state = TITLE  # ゲーム状態をとりあえずタイトルに戻す
                battle.return_title = True
                return None
            else:
                msgwnd.set("ゆうしゃはしんでしまった！")
                battle.is_player_dead = True
    return ""
    
def on_battle_monster_dead(event, battle, msgwnd):
    """モンスターの死亡処理"""
    print("battle monster dead---")
    if event.type == KEYDOWN and event.key == K_SPACE:
        if not msgwnd.next():
            msgwnd.set(f"{battle.monster_status['name']}をたおした！")
            return "battle_end"
    return ""
    
def on_battle_end(event, battle, msgwnd):
    """バトルが終了したときの処理を行う"""
    print("battle end---")
    ###global game_state
    if event.type == KEYDOWN and event.key == K_SPACE:
        if not msgwnd.next():
            # フィールドに戻る処理
            ###game_state = FIELD  # ゲーム状態をフィールドに戻す
            battle.cmdwnd.hide()  # コマンドウィンドウを隠す
            battle.return_field = True
            return None
    return ""   
```

このファイルは戦闘イベントスクリプトを定義したファイルです。
return 値で次のイベントの文字列を返す事によって、次のイベントに実行が遷移されます。
関数を複数増やす事によって、細かく状態を分岐させる事が出来ます。

ゲーム状態の if 文による条件分岐ではなく、return 値で返される文字列によって自動的にコードが分岐されます。そのため、「処理を意味単位で分割出来たり」、「処理の遷移を追いやすくなった」と思いますので、実装側のコードも少し見通しが良くなったように思います。
また、main.py から戦闘のコードのみを分離する事によって、main.py は変更せずに、battle_event.py の中身だけを修正する事により、コードが管理しやすくなったかと思います。
これが、スクリプトイベントを導入したメリットになります。

根本的に、メッセージ処理の使い方自体は改善出来ていませんので、関数分割やフラグで管理する必要はあります。
Python の generator などを使用する事により、順番に呼び出せるような方法もありますが、generator は動きが少し分かりづらくなるため、使用しませんでした。

## 呼び出し側のコード

イベントスクリプトを呼び出す部分のコードを見てみます。

```main.py
TITLE, FIELD, TALK, COMMAND, BATTLE = range(5)
...
class PyRPG:
    def __init__(self):
        ...
        # イベント管理用のオブジェクト
        self.event_manager = event_manager.EventManager()
        ...
        self.player = Player(os.path.join("data", "pipo-charachip021.png"), self)
        ...
        # 戦闘画面
        self.battle = battle_event.Battle(self.player, self.msgwnd, self.msg_engine)
        # 戦闘イベント登録
        battle_event.subscribe(self.event_manager)
        self.curr_event = "battle_init"
    ...
    def update(self):
        """ゲーム状態の更新"""
        if game_state == TITLE:
            ...
        elif game_state == BATTLE:
            self.event_manager.trigger("battle_update", self.battle)
    def render(self):
        """ゲームオブジェクトのレンダリング"""
        if game_state == TITLE:
            ...
        elif game_state == BATTLE:
            self.event_manager.trigger("battle_draw", self.battle, self.screen)
    def check_event(self):
        """イベントハンドラ"""
        global game_state
        for event in pygame.event.get():
            if event.type == QUIT:
                ...
            elif game_state == BATTLE:
                self.curr_event = self.event_manager.trigger(self.curr_event, event, self.battle, self.msgwnd)
                if self.curr_event is None:  # BATTLEイベントの終了
                    if self.battle.check_return_field():
                        game_state = FIELD
                    elif self.battle.check_return_title():
                        game_state = TITLE
```

check_event(...) メソッドの呼び出す部分に注目してください。
ゲーム状態が BATTLE のみにまとまり、さらに戦闘処理が、イベントの呼び出しと戻り値の処理のみになりました。
呼び出し側のコードも、かなりすっきりしたと思います。
この部分のコードは、これ以降ほとんど変更せずに済むはずです。
戦闘処理を修正したい場合は、battle_event.py の中身を変更すれば良いです。

# おわりに

次回は戦闘処理に対して、エフェクトを実装したいと思います。
敵を攻撃したら点滅したり、攻撃を喰らったら画面が揺れる処理です。
さらに、HPがなくなってきたらウィンドウと文字を赤くします。
更新はしばらく先になるかもしれません。