#9 PythonでRPGを作ろう!基礎が固まるコマンドラインで動くゲーム開発 | 初心者向け

python_rpg19

皆さんこんにちは。コントローラーと言ったら十字キーも好きですが、アーケードコントローラーも好きですし、エアコンのリモコンも好きな小幡です。最近ではIoT化が進みエアコンの起動が声で出来たり、コントローラーの在り方も少しずつ変わってきていると感じています。

今回は前回作成したprocess.pyをさらに修正して様々な処理を詰め込んでいきます。例えば「想定外のキーが入力された場合」、「タイトルの表示」、「プレイヤー名の入力」など、これらの処理は一連のプロセスとして整理しておいた方がmain.pyを見た時に理解しやすくなります。

さらにEventクラス、Textクラスも一気に仕上げていきます。これらのクラスは今回で既に配布しているコードと同等の記述まで至りますが、まだすべてを使う状態になるわけではありません。メソッドが呼び出されるたびに確認するイメージで良いと考えます。

コードは書く時間よりも、読まれる時間の方が長いのが一般的です。これは、書きやすさよりも、読みやすさを大切にしたほうが良いということでもあります。この読みやすさを求めるためのコードの整理を行うイメージです。

前回の記事は以下リンクよりどうぞ。

前回のおさらいです。

  • 「上・下・左・右」のキー入力を受け付ける
  • 「単純な移動処理」を完成させる

ということで、前回までにフィールドマップ内での「単純な移動処理」を完成させました。

標準入力の受付を整理する

まずは標準入力の受付を整理します。ここでようやく前回作成した”KEY_LIST”を使用します。

ここからは最後に行うテストや、テキスト処理の整理、イベント処理の整理などを一気に行いますので、少し読み解くのが大変かもしれませんが、やっていることは単純なので最初はあまり深く考えずに、部品をたくさん作っているイメージを持ってもらえればと思います。

もちろん、コーディングする際には毎回説明していますが、ここで紹介しているコードが完璧なものだとは言えませんので、改良の余地はいくらでもあると思います。むしろ改善した方が良いところが見つけることができれば、それは基礎力が高まっている証拠かと思いますので、どんどんコードを直してもらえればと思います。

さて、「標準入力を受け付ける処理」はprocess.pyに全て任せてしまいます。コードは以下です。今まで使っていなかったEventクラス・Textクラスが登場してきます。後で作成しますのでEentクラス・Textクラスのインポートも行っておきます。

from event import Event
from text import Text

# == 中略 ~~

    @classmethod
    def input_player_key(cls) -> str:
        """標準入力の受付

        Returns:
            str: バリデーションされた文字列
        """
        while True:
            input_key = Event.input()
            for key, value in cls.KEY_LIST.items():
                if input_key in value:
                    return key

            # 不正な入力の場合
            else:
                print(Text.MES_CAN_NOT_USE_KEY)

具体的にこのメソッドが何をしているか見ていきましょう。

5行目はクラスメソッドで定義している事をしらせています。クラスメソッドに関しては以前詳しく説明しましたが、改めて説明するとProcessクラスで定義しているメンバを使用するメソッドであるということです。今回のメソッドでは”cls.KEY_LIST”を使用しています。

7行目からのコメントにも書きましたが、このメソッドは改めて標準入力の受付をするメソッドです。Returnとしてバリデーションした文字列を返却しています。「バリデーション」を直訳すると「検証」です。これは入力された文字列を検証し、想定外のものが入力されていた場合は入力を認めず、想定した入力の場合にメソッドを通過させるというイメージです。この”input_player_key”はバリデーションしているメソッドなので、”validate_player_key”という名前でも良いのですが、今まで使っていたinput関数の役割を担うために、このような名前となっています。

12行目のWhileループはmain関数で使用した時と似ていますが、今回はreturnされることでループから抜け出す処理になっています。これはプレイヤーから入力を求めた結果、想定しない入力だった場合「無効なキーです」と標準出力させ、ループしてもう一度入力を促すという流れになっています。有効なキー入力が与えられない場合は次に進まないというイメージです。

13行目はテスト用にEventクラスを作成し、そこからinput関数を呼び出しているイメージです。現在作成しているPython_rpgは最後にテストも作る想定で作業を始めたのですが、標準入力と出力のテストはしたことがなかったので部品として分ける事で理解し易い形になるのでは?という思惑から分離させています。なので、Even.input()の中身は普通のinput関数と同じイメージで大丈夫です。また後でEventクラスも作成し追加します。

14行目からはinput関数によって入力されたキーの値を検証する条件判定処理です。

for key, value in cls.KEY_LIST.items():
    if input_key in value:
        return key

ここで難しいのは”cls.KEY_LIST.items()”でしょうか。この”item()”というのは、辞書型のキーとバリューをそれぞれ取り出し、変数に入れる事ができる処理です。forループなので辞書型の値が存在する限りループするのですが、1番目のkeyとして”UP”、valueとして”[‘w’, ‘W’, ‘w’, ‘W’]”が入ります。何を言っているのかわからないという場合は、前回作成したprocess.pyのProcessクラスの定数”KEY_LIST”のあたりをもう一度見返してみてください。”KEY_LIST”が辞書型であること、keyに定数、valueにリスト型が与えられていることが見えてこないと、少し難しく感じるかもしれません。

次にif文に入ります。forループで取り出した1番目のkey”UP”、value”[‘w’, ‘W’, ‘w’, ‘W’]”が用意された状態です。ここのif文では「もしvalue”[‘w’, ‘W’, ‘w’, ‘W’]”の中に、input_keyがあればTrue」という条件式になっていることがわかります。ということで、「半角小文字、半角大文字、全角小文字、全角大文字」の4つの想定されたパターンが入力されていればTrueになります。「W」キーを押した場合にキーボードの設定が特殊な設定(例えばひらがな入力)になっていない限り「W」キーを1回押して、Enterされた場合はTrueになるということです。間違って2回連打してしまった場合などはFalseになりますし、「wssw」など他の入力も含んだ場合などもFalseになります。

そして最後に条件式がTrueだった場合、forループで取り出したkey”UP”、value”[‘w’, ‘W’, ‘w’, ‘W’]”のkeyが返却される事になります。key”UP”のUPとは何だったでしょうか?これは”w”半角英字を設定していました。ここではvalue”[‘w’, ‘W’, ‘w’, ‘W’]”の4つのパターンが入力された場合、結局”w”の半角英字に整えられるという事です。これで検証した結果、整えられた文字列の獲得が出来るようになりました。

これ以降Processクラスのinput_player_keyメソッドを使い、キー入力待ちをすることで、バリデーションされた想定する値を保持することができるようになります。今回はプレイヤーの移動のキー入力として使いましたが、モンスターとの戦闘でのキー入力や、アイテム使用時のキー入力など使える場所はたくさんあります。どんどん使っていきましょう。

19行目のelse文はfor文で取り出した”KEY_LIST”の中にキー入力された値がなかった場合に入っていくことになります。想定外のキー入力だった場合です。

20行目では標準出力で、Textクラスの値を出力しています。このTextクラスもまだ未定義ですが、テキストを集めたクラスというイメージです。文字列に関する情報はここに集約していく想定です。

Eventクラスを作成する

さきほども触れましたが、イベントを処理するEventクラスを作成していきます。これも分離させる必要性は低いかもしれませんが、クラス作成の練習だと考えて貰えれば幸いです。またモンスターとのエンカウント処理もここに記述していきます。いつものようにevent.pyを作成し、Eventクラスを作成します。必要な処理を一気に全て記述します。

メソッドの無いようなコメント文を読んでもらえれば、なんとなくやりたいことが把握できると思います。コード全体は以下です。

import os
import sys
from random import random

from text import Text


class Event:

    YES_LIST = [
        'y', 'ye', 'yes', 'Y', 'YE', 'YES',
        'y', 'いぇ', 'いぇs', 'Y', 'YE', 'YES',
    ]

    @staticmethod
    def clear():
        """コンソール画面をクリアする
        """
        os.system('cls' if os.name in ('nt', 'dos') else 'clear')

    @classmethod
    def is_yes(cls, answer: str) -> bool:
        """"応答がYesであるか否か
        """
        return True if answer in cls.YES_LIST else False

    @staticmethod
    def is_encount(counter):
        """モンスターと戦闘するか否か
        """
        return True \
            if int(random() * 10) % 9 == 0 or counter % 20 == 0 \
            else False

    @classmethod
    def confirmation(cls) -> bool:
        """確認
        """
        answer = input(Text.MES_CONFIRMATION)
        return True if answer in cls.YES_LIST else False

    @staticmethod
    def input():
        """標準入力
        """
        return sys.stdin.readline().rstrip('\n')

ひとつずつ詳しく見ていきます。

    YES_LIST = [
        'y', 'ye', 'yes', 'Y', 'YE', 'YES',
        'y', 'いぇ', 'いぇs', 'Y', 'YE', 'YES',
    ]

上は後で出てくるis_yesメソッドのための定数です。「本当によろしいですか?」という問いに対してプレイヤーの「Yes」のキー入力を受け取るための想定した文字列が集まったリストです。ここで定数を作ることで、これらの文字列が入力された場合は全て「Yes」として判定するという仕組みのために用意します。

    @staticmethod
    def clear():
        """コンソール画面をクリアする
        """
        os.system('cls' if os.name in ('nt', 'dos') else 'clear')

上は今までキー入力されるたびにフィールドマップが表示されていたりして、縦に長く標準出力されていたコンソール画面をクリアして見やすい環境づくりのためのメソッドです。os.systemの引数はコマンドラインで実際に使うコマンドの文字列ですが、ここでは三項演算子をしようして条件分岐させています。os.nameが”nt”か”dos”の場合は”cls”コマンドを使い、画面をクリアし、それ以外のosの場合は、clearコマンドを使うというイメージです。

このように、windows版のゲーム、mac版のゲームを両方のOSに対応したゲームを作成しようとすると想定しなければならないことがたくさん出てきます。第1回目の動作環境説明でも記述した通りい、macでの動作確認はしていません。今回は例として1つ作成してみましたが、他の処理はmacの事を一切考えて居ませんのでご了承ください。

注意

厳密に言うとWidows版、mac版という処理ではありませんのでご注意ください。

    @classmethod
    def is_yes(cls, answer: str) -> bool:
        """"応答がYesであるか否か
        """
        return True if answer in cls.YES_LIST else False

上は先ほど説明した「本当によろしいですか?」の答えを判別するメソッドです。基本的にBoolean型で値を返却するメソッドの名前は”is_〇〇”の様な形で作られる事が多いです。また何かを処理するメソッドの先頭は動詞が来ることが多いです。(get_itemの様な形)

この処理も三項演算子で記述され1行で1行でreturnするものを作成し、返却しています。内容は「もしcls.YES_LISTの中にanswerがあればTrue、なければFalse」というイメージです。answerはプレイヤーの解答の引数です。

    @staticmethod
    def is_encount(counter):
        """モンスターと戦闘するか否か
        """
        return True \
            if int(random() * 10) % 9 == 0 or counter % 20 == 0 \
            else False

上はモンスターとのエンカウント判定処理です。Mapクラスのmoveメソッドの最後でcounterに1を足したのを覚えているでしょうか?このカウンターを引数に使ってエンカウント判定を行います。ここでも三項演算子を使いTrueかFalseを返却しています。条件式は「ランダムの数値0から9を9で割った余りが0の時、もしくはカウンターを20で割った余りが0の時True」ですので、わかりやすく言い換えると「歩くたびに、10分の1の確立でエンカウント」もしくは「20歩に1かいエンカウント」することになります。

おすすめ

ランダム”random()”はゲームの中ではよく使う要素だと思いますので、詳しく知りたい方は調べてみましょう。

    @classmethod
    def confirmation(cls) -> bool:
        """確認
        """
        answer = input(Text.MES_CONFIRMATION)
        return True if answer in cls.YES_LIST else False

上はis_yesメソッドとほぼ同じメソッドです。(似ているので改良の余地ありです。)Escを押した時に、「本当によろしいですか?」と標準出力し「Yes」の場合はTrueを返すという処理で使っています。

    @staticmethod
    def input():
        """標準入力
        """
        return sys.stdin.readline().rstrip('\n')

標準入力を受け付けるメソッドです。余談ですがテストでモックするなど、inputをこのように作成しなくても良いことに気が付いたので、後々無くなるかもしれませんが、今回はEvent.input()を使用します。

Textクラスを作成する

続けてTextクラスも一緒に作成してしまいます。

こちらも作成するまえに少し出てきましたが、文章や文字列を扱うために用意したクラスです。同様にtext.pyを作成し、Textクラスを作成します。こちらも一気に記述したコードが以下です。

class Text:
    PLAYER_NAME_MAX_LENGTH = 10

    # title
    STRING_DECORATION = '=' * 24 + '\n'
    TITLE = \
        STRING_DECORATION \
        + '\n' * 5 \
        + '     RPG created by Python\n' \
        + '\n' * 5 \
        + STRING_DECORATION \
        + 'Enterを押してゲームスタート\n'

    # text
    MES_INPUT_PLAYER_NAME = \
        STRING_DECORATION + '\n\nプレイヤー名を入力してください: '
    MES_PLAYER_NAME_IS_TOO_LONG = '名前が長すぎます。(10文字まで)'
    QUESTION_ANSWER = '"{}" これで良いですか?\n"yes" または "no" で応答してください [y/N]:'
    WELCOME = 'ようこそ '
    INPUT_YES_OR_NO = ''
    ENTER = '\n'
    MES_GAME_MISSION = STRING_DECORATION \
        + '\n\nようこそ{}さん!\n' \
        + 'ゲームクリア条件は、ゴール(G)に到達することです。\n' \
        + '\n道中恐ろしいモンスターと遭遇するかもしれません。\n' \
        + '戦うか逃げるかは、あなた次第です!\n' \
        + '\n・・・恐ろしいドラゴンが接近中と言う\n' \
        + '連絡がありましたので、\n' \
        + '早めにゴールを目指す事をお薦めします!\n' \
        + '\nそれでは、いってらっしゃい!!'

    # map
    MES_CONFIRMATION = \
        '本当に良いですか?\n' \
        + '"yes" または "no" で応答してください [y/N]'
    MES_HOW_TO_PLAY = \
        '========= 操作方法 =========\n' \
        + '[終了   :q ][上    :w ][アイテム :e ]\n' \
        + '[左    :a ][下    :s ][右    :d ]\n' \
        + '[ステータス:z ][決定   :x ][ヘルプ  :c ]\n' \
        + '========================\n'
    HOW_TO_USE_ITEM = \
        '========= 操作方法 =========\n' \
        + '[       ][上    :w ][とじる  :x ]\n' \
        + '[       ][下    :s ][       ]\n' \
        + '[       ][つかう  :x ]\n' \
        + '========================\n'
    ITEM_LIST_PREFIX = '######## アイテム一覧 ########\n'
    ITEM_LIST_NOTING = 'アイテムがありません。'
    MES_USE_HERB = 'HPが少し回復した!'
    MES_USE_EQUIPMENT = '装備品は持っているだけで効果があります'
    ITEM_LIST_SUFFIX = '\n########################\n'
    USE_ITEM_CONFIRM = \
        '"{}"を使用します。よろしいですか?\n' \
        + '"yes" または "no" で応答してください [y/N]'
    ICON_SELECTED = '[*]'
    ICON_NOT_SELECTED = '[ ]'
    MES_CAN_NOT_USE_KEY = '!!! 無効なキーです !!!'
    MES_CAN_NOT_MOVE = '!!! 移動できません !!!'
    MES_GET_SIELD = '勇者の盾を手に入れた!'
    MES_GET_WEAPON = '勇者の剣を手に入れた!'
    MES_GET_HERBS = '薬草を手に入れた!'

    # buttle
    MES_HOW_TO_BUTTLE = \
        '========= 操作方法 =========\n' \
        + '[終了   :q ][     :w ][アイテム :e ]\n' \
        + '[こうげき :a ][まほう  :s ][にげる  :d ]\n' \
        + '[ステータス:z ][ヘルプ  :x ]\n' \
        + '========================\n'
    PLAYER_STATUS = \
        '{}\n' \
        + 'HP[{}/{}] MP[{}/{}]\n'
    PLAYER_STATUS_DETAIL = \
        'LEVEL: {}\n' \
        + '集めた経験値: {}exp\n' \
        + '次の経験値まで後: {}exp\n' \
        + '攻撃力: {}\n' \
        + '防御力: {}\n'
    MONSTER_STATUS = \
        '{}\n' \
        + 'HP[{}]\n'
    MES_APPEAR_MONSTER = '{}が、あらわれた!'
    MES_CHOOSE_ACTION = '何をしますか?'
    MES_MP_IS_EMPTY = 'MPが空だった!'
    KNOCK_OUT_MONSTER = '{}を、たおした!'
    KNOCK_OUT_PLAYER = '{}は、ちからつきた...'
    MES_ATTACK_FROM_PLAYER = '{}は、こうげきした!'
    MES_ATTACK_FROM_MONSTER = '{}のこうげき!'
    MES_DAMAGE = '{}のダメージ!'
    MES_MAGIC = '{}は、まほうをとなえた!'
    MES_GET_EXP = '{}のけいけんちをかくとく!'
    MES_LEVEL_UP = 'レベルアップ! {}はレベル{}になった!'
    MES_ESCAPE = 'にげた!'
    MES_CAN_NOT_ESCAPE = 'にげることに、しっぱいした!'

    GAME_OVER = STRING_DECORATION + '\n\n ... ゲーム オーバー ... \n\n'
    GAME_CLEAR = STRING_DECORATION + '\n\n=== ゲーム クリア === \n\n'

上のコードは単純に文字列を定数として定義しているのが大部分ですので、ざっと見ておいてもらえれば大丈夫です。

テキストの扱いについて”\n”が改行を意味することや”+”で文字列を結合していることなど単純な処理してほとんどしていませんが、以下のコードに少し触れておきます。

STRING_DECORATION = '=' * 24 + '\n'

ストリングデコレーションという定数名からも読み取れるかと思いますが、文字列を装飾するための文字列です。”=”を24個結合させて、長い帯の様な装飾”=========”を表現しています。単純に24個書いても良いのですが、24個もつなげると何個つながっているのかパットみただけではわからないことと、もし25個、あるいは35個に変更したいときに、簡単に変更できるなどメリットが多い様に感じています。

この定数からもわかるように、今回作成しているPython_rpgのコンソール画面は少なくとも全角文字列24個分は表示できることが推奨画面サイズです。それよりも小さい画面でプログラムを起動させると文字がずれてしまいます。こういう推奨設定などはREADME.mdに記載した方が良いかもしれません。

    MES_HOW_TO_PLAY = \
        '========= 操作方法 =========\n' \
        + '[終了   :q ][上    :w ][アイテム :e ]\n' \
        + '[左    :a ][下    :s ][右    :d ]\n' \
        + '[ステータス:z ][決定   :x ][ヘルプ  :c ]\n' \
        + '========================\n'

上のコードは操作方法を標準出力するための定数です。先ほど説明した「24個の文字列」が入る幅がコンソール画面の推奨サイズであることと同様に、この文字列がずれてしまう環境では遊びにくい仕様になっています。標準出力・入力だけで遊ぶことを目指していますが、やはり画面を作らないと文字がずれたりする弊害もあります。(ちなみに、私の環境でのコマンドプロンプトとVSCodeでのPowserShellでは文字ずれは起きませんでした。だから良いという訳ではないですので改良の余地ありです。)

注意

ここで定義した定数は、コードを書きなおした経緯もあり使っていないものも含まれていますのでご注意ください。

Processクラスに処理を追加する

最後にprocess.pyのProcessクラスも処理を一気に追加します。変更したコード全体が以下です。

from event import Event
from text import Text


class Process:
    UP = 'w'
    DOWN = 's'
    LEFT = 'a'
    RIGHT = 'd'
    ESC = 'q'
    ITEM = 'e'
    STATUS = 'z'
    DECISION = 'x'
    HELP = 'c'
    EMPTY = ''
    KEY_LIST = {
        UP: ['w', 'W', 'w', 'W'],
        DOWN: ['s', 'S', 's', 'S'],
        LEFT: ['a', 'A', 'あ', 'A'],
        RIGHT: ['d', 'D', 'd', 'D'],
        ESC: ['q', 'Q', 'q', 'Q'],
        ITEM: ['e', 'E', 'え', 'E'],
        STATUS: ['z', 'Z', 'z', 'Z'],
        DECISION: ['x', 'X', 'x', 'X'],
        HELP: ['c', 'C', 'c', 'C'],
        EMPTY: ['', ' ', ' '],
    }

    @staticmethod
    def show_title():
        """タイトルを表示
        """
        print(Text.TITLE)

    @staticmethod
    def input_player_name() -> str:
        """プレイヤーの名前を入力

        Returns:
            str: プレイヤー名
        """
        print(Text.MES_INPUT_PLAYER_NAME)
        player_name = Event.input()

        # プレイヤー名が長い場合
        if len(player_name) >= Text.PLAYER_NAME_MAX_LENGTH:
            print(Text.MES_PLAYER_NAME_IS_TOO_LONG)
            Event.input()
            return ''

        # プレイヤー名が未入力の場合
        if len(player_name) == 0:
            return ''

        return player_name

    @staticmethod
    def confirm_input_player_name(player_name: str) -> str:
        """プレイヤーの応答がYesかNoを確認

        Args:
            player_name (str): プレイヤー名

        Returns:
            str: プレイヤー名
        """
        print(Text.QUESTION_ANSWER.format(player_name))
        player_name_answer = Event.input()
        if Event.is_yes(player_name_answer):
            return player_name
        else:
            return ''

    @classmethod
    def input_player_key(cls) -> str:
        """標準入力の受付

        Returns:
            str: バリデーションされた文字列
        """
        while True:
            input_key = Event.input()
            for key, value in cls.KEY_LIST.items():
                if input_key in value:
                    return key

            # 不正な入力の場合
            else:
                print(Text.MES_CAN_NOT_USE_KEY)

    @staticmethod
    def show_player_status(player):
        """プレイヤーのステータス詳細を表示

        Args:
            player (Player): インスタンス
        """
        # 次レベルまでの必要経験値
        required_exp = player.level**2 - player.exp

        # 概要ステータスを表示
        print(Text.PLAYER_STATUS.format(
            player.name,
            player.hp,
            player.max_hp,
            player.mp,
            player.max_mp,
        ))

        # 詳細ステータスを表示
        print(Text.PLAYER_STATUS_DETAIL.format(
            player.level,
            player.exp,
            required_exp,
            player.power,
            player.defense,
        ))
        Event.input()

Textクラスを作成したので、Titleの表示や詳細ステータスの表示など、標準出力をしようするプロセスを一気に追加しました。追加した処理を見ていきましょう。

    @staticmethod
    def show_title():
        """タイトルを表示
        """
        print(Text.TITLE)

上はタイトルを表示させるメソッドです。

    @staticmethod
    def input_player_name() -> str:
        """プレイヤーの名前を入力

        Returns:
            str: プレイヤー名
        """
        print(Text.MES_INPUT_PLAYER_NAME)
        player_name = Event.input()

        # プレイヤー名が長い場合
        if len(player_name) >= Text.PLAYER_NAME_MAX_LENGTH:
            print(Text.MES_PLAYER_NAME_IS_TOO_LONG)
            Event.input()
            return ''

        # プレイヤー名が未入力の場合
        if len(player_name) == 0:
            return ''

        return player_name

上はプレイヤー名の入力を受け付ける処理です。文字列が長い場合と未入力の場合を処理していますが、使ってほしくない単語のリストなどを作って、もしリストにある文字列を使っている場合に対処する処理などを作っても良いかもしれませんね。

    @staticmethod
    def show_player_status(player):
        """プレイヤーのステータス詳細を表示

        Args:
            player (Player): インスタンス
# ~~以下略~~

上はプレイヤーのステータス詳細を表示させる処理です。mainループ中にステータス表示キーが押された場合に呼び出される想定です。

まとめ

ということで、Eventクラス、Textクラス、Processクラスの作成を終えました。

具体的にゲームではまだ使っていないので、少しイメージを持ちずらいかもしれません。入念にクラス設計した場合などには、こういうケースもあるかもしれませんが、今回の場合は事前にコードを作成し、それを説明しながら記述していった結果、少しわかりずらい構成となってしまいました。すみません。

部品をたくんさん作っている様な回になりましたが、ゴールまでもう少しというところでしょうか。

最後まで読んで頂きありがとうございました。次回もまた皆さんにお会いできることを楽しみにしております。

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

この記事を書いた人

小幡 知弘

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