今回は今までの知識を使って簡単なゲームを作ってみます。まだGUIについては詳細に扱っていないため、ゲームはCLI(ターミナル、コマンドプロンプト)を使ったものとなります。

どういうものを作るか

国民的某RPGを簡単にしたようなゲームを作ります。大人の事情で画像は出せないのですが、以下のような画面は見たことがありますね。

オリジナルのとおりであればモンスターとのバトルなどもあるのでしょうが、今回はとりあえず、

  • 主人公が長方形型のマップを歩く
  • 主人公は町人と話すことができる

という機能のみを実装します。クソゲーですが最初はこんなもので我慢してください(笑)。

開発の流れ

最初にすべてのクラスを設計して一気に詳細を作り込むのではなく、機能拡張をしながら徐々に作りこんでいきたいと思います。

ただ、何も考えずに作り始めると後々の修正が大変になるため、以下の図ようなアーキテクチャにしたいと思います。

なぜこのようなアーキテクチャとなったか理解する必要があります。まずゲームがどのように構成されているかよく考えてみてください。ゲームの構成要素にはマップがあり、そこに主人公や町人がいますね。そのため、Mapクラスを作り、それに主人公の Heroクラスや、町人のTownsmanクラスを持たせます。町人は複数いるため、Townsman を複数持つtownspeopleという配列を使っています。

実装の方法はさまざまでしょうが、今回はキーボード入力をHeroクラスが読み取り、 その入力に応じて画面をアップデートするというものにします。

実装手順は大まかに以下のような工程とします。

  1. Heroクラスの実装1: キーボードからの入力を読み取る
  2. Heroクラスの実装2: 入力に応じて、x,y座標の更新と向きに応じたアイコンのアップデート
  3. Mapクラスの実装: 主人公がマップを歩き回れるようにする
  4. Townsmanクラスの実装: 主人公に会話機能の追加

この1~4の実装が完了すると、最終的には以下の図のような形でプログラムが動くようになります。

オブジェクト試行的な観点から考えると、重要になるのはMapに情報を持たせ過ぎないということです。特に意識を払わず設計すると、主人公や町人といったすべての座標をMapクラスで管理するようなコードになる可能性が高そうですが、今回は、座標は基本的にHeroやTownsman自身で管理させるようにします。

キー入力を読み取る

まず第1工程として、勇者Heroクラスがキー入力を読み取ることから始めます。これにはキーボードのキー入力を読み取る関数を利用します。以前、raw_input()を使って Enterが押されるまでの複数のキー入力をまとめて読み取ることをしましたが、それの ひとつのキー版だと思っていただければ大丈夫です。

残念ながらPythonにはキー入力をひとつだけ読み取る関数がないので、今回はgetchという既存のライブラリを使います。おそらくget charに名前が由来していますね。自分でダウンロードしていただいてもかまわないのですが、以下に私が利用したコードも一応置いておきます。

getch.py

では、さっそくコードを書き始めてみます。基本的にはwhile文で無限ループさせて、キーを読み取り表示するという流れです。なお、IDLEなどで動作させると動かない可能性があるのでターミナルやコマンドプロンプトから実行してください。

プログラムを以下に記載します。

import getch

class Hero:
    def run(self):
        while(True):
            key = ord(getch.getch())
            if(key == 3): # Ctrl-C: Quit
                print('bye!!')
                break;
            print('key input: ' + str(key))

hero = Hero()
hero.run()

少し丁寧に説明します。まずプログラムファイルと同じディレクトリにあるgetch.pyをimportしています。そしてHeroクラスのrunメソッドを実行すると無限ループに入り、

  1. キー入力を読み取る
  2. 入力値をord関数を使って整数にする
  3. 入力値を画面に表示

とさせています。

ただ、これだけだとプログラムが終了ができなくなるので、Ctrl-Cが入力されたらプログラムを終了するようにしています。具体的には、Ctrl-Cが押されたらkey = ord(getch.getch()) によりkeyは3になります。そしてif文でkeyが3になっているかを確認しています。これは要するにCtrl-Cが押されたかということの確認と同じです。

このプログラムを起動して a s d f Ctrl-C と押すと以下のようになりました。

% python test.py
key input: 97
key input: 115
key input: 100
key input: 102
bye!!

簡単ですね。

勇者の位置情報とアイコン

キー入力が読み取れるようになったので、次はマップ上の勇者を動かすための実装を始めます。まず以下の絵を見て下さい。勇者や王様がいるマップにはグリッドがあり、そこにキャラクターが配置されていることがわかりますね。

この図でいうと王様はx = 3, y = 1にいます。そして主人公はx =3, y = 3にいます。

勇者は基本的にこのグリッドに沿って動きます。そのため、今回は先程のHeroクラスを押されたキーに応じてx,y座標を更新し、キャラクターのアイコンを向きに応じたものに変化させるという拡張をします。アイコンは上向きが ^ 、左が < 、右が > 、下が V となります。要するに矢印ですね。

なお、キーボードの矢印キーは機種依存のようでしたので代わりに、

  • 上: w
  • 左: a
  • 右: s
  • 下: z

    としています。

コードを以下に記載します。

import getch

class Hero:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.icon = '^'

    def run(self):
        print('-----------------------------')
        print('w:up, a:left, s:right, z:down')
        print('ctrl-c:quit')
        print('-----------------------------')

        while(True):
            key = ord(getch.getch())
            if(key == 3): # Ctrl-C: Quit
                print('bye!!')
                break;
            elif(key == 119): # W: Up
                self.icon = '^'
                self.y -= 1
            elif(key == 97):  # A: Left
                self.icon = '<'
                self.x -= 1
            elif(key == 115): # S: Right
                self.icon = '>'
                self.x += 1
            elif(key == 122): # Z: Down
                self.icon = 'V'
                self.y += 1
            else:
                continue
            print(self.icon + ' X:' + str(self.x) + ', Y:' + str(self.y))

hero = Hero(0, 0)
hero.run()

先ほどのコードからの変更点としては、コンストラクタで座標とアイコンを初期化し、キー入力に応じてx, yの値とアイコンを更新するようにしたことが挙げられます。また最初にキーの使い方のメッセージも出していますね。

わかると思いますが左に行くということは x 座標が1 減るということなので、'self.x -= 1' としてxの値を1減らしています。他の方向もこれと同じで座標に +-1しています。

実行すると以下のようになります。

YUIITO-M-64WZ% python test.py
-----------------------------
w:up, a:left, s:right, z:down
ctrl-c:quit
-----------------------------
V X:0, Y:1
V X:0, Y:2
V X:0, Y:3
V X:0, Y:4
< X:-1, Y:4
< X:-2, Y:4
< X:-3, Y:4
< X:-4, Y:4
> X:-3, Y:4
> X:-2, Y:4
> X:-1, Y:4
> X:0, Y:4
^ X:0, Y:3
^ X:0, Y:2
^ X:0, Y:1
^ X:0, Y:0
bye!!

押されたキーによってアイコンが変更され、x,y座標が更新されていることがわかります。

マップの実装

勇者の座標を更新できるようになったので、次は実際にマップを作成して勇者を動かせるようにしたいと思います。

決められた枠内を勇者が移動できるようにするために、勇者が自分のx, y座標を更新する前に「そこに移動できるか」を確認し、移動できる場合のみ更新をします。たとえば座標0,0は枠内ですが、0,-1は枠外なので移動できません。

つまりx = 1, y = 0にいる勇者が左に行きたい場合、「ひとつ左のマスである x = 0, y = 0 に移動できるか」を確認し、動けるので座標を更新します。そして勇者のアイコンの向きを左に更新します。

一方、x = 1, y = 0の際に上に異動したい場合は「ひとつ上のマスであるx = 1, y = -1に移動できるか」を確認し、これが枠外のため動けないので座標を更新しません。ただ、勇者のアイコンの向きだけは上向きに更新します。

まず新しく作ったマップクラスのコードを見てみます。重要なのはis_movableメソッドとupdateメソッドです。

import getch

class Map:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        # 関数渡しで下記2つのメソッドを勇者インスタンスに渡す
        self.hero = Hero(3, 3, self.is_movable, self.update)

    def run(self):
        self.hero.run()

    # 勇者が座標 x, y に動ければ True を返す
    def is_movable(self, x, y):
        if(x < 0):
            return False
        elif(self.width-1 < x):
            return False
        elif(y < 0):
            return False
        elif(self.height-1 < y):
            return False

        return True

    # 画面に現在の状態を表示
    def update(self):
        characters = {}
        characters[(self.hero.x, self.hero.y)] = self.hero.icon

        def get_top_bottom_text():
            return '+' + '-' * self.width + '+\n'

        map_list = []
        map_list.append(get_top_bottom_text())
        for y in range(0, self.height):
            map_list.append('|')
            for x in range(0, self.width):
                if((x, y) in characters):
                    map_list.append(characters[(x,y)])
                else:
                    map_list.append(' ')
            map_list.append('|\n')
        map_list.append(get_top_bottom_text())
        print(''.join(map_list))

移動できるかの確認は勇者というよりもマップに依存しているため、その判定は勇者クラスではなくマップクラスのis_movableというメソッドに実装しています。

このメソッドに勇者が移動したい先のx, y座標を渡すと、それがマップの上限、下限からはみ出ていないかをチェックし、はみ出ていれば False(移動できない)、はみ出ていなければTrue(移動できる)を返します。そして勇者はこの結果に従って自分の座標を更新します。

次にupdateメソッドはマップを枠付きで表示し、勇者も座標に沿った位置に描画されます。コードを読んでみれば何をやっているかわかると思いますが、以下のように描画しています。

  1. 勇者などのキャラクターをDictionaryに、キーを座標のタプル (x座標,y座標)、Valueをアイコンとして格納
  2. 一番上の列(枠)を表示
  3. 左端の枠を表示
  4. 列の1マスを表示。キャラクターがいればアイコン、いなければ空白
  5. 右端の枠を表示し改行
  6. 3~5をマップの高さ繰り返す
  7. 一番下の列(枠)を表示

なお、実際は毎回プリントするのではなく、リストに文字列をどんどん追加していき、最後にそれを画面に出力させています。IO処理の回数を減らすために、まとめて出力させています。

コンストラクタである__init__を見てもらうとわかりますが、このis_movableメソッドとupdateメソッドは「関数渡し」を使ってHeroクラスに渡されています。

こうすることでマップクラスのメソッドであるis_movableなどを、Mapのインスタンスを経由せずに勇者クラスが直接呼び出せるようにしています。これはHeroクラスに親であるのMapクラスのインスタンスを渡すよりも「意図しない使い方をされない」という面で優れています。

少しむずかしいと思うので以下の図を使って説明します。

上記図では「MapがHero を持っていて、HeroはMapのメソッドを使いたい」とします。 ただ、Heroが使うのはfunction Aのみであり、function Bはまた別の用途で使われているとしましょう。

上側の例では、MapがHeroインスタンスを作成するときに自分自身をインスタンスとしてheroに渡します。Heroインスタンスは渡されたMapのインスタンスを経由してMapのメソッドを呼び出します。たとえば以下のコードのような例です。

class Map:
    def __init__(self):
        self.hero = Hero(self)

    def function_a(self):
        print('function a')

    def function_b(self):
        print('function b')

    def test(self):
        self.hero.test()

class Hero:
    def __init__(self, map1):
        self.map1 = map1

    def test(self):
        self.map1.function_a()
        self.map1.function_b()

m = Map()
m.test()

# function a
# function b

このとき、HeroのインスタンスはMapのインスタンスを経由してfunction_aを呼び出せていますが、本来Heroが触るべきでないfunction_bまで呼び出せてしまっていますね。これはあまりよくないです。

一方、下側の例では関数渡しをしてfunction_aをHeroインスタンスに渡しているので、function_bは普通であれば呼びだされません。

サンプルコードは以下となります。

class Map:
    def __init__(self):
        self.hero = Hero(self.function_a)

    def function_a(self):
        print('function a')

    def function_b(self):
        print('function b')

    def test(self):
        self.hero.test()

class Hero:
    def __init__(self, function_a):
        self.function_a = function_a

    def test(self):
        self.function_a()

m = Map()
m.test()

# function a

コードとしては両者の違いはそれほど多くないのですが、違いに注意してください。

話を戻しましょう。次に更新したHeroクラスを示します。

class Hero:
    def __init__(self, x, y, is_movable, update):
        self.x = x
        self.y = y
        self.icon = '^'
        self.is_movable = is_movable
        self.update = update

    def run(self):
        print('-----------------------------')
        print('w:up, a:left, s:right, z:down')
        print('ctrl-c:quit')
        print('-----------------------------')
        self.update()

        while(True):
            key = ord(getch.getch())
            if(key == 3): # Ctrl-C: Quit
                print('bye!!')
                break;
            elif(key == 119): # W: Up
                self.icon = '^'
                if(self.is_movable(self.x, self.y-1)): self.y -= 1
            elif(key == 97):  # A: Left
                self.icon = ''
                if(self.is_movable(self.x+1, self.y)): self.x += 1
            elif(key == 122): # Z: Down
                self.icon = 'V'
                if(self.is_movable(self.x, self.y+1)): self.y += 1
            self.update()

m = Map(7,7)
m.run()

見てもらうとわかるように、Heroクラス内で関数渡しで渡されたis_movableとupdateを呼び出しています。それ以外は特に大きな変更はありませんね。

町人の実装

最後に町人を実装します。まず町人のインスタンスにx,y座標とアイコン、それからメッセージを持たせています。

import getch

class Townsman:
    def __init__(self, x, y, icon, message):
        self.x = x
        self.y = y
        self.icon = icon
        self.message = message

そしてMapクラスに町人を配列として持たせています。その際に町人を初期化していますね。

またis_movable関数で町の枠の判定だけでなく、「そこに町人がいるか」という判定も追加しています。主人公が町人のいるマスに動けないようにするためです。

そして新しいメソッドであるget_messageでは、指定したx,y座標の町人からメッセージを取得します。実装を見ればわかりますが町人の配列をループで回して、そこに町人がいればメッセージを取得して返しています。ループで何もヒットしない、つまりそこに町人がいなければ「誰もいない」というメッセージを返しています。

最後の変更はコンストラクタ内のHeroの初期化です。初期化時にis_movable、update に加えて、このget_messageも関数渡しで渡すようにしています。

class Map:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.hero = Hero(3, 3, self.is_movable, self.get_message, self.update)
        self.townspeople = []
        self.townspeople.append(Townsman(3, 1, 'K', "I'm King"))
        self.townspeople.append(Townsman(1, 5, 'S', "I'm Soldier 1"))
        self.townspeople.append(Townsman(5, 5, 'S', "I'm Soldier 2"))

    def run(self):
        self.hero.run()

    def is_movable(self, x, y):
        if(x < 0):
            return False
        elif(self.width-1 < x):
            return False
        elif(y < 0):
            return False
        elif(self.height-1 < y):
            return False

        for townsman in self.townspeople:
            if(x == townsman.x and y == townsman.y):
                return False

        return True

    def update(self, message=''):
        characters = {}
        for townsman in self.townspeople:
            characters[(townsman.x, townsman.y)] = townsman.icon
        characters[(self.hero.x, self.hero.y)] = self.hero.icon

        def get_top_bottom_text():
            return '+' + '-' * self.width + '+\n'
        def get_message_border(width):
            return '#' * width + '\n'

        map_list = []
        map_list.append(get_top_bottom_text())
        for y in range(0, self.height):
            map_list.append('|')
            for x in range(0, self.width):
                if((x, y) in characters):
                    map_list.append(characters[(x,y)])
                else:
                    map_list.append(' ')
            map_list.append('|\n')
        map_list.append(get_top_bottom_text())

        map_list.append(get_message_border(10))
        map_list.append(message + '\n')
        map_list.append(get_message_border(10))

        print(''.join(map_list))

    def get_message(self, x, y):
        for townsman in self.townspeople:
            if(x == townsman.x and y == townsman.y):
                return townsman.message
        return 'no one exists..'

最後にHeroクラスです。これは簡単で、Mapで定義されたget_message関数を関数渡しで受け取り、キーdを押されたときに呼び出すようにしています。

dを押されたときにどの座標の住人に話しかけるかは主人公が向いている方向で変わってくるので、メソッドtalkが新しく実装され、そこで話しかけるべきx,y座標を求めています。

class Hero:
    def __init__(self, x, y, is_movable, get_message, update):
        self.x = x
        self.y = y
        self.icon = '^'
        self.is_movable = is_movable
        self.get_message = get_message
        self.update = update

    def run(self):
        print('-----------------------------')
        print('w:up, a:left, s:right, z:down, d:talk')
        print('ctrl-c:quit')
        print('-----------------------------')
        self.update()

        while(True):
            key = ord(getch.getch())
            if(key == 3): # Ctrl-C: Quit
                print('bye!!')
                break;
            elif(key == 119): # W: Up
                self.icon = '^'
                if(self.is_movable(self.x, self.y-1)): self.y -= 1
            elif(key == 97):  # A: Left
                self.icon = ''
                if(self.is_movable(self.x+1, self.y)): self.x += 1
            elif(key == 122): # Z: Down
                self.icon = 'V'
                if(self.is_movable(self.x, self.y+1)): self.y += 1
            elif(key == 100): # D: talk
                self.talk()
                continue
            self.update()

    def talk(self):
        if(self.icon == '^'):
            message = self.get_message(self.x, self.y-1)
        elif(self.icon == ''):
            message = self.get_message(self.x+1, self.y)
        elif(self.icon == 'V'):
            message = self.get_message(self.x, self.y+1)
        else:
            print('Error')
            exit()
        self.update(message)

m = Map(7,7)
m.run()

ソフトウェアの設計について

個人的な意見となってしまうのですが、ソフトウェアの設計はわりと低いレベルでの プログラミングの経験が土台となります。不安定な土台の上に建物を建てられないように、低いレベルでの実装やオブジェクト指向の理解では、正しい設計をすることは一般的に難しいと考えられます。

まずは今回程度の数百行レベルのコードでもよいので、自分で一からコードを書き始めて、ある程度なれたら数千行程度のアプリケーションを書いてみるのが上達の早道だと思います。

なお、今回は比較的簡単な場当たり的な形で拡張を繰り返しました。規模の小さいプログラムの場合は、このような構成手法でも問題ないと思います。ただ、開発するコードの規模や関わる人員の数が増えてくると、このような場当たり的な設計手法では開発の工程の後になればなるほど修正が難しくなってきますので注意してください。ただ、4~5人で数万行のコードを書くぐらいならこんな感じでも全然いけると思いますよ。


次回からオブジェクト指向の後半戦に入ります。といっても前半より内容は少なく、主に継承とポリモーフィズムがメインの内容となります。次回からもよろしくお願いします。

執筆者紹介

伊藤裕一(ITO Yuichi)

シスコシステムズでの業務と大学での研究活動でコンピュータネットワークに6年関わる。専門はL2/L3 Switching とデータセンター関連技術およびSDN。TACとしてシスコ顧客のテクニカルサポート業務に従事。社内向けのソフトウェア関連のトレーニングおよびデータセンタとSDN関係の外部講演なども行う。

もともと仮想ネットワーク関連技術の研究開発に従事していたこともあり、ネットワークだけでなくプログラミングやLinux関連技術にも精通。Cisco社内外向けのトラブルシューティングツールの開発や、趣味で音声合成処理のアプリケーションやサービスを開発。

Cisco CCIE R&S, Red Hat Certified Engineer, Oracle Java Gold,2009年度 IPA 未踏プロジェクト採択

詳細(英語)はこちら