#11 PythonでRPGを作ろう!基礎が固まるコマンドラインで動くゲーム開発

python_rpg21

2023年8月29日更新、「2Dマップを画面遷移できるようにした」について、マップ関連の説明追加しました

皆さんこんにちは。#10を投稿してから大分日が経ちまして、コードも大改修を行いました。

#10までは初心者向けということを中核にコードを書いていたり、説明を極力してきたつもりですが、その後はとにかくコードの可読性や保守性の向上するために色々と試行錯誤していました。

ゲームとしての追加機能はほとんどないのですが、コードを消したり追加したりをしたため、#10までの原型を留めていないと思います。

注意

オブジェクト指向的に適切でない処理も多数含まれている可能性があります。

修正した内容

大きく分けて以下を修正したと思います。

  • 手続き型的な流れを、モードセレクト型に変更した
  • if文の使用回数を減らした
  • while文の使い方を見直した
  • Buttleクラスも同様にモードセレクト的な処理に変更した
  • ProcessクラスにPlayerクラスとMonsterクラスを保持させた
  • PlayerクラスとMonsterクラスで出来ることを増やした
  • Keyクラスだけだったところを、InputKeyクラスを作成し、多態性を持たせようとした
  • Fieldクラスを作成し、2Dマップを画面遷移(スクロール)できるようにした
  • Constクラスを作成し、モードセレクトなどで活用するようにした
  • Itemクラスに新しいアイテムを追加した
  • Colorくらうsを作成し、標準出力に色を付けられるようにした

一番最初に修正したいと思っていた事は、コードの記述でif文の出現回数を減らす事でした。

githubのmasterブランチにはバージョン1.0.0のリリースをしていますが、いつ更新されるかわからないので、現在までに変更されたコードをZIPにして置いておきます。以下リンクよりダウンロードしてご確認ください。

バージョンを更新しました。v2023_09

バージョン2023_09版の解説は次回の記事にしたいと考えていますので、しばらくお待ちください。

多すぎるif文の登場回数を減らした

とにかくif文が多く読むのが大変だったと思います(今でも大変だと思いますが)

標準入力を受け取った後に、「キーが何か?」というように行われる行動1つ1つにif文を付けていました。これは、「キーが何か?」ではなく、「キーがアクションする」というように、「どんなキーがくるかわからない」ところを、とにかく入力されたキーに行動させるようなイメージで修正することにしました。

これは、オブジェクト指向の多態性と呼ばれるものに近いイメージだと思います。key.action()とすることで、その与えられた標準入力のキーが行動をする、という言い切る形になるので、「もしキーがWだったら~」「もしキーがSだったら~」などif文の条件分岐を記述する必要がなくなります。

キーが入力されることと、キーがアクションすることは、ほぼ決定事項として記述されても、そのキーに応じて行動する内容が変わるというイメージです。本来であればkey.action()として、次の行動を呼び出すことができれば簡単なのですが、まったく想定されていないキーが入力された場合は、何もしなかったり、2Dマップを移動するためのキーと、ステータス画面やアイテム一覧画面を表示させるキーの受付が同じ処理なので、そのキーが移動するためのキーなのか、ディスプレイを変更するためのキーなのか、というif文の条件分岐をさせています。

どうにかしてkey.action()だけにすることはできないか…と考えてみましたが、未だに考えが至っていません。key.action()が出来ない理由のもう一つに同じキー入力でも、状況によってさせたいアクションが変更してしまうということもあります。

例えば、今までの処理だと、フィールドマップを移動する際は「W・S・A・D」で「上・下・左・右」のアクションを呼び出していました。ところが、アイテム一覧画面でアイテム選択カーソルを移動させるためには「W・S」のみ使用します。つまり「上・下」の移動しかしないわけです。それなのに「左・右」のアクションをさせていたら不具合が生じてしまいます。

ということで、今回はそれぞれのキーのクラスに対してフィールドに場面ごとに値を保持させることにしました。フィールドマップの場面において、「W・S・A・D」には「移動可」のような値を保持させて、「移動可」のキーの場合は、移動処理を呼び出し、それ以外は、別の処理をさせるようなイメージです。

while文の使い方を見直した

これは一番目に書いた「 手続き型的な流れを。モードセレクト型に変更した」と近い修正です。

今までの実装はとにかく「繰り返しさせたいところはWhile文」を多用していました。しかもその条件式のほとんどが「while True:」となっていました。こういう記述をしていたことで、「入力されたら判定 -> 判定が行動なら行動 -> 行動が成功したらそれぞれ表示 ->表示が終わったら最初に戻る」というような、全てにif文やらbreakやらreturnが付与されていたように思います。

これはif文が多すぎた問題と同じで、breakやreturnの分だけコードを読まなければならないような感じです。とにかく1つのwhile文の中身がずらずらと記述されていた状態だったので、とにかくwhile文の見直しが必要だなぁと思っていました。

while文の条件式は全てTrueだったところを、モードを確認するように修正しました。

まだまだ未解決な部分が多いので、while文の改良は今後も行っていきたいと考えて居ます。

ProcessクラスにPlayerクラスとMonsterクラスを保持させた

これは自分でも疑問が残るところですが、オブジェクトを移譲するという考え方でいけば、大間違いというわけでもないのかなlと考えています。

他の人がゲーム開発をしているコードをチラ見すると、main関数にはGame()というゲームクラスを呼び出しているだけになっているパターンがあったので、「ゲームをスタートさせているだけ」ということを明確にしている面などが良いように感じたので、Process()と記述するだけにしてみようと考えました。

その際、PlayerクラスとMonsterクラスの扱いに悩みました。インスタンスを生成したのち、毎回モードが移動するたびに、引数として2つのインスタンスを渡すのはナンセンスな感じがしたので、Processクラスに保持させてしまおうという事になりました。

移譲するという事に至ったのは以下の記事の影響が大きいです。

そもそも移譲という選択肢が正しいのか?そもそも移譲という方法に当てはまっているのか?など疑問が残るところの多い処理となりましたが、なんとなく落ち着きがよかったので、クラスにインスタンスを保持させる形となっています。

プロセス関連のコメントへの返信

process.pyについて解説して欲しいというコメントを頂きましたので、あらためてprocess.pyについて解説してみます。

def main():
    Process()

上のコードはmain.pyのmain関数です。このmain関数は、Processをインスタンス呼び出しするだけです。今見直すと、既存の解説にある通り、main関数ではゲームスタートを強調することには成功しているかもしれませんが、「ここからゲームがスタートする」ということは読み取りずらいかなと思っています。

def main():
    game = Game()
    game.start()

上の例のように、game.start()と明示したほうがコードを読み込むときはわかりやすいかもしれませんね。

さて、肝心のprocess.pyの中身ですが、Processクラスが1つあり、その中にmode_xxxというモードを扱う関数がたくさん定義されています。

この、「たくさん定義されたモード」をself.start()によって呼び出されているイメージです。

Processクラスは、ゲームのモードを管理して、状況によって各モードを切り替えてあげる役割を担っています。

それだけではなく、既存の解説にもある通り、プレイヤーのインスタンス、マップのインスタンス、ゲームが実行中であるフラグなどを管理する役割もになっています。これらの初期化を以下のようにコンストラクタで明示しています。

    def __init__(self):
        self.player = None
        self.map = None
        self.game_flg = True
        self.counter = 0
        self.select_index = 0
        self.mode = Mode.START
        self.start()

そして、各モードは関数の中でやりたいことを記述しています。それぞれのモードでは、やりたいことが全く違うので、そこから新しいインスタンスを作ったり、画面表示を切り替えたりしているというイメージです。

以上、process.pyはゲーム全体のプロセス・モードの流れが記述されていることが伝われば幸いです。

PlayerクラスとMonsterクラスで出来ることを増やした

これは、もともとオブジェクト指向を学ぶために始めたプロジェクトのはずだったのに、PlayerクラスもMonsterクラスもインスタンスメソッドを何も持っていない状態だったので、増やす事にしました。もともとあったプレイヤーがレベルアップするメソッドのように、プレイヤーが攻撃するメソッドなどを追加しています。

メソッドを追加していけば、追加していくほど、このPlayerの意味するところが、ゲーム上に存在するプレイヤーなのか、パソコンのキーボードをたたいているプレイヤーなのかが、曖昧になっていきそうだったのが、悩ましいところでした。

Monsterクラスも同様に、攻撃するのはMonster、というようにメソッドを追加しています。

今思うと、この当たり前の様なことも出来ていなかったのは、どこでインスタンスを生成したら良いか?などが定まっていなかったのが原因かもしれません。

多態性を持たせようとした

これは主にKeyクラスでの話になりますが、同じキーの入力と受付などの処理を全てif文で処理していました。

例えば、「上へ移動するキーなら上へ移動」「下へ移動するキーなら下へ移動」というように、全て「たずねる(聞く)」形式になっていたことが問題だったと思います。

これは、キーが入力されるたびに「下ですか?上ですか?右ですか?・・・」のように、キーが何であるかをif文でたずねていたというイメージです。

これを「たずねる(聞く)」ではなく、「言う」に変えました。これは有名な言葉らしいです。

Tell, Don’t Ask 言え!、聞くな

https://jabba.cloud/20150912232135

ということで、「キーの方向に移動してね」というように、Key.action()するイメージです。

しかし、「下キー」にアクションさせるにしても、下キーの役割が色々あることに気が付いて、そこで悩むことになるわけですが・・・

何にせよメインロジック部分から大量のif文が消えたことは、読みやすくなったと感じていますので先に進みます。

キー関連のコメントへの返信

キー関連について解説して欲しいというコメントを頂きましたので、改めて解説してみます。

key.pyの構成を全体的に捉えると、重要なものは3つです。

  • Keyクラス
  • InputKeyクラス(InputKeyクラスの具象クラスたち)
  • get_input_key_obj関数(InputKey具象クラスを呼び出すための関数)

Keyクラスはキーボードの各の文字に名前をつけています。例えばWキーはUP、XキーはDECISION(決定)です。それぞれのキーについて、使用するときは、これらの名前で呼ぶというイメージです

InputKeyクラスはABCクラスを継承した抽象基底クラスです。pythonの抽象クラスの場合は、@abstractmethod(抽象メソッドを示すデコレータです)を付与した関数は、継承して作成された子クラスでも実装しないとインスタンス化できません。詳しくは以下の公式に詳しく記述があります。

get_input_key_obj関数は、InputKeyの具象クラスをまとめて定義して、それらをプレイヤーのキー入力から導き出されるインスタンスへ返却しています。Processクラスのstart()でモードをまとめて定義している方法と全く同じ方法を使っています。こうすることで、InputKeyクラスを個別に管理する必要がなくなるメリットがあると考えています。

今回key.pyの中で抽象クラスを使っていますが、抽象クラスを使ったことでパソコンのキーボードで使用できるキーすべてを具象クラスとして定義することが簡単になったように思います。例えば新しくFキーが入力された時の挙動を定義する時は、既存のInputKeyを見本に、新しい具象クラスを定義するだけで済みます。InputKeyはクラスを作るための設計書というイメージです。

以上、key.pyについて少しでも理解が深まれば幸いです。

2Dマップを画面遷移できるようにした

フィールドマップの画面遷移は、今まで1画面に全てのマップがおさまるようなゲームだったので、機能としてありませんでした。

なんとなく「ゲームと言ったら、画面遷移させてみたい」という事で、実装してみることにしました。

もともと2次元配列にX軸とY軸をイメージして空スペースだったり、アイテムだったりを配置していたので、その2次元配列からはみ出した時に、画面遷移させるようにしています。

X軸がはみ出したとき、これは例えば、ずっと右へ移動していって、右端から画面外に出たとき、さらに右も2次元配列のマップを用意して、X軸を反対にするというイメージです。

Y軸がはみだした時は、Y軸を反対にして、次のマップに移動させてあげています。

理論的には簡単なはずなのに、コードにすると複雑になってしまうのは、技術力の低さゆえでしょう。

マップ関連のコメントへの追記

以下追記です。マップの画面遷移について以下のコメントを頂いたので説明を追加したいとおもいます。

>内容がかなり複雑で、もう一度この最終版で内容解説をしていただきたい程です。

では、早速最終版の内容解説を追加していきます。このバージョンでのマップ処理の大きな変化は以下の通りです。

  • filed.py、Filedクラスの追加
  • MapクラスでFiledクラスを呼び出して使用

それぞれ個別に見ていきます。

filed.py、Filedクラスの追加

これは、今までmap.pyの中で定数定義としてマップ情報を、filed.pyに切り出しました。これによって、マップデータはfiled.pyにまとめて置けるようになります。map.pyとfiled.pyは名前があまりよくないと感じていますが、何か良いアイデアがあったらそちらを採用してください。

filed.pyにFiledクラスを作成したことによって、画面遷移のイメージが湧いてくると思います。今まで1つしか定義していなかったマップデータが、以下のように3列、3行、合計9つ用意されています。

  • [11, 12, 13]
  • [21, 22, 23]
  • [31, 32, 33]

これは自分で見返していてもわかりずらいなと感じたところですが、[11]のフィールドデータがスタート地点で、大きいワールドマップの左上にあるイメージです。主人公が右側に進んで行って、フィールド[11]の右端に到達した場合、フィールド[12]の左端に画面遷移しています。

同様に主人公がフィールド[11]の一番下端まで移動した場合、フィールド[21]の一番上端に画面遷移しています。

ゴールはフィールド[33]の右下に設定されています。

ちなみに、filed.pyでは、FIELDS_2も定義していますが、このバージョンでは未使用です。本当は不必要な実装は削除すべきですが、いろんなフィールドを実装したかったところ、途中でやめてしまったのだとおもいます。すみません。

MapクラスでFiledクラスを呼び出して使用

Mapクラスではいくつかのメソッドを追加しています。

  • create_map
  • is_out_of_range
  • scroll_map
  • get_revers_point

これらは、画面遷移させるために新しく追加したメソッドです。

create_mapは、先ほど紹介したFiledクラスから3X3の大きいマップデータをロードしてマップを作成しているイメージです。

is_out_of_rangeは、画面端の判定を行っています。

scroll_mapで具体的に画面遷移する処理を記述しています。get_revers_pointで画面遷移した場合の主人公の表示場所を参照し、画面遷移後の新しいフィールドに設定しているようです。

しかし、改めてこの実装を見ると、全ての処理を終えて、準備万端になった後に、画面遷移可能かどうかの確認を行なっているのは、あまりよくないなと思います。

if next_field == Item.BLOCK.value:

最後の最後で、移動先が壁の場合は何もせずに処理を終了しているので、それまでに行ってきたget_revers_pointなどが不要な処理となってしまっています。改善が必要かなと思います。

何はともあれ、無事に画面遷移の準備ができたら、あとは1画面マップだった時と同じように、画面表示を更新するだけです。

以上、コメントのご質問の回答になっていれば幸いです。

おわりに

最後まで読んで頂きありがとうございました。

他にも書き換えているとことがたくさんありますが、まだまだ実装したいこともたくさんありますし、直さないといけないなぁと感じているところもあります。

今回までに、オブジェクト指向について深く考えさせられることがたくさんあったことは、とても良いことだと感じています。まだまだ自分の技術力が低いと感じる一方で、少しずつわかることも増えてきているなぁと感じています。

相変わらずnumpyなどのpythonだからこそ生きてくるだろうライブラリを使えていないことは残念ですが、どんな言語の仕事が来ても、使える技術をしっかり身に着けておくことも重要なことだと感じます。

これからもpython_rpgとコチラの連載ブログは継続させていくつもりですので、どうぞよろしくお願いします。

以下のDiscordサーバでも質問を受け付けています。お気軽に声をかけて頂けると幸いです!

この記事を書いた人

小幡 知弘

1990年茨城県神栖市生まれ
2013年大阪芸術大学卒業
Python×Webエンジニア