はじめに
今回は前回実装した戦闘処理をイベントスクリプトを使用して書き直します。
前回は、状態によって条件分岐が大量に発生し、コードが見づらくなっていました。
イベントスクリプトを導入する事によって、コードの見通しがどう改善するかを検討します。
コードの場所
GitHub に置いています。
実行画面
今回の内容
イベントスクリプトの管理クラスの実装
今回はイベントスクリプトを管理するクラスを実装します。
イベントスクリプトは、次のようなコードです。
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)
使用例もコードに付けています。
以下の手順により使用します。
- イベントを登録
- イベントコードの呼び出し
イベント名をキーとして、処理が値の辞書になっています。
戻り値が、次のイベントの文字列です。空文字("")の場合、現在のイベントと同じイベントが返されます。None の場合、イベントの終了を意味します。
(補足) あとで気付きましたが、戻り値を event_manager.py で次のように定数を定義しておけば良かったです。次回、修正します。
CONTINUE_EVENT = ""
FINISH_EVENT = None
イベントスクリプトの呼び出し部分のコードは後で見てみます。
ファイルの分割
まず戦闘に関する処理を battle_event.py ファイルに移動します。
これに伴い、Window クラスや load_image() 関数を main.py と battle_event.py の2つのファイルで使用する必要があるため、その2つの定義を 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
イベントスクリプトの定義
では、ちょっと長いですが、戦闘に関する処理のコードを見てみます。
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 は動きが少し分かりづらくなるため、使用しませんでした。
呼び出し側のコード
イベントスクリプトを呼び出す部分のコードを見てみます。
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がなくなってきたらウィンドウと文字を赤くします。
更新はしばらく先になるかもしれません。