↓ メインコンテンツへスキップ
  1. 第12講 PyGame を用いたソフトウェア開発(2/3)/

Player型の導入

Player型の導入
#

前回の例題11-7で作成したプログラムでは、メインループの中にキャラクタに対する処理や、描画処理など様々な処理が詰め込まれていました。 各処理を関数にまとめたとしても、どのキーを押した時にどのキャラクタを動かすのかをメインループ内で判断すると、1つのプログラムが長くなり、見通しが悪くなります。 その結果、保守性が下がり、修正や機能追加が困難になります。

この問題を解決する一つの方法がオブジェクト指向の導入です。 ある特定のモノに関連するデータ、処理を一つのクラスにまとめて、そのクラスから作成したオブジェクトに対して、処理を依頼します。

例題11-7では、キャラクタの画像と位置、大きさに関する処理をPlayerというクラスにまとめていきましょう。

これが実現できれば、Player を扱う側のプログラム(ex12-1.py)は以下のように全体の見通しがしやすくなります。

import pygame as pg # PyGame をインポートし、pg という名前で使えるようにする。
import sys          # sys モジュールをインポートする(終了処理で使う)。
import player       # 別の player.py に定義されている Player 型のインポートする。

# 1. ゲームの準備をする。
pg.init()
screen = pg.display.set_mode((800, 600))
clock = pg.time.Clock()
player = player.Player(sys.argv[1])

# 2. メインループ
while True:
    # 2-1 画面を初期化する。
    screen.fill(pg.Color("WHITE"))  # 画面を白色で塗りつぶす。

    # 2-3. 絵を描いたり、判定したりする。
    player.blit(screen)

    # 2-4. 画面を表示する。
    pg.display.update()
    clock.tick(60) # 画面の更新を1秒間に60回以下のスピードに設定する。

    # 2-5. 閉じるボタンが押されたら終了する。
    for event in pg.event.get():  # イベント一覧を取得して、各イベントを調べる。
        if event.type == pg.QUIT: # もし、閉じるボタンが押されたら。
            pg.quit()             # PyGame を終了する。これだけではウィンドウは閉じない。
            sys.exit()            # ウィンドウを閉じて、プログラムを終了する。

一方の Player クラスでは、初期化と、メインループ内の処理を分けて書いています。 変数の名前、参照方法は異なりますが、処理内容は[例題7]と同じです。

import pygame as pg

class Player:
    def __init__(self, img_file):
        self.img = pg.image.load(img_file)
        rect = self.img.get_rect()
        (self.ix, self.iy) = 1, 0  # 画像中のどの部分を表示するのかをインデックス。
        (self.GW, self.GH) = (rect.width / 3, rect.height / 4)
        (self.dx, self.dy) = 0, 0  # 移動量
        self.rect = pg.Rect(0, 0, 32, 32) # 初期位置

    def blit(self, parent):
        # 2-3. 絵を描いたり、判定したりする。
        keys = pg.key.get_pressed()
        if keys[pg.K_UP]:
            self.dx, self.dy, self.iy = 0, -1, 3 # 移動方向と画像を更新する。
        elif keys[pg.K_DOWN]:
            self.dx, self.dy, self.iy = 0, 1, 0
        elif keys[pg.K_LEFT]:
            self.dx, self.dy, self.iy = -1, 0, 1
        elif keys[pg.K_RIGHT]:
            self.dx, self.dy, self.iy = 1, 0, 2
        else:
            self.dx, self.dy = 0, 0

        nbox = self.rect.move(self.dx, self.dy) # 移動先の座標を調べる。
        if parent.get_rect().contains(nbox):    # 移動先の座標が screen の内側であれば
            self.rect = nbox                    # 移動先を更新する。
        parent.blit(self.img, self.rect, (self.ix * self.GW, self.iy * self.GH, self.GW, self.GH))

足踏み
#

プレイヤを動かすとき、既存のゲームであればプレイヤが足踏みをします。 前回紹介したキャラクターチップには、足踏み用の画像も提供されています。 そのため、Player型の blit メソッド内で ix を適切に変更することで足踏みが実現できます。 ただし、blit が呼び出されるごとに self.ix = self.ix % 3 とすると以下のように非常に早い足踏みになります。

これを避けるには、blit が何回か呼び出されるまで self.ix を更新しないようにしましょう。 更新頻度を ANIMATION_STEPS 回に1回にするには、 例えば、self.steps = self.steps + 1で呼び出し回数を記録しておき、 self.steps % ANIMATION_STEPS == 0 のときに self.ix を更新するようにします。

適当なスピードで更新されるようANIMATION_STEPSの値を各自調整してください。

🏁 足踏み機能を入れたPlayer
import pygame as pg

MAX_IX = 3
MAX_IY = 4
ANIMATION_STEPS = 20

class Player:
    def __init__(self, img_file):
        self.img = pg.image.load(img_file)
        rect = self.img.get_rect()
        (self.ix, self.iy) = 1, 0  # 画像中のどの部分を表示するのかをインデックス。
        (self.GW, self.GH) = (rect.width / MAX_IX, rect.height / MAX_IY)
        (self.dx, self.dy) = 0, 0  # 移動量
        self.rect = pg.Rect(0, 0, self.GW, self.GH) # 初期位置
        self.steps = 0

    def blit(self, parent):
        # 2-3. 絵を描いたり、判定したりする。
        # steps を更新する。MAX_IX * ANIMATION_STEPS で割った余りを用いているのは
        # Python3 の整数型に上限はないが、あまりに大きな数だと余分なメモリを使うので、
        # 上限を設けている。
        self.steps = self.steps + 1
        if self.steps % ANIMATION_STEPS == 0:
            self.steps = 0  # steps をリセットする。
            self.ix = (self.ix + 1) % MAX_IX
        keys = pg.key.get_pressed()
        if keys[pg.K_UP]:
            self.dx, self.dy, self.iy = 0, -1, 3 # 移動方向と画像を更新する。
        elif keys[pg.K_DOWN]:
            self.dx, self.dy, self.iy = 0, 1, 0
        elif keys[pg.K_LEFT]:
            self.dx, self.dy, self.iy = -1, 0, 1
        elif keys[pg.K_RIGHT]:
            self.dx, self.dy, self.iy = 1, 0, 2
        else:
            self.dx, self.dy = 0, 0

        nbox = self.rect.move(self.dx, self.dy) # 移動先の座標を調べる。
        if parent.get_rect().contains(nbox):    # 移動先の座標が screen の内側であれば
            self.rect = nbox                    # 移動先を更新する。
        parent.blit(self.img, self.rect, (self.ix * self.GW, self.iy * self.GH, self.GW, self.GH))

足踏み用にクラスを抽出する
#

先ほどの例で、Player型に足踏みの機能を追加しました。 しかし、機能を追加するたびにクラスの中に処理を追加していくと切り出した Player型が複雑になっていきます。 そのため、足踏みの機能をSteper型に取り出して新たな型を作成しましょう。 Steper型は、Playerのblitが呼び出されるたびに1度呼び出され、self.ixの更新が必要かを返すことにします。

class Steper:
    def __init__(self, animation_steps = 20):
        self.steps = 0
        self.animation_steps = animation_steps
    
    # blitの呼び出しごとに呼び出され、ix の更新が必要であるかを返す。
    # 更新が必要なら True を返し、そうでないなら False を返す。
    def is_step(self):
        self.steps = self.steps + 1 # steps に1追加していく。
        if self.steps == self.animation_steps: # steps が一定数になれば、リセットし、True を返す。
            self.steps = 0                     # つまり、ix の更新が必要であると呼び出し元に伝える。
            return True
        return False                           # そうでなければ更新は不要であるため、False を返す。
import pygame as pg

MAX_IX = 3
MAX_IY = 4
ANIMATION_STEPS = 20

class Player:
    def __init__(self, img_file):
        self.img = pg.image.load(img_file)
        rect = self.img.get_rect()
        (self.ix, self.iy) = 1, 0  # 画像中のどの部分を表示するのかをインデックス。
        (self.GW, self.GH) = (rect.width / MAX_IX, rect.height / MAX_IY)
        (self.dx, self.dy) = 0, 0  # 移動量
        self.rect = pg.Rect(0, 0, self.GW, self.GH) # 初期位置
        self.steper = Steper(ANIMATION_STEPS)

    def blit(self, parent):
        # 2-3. 絵を描いたり、判定したりする。
        # steps を更新する。MAX_IX * ANIMATION_STEPS で割った余りを用いているのは
        # Python3 の整数型に上限はないが、あまりに大きな数だと余分なメモリを使うので、
        # 上限を設けている。
        if self.steper.is_step():
            self.ix = (self.ix + 1) % MAX_IX
        keys = pg.key.get_pressed()
        if keys[pg.K_UP]:
            self.dx, self.dy, self.iy = 0, -1, 3 # 移動方向と画像を更新する。
        elif keys[pg.K_DOWN]:
            self.dx, self.dy, self.iy = 0, 1, 0
        elif keys[pg.K_LEFT]:
            self.dx, self.dy, self.iy = -1, 0, 1
        elif keys[pg.K_RIGHT]:
            self.dx, self.dy, self.iy = 1, 0, 2
        else:
            self.dx, self.dy = 0, 0

        nbox = self.rect.move(self.dx, self.dy) # 移動先の座標を調べる。
        if parent.get_rect().contains(nbox):    # 移動先の座標が screen の内側であれば
            self.rect = nbox                    # 移動先を更新する。
        parent.blit(self.img, self.rect, (self.ix * self.GW, self.iy * self.GH, self.GW, self.GH))

このようにすることで、Player にあった、いつ足踏みするかの判定処理が self.steper.is_step() の評価に置き換わりました。 こうした判定処理や複雑な計算式などといった一連の処理は、その部分を作ってからしばらくすると、なぜこのような処理や計算式にしているのかを忘れます。

忘れたときのために、思い出すためのコメントを書けば良いのですが、 コメントは良くも悪くもプログラムの内容と一致している必要がありません。 そのため、修正漏れがいつか必ず発生します。 これを避けるために、コメントではなくメソッド名(関数名)を使って説明するようにします。

もちろん、メソッド(関数)の名前も、中身を表す名前でなくても動きますが、そのような場合、ソースコードの読みやすさ(可読性)が低いと判断されます。 すなわち、メソッド(関数)名の修正は、コメントの修正よりも修正するためのモチベーションが高いわけです。 このようなことから、近年ではコメントで処理内容を説明するよりも、メソッド(関数)名で説明することが多くなっています。

処理をクラスに切り出すのも、同じような意図で行なっています。

キャラクターの向きをクラスに抜き出す
#

同じように、Player型に含まれているキャラクターチップの ixやiyの処理もCharacterというクラスに抜き出してみましょう。

class Character:
    def __init__(self, img_file):
        self.img = pg.image.load(img_file)
        rect = self.img.get_rect()
        (self.ix, self.iy) = 1, 0  # 画像中のどの部分を表示するのかをインデックス。
        (self.GW, self.GH) = (rect.width / MAX_IX, rect.height / MAX_IY)
        self.steper = Steper(ANIMATION_STEPS)

    def turn_down(self):  # 下に向く
        self.iy = 0
    def turn_left(self):  # 左に向く
        self.iy = 1
    def turn_right(self): # 
        self.iy = 2
    def turn_up(self):
        self.iy = 3

    def blit(self, parent, rect):
        if self.steper.is_step():
            self.ix = (self.ix + 1) % MAX_IX
        parent.blit(self.img, rect, (self.ix * self.GW, self.iy * self.GH, self.GW, self.GH))

このようなクラスを導入すると、Player型の処理がどのようになるかを考えてみましょう。

class Player:
    def __init__(self, img_file):
        self.character = Character(img_file)
        (self.dx, self.dy) = 0, 0  # 移動量
        self.rect = pg.Rect(0, 0, self.character.GW, self.character.GH) # 初期位置

    def blit(self, parent):
        # 2-3. 絵を描いたり、判定したりする。
        # steps を更新する。MAX_IX * ANIMATION_STEPS で割った余りを用いているのは
        # Python3 の整数型に上限はないが、あまりに大きな数だと余分なメモリを使うので、
        # 上限を設けている。
        keys = pg.key.get_pressed()
        if keys[pg.K_UP]:
            self.dx, self.dy = 0, -1 # 移動方向と画像を更新する。
            self.character.turn_up()
        elif keys[pg.K_DOWN]:
            self.dx, self.dy = 0, 1
            self.character.turn_down()
        elif keys[pg.K_LEFT]:
            self.dx, self.dy = -1, 0
            self.character.turn_left()
        elif keys[pg.K_RIGHT]:
            self.dx, self.dy = 1, 0
            self.character.turn_right()
        else:
            self.dx, self.dy = 0, 0

        nbox = self.rect.move(self.dx, self.dy) # 移動先の座標を調べる。
        if parent.get_rect().contains(nbox):    # 移動先の座標が screen の内側であれば
            self.rect = nbox                    # 移動先を更新する。
        self.character.blit(parent, self.rect)

先のPlayer型では self.iy に直接、値を代入していましたが、修正版では turn_upなどのメソッド呼び出しに置き換わっています。 そのために1行増えてはいますが、turn_up、turn_leftなど意味のある呼び出しになったことで、 押されたキーと向く方向が一致していることが一目見てわかるようになりました。 ixの更新も Character型に吸収されたため、Player型はユーザの操作とどこに描画するのかだけに注目すれば良いようになりました。 また、最後にblitを呼び出しているところも、Player型は、描画するキャンバス(parent)と描画位置(self.rect)にのみ注目していれば良く、他は Character型の中で適切に処理してくれます。

このように型ごとに注目する対象を決め、それだけに集中できるように、他のことは他の型に任せることがオブジェクト指向的な考え方に繋がっていきます。

例題12-1. 障害物を導入する
#

今度は、障害物(Block)クラスを導入し、キャラクタと障害物が重ならないようにしましょう。 32x32の大きさの障害物をランダムに30個配置しましょう。

import pygame as pg
class Block:
    def __init__(self, x, y):
        self.rect = pg.Rect(x, y, 32, 32)

    def blit(self, parent): # ブロックを黒色で描画する。
        pg.draw.rect(parent, pg.Color("BLACK"), self.rect)

    def collide(self, other): # 他の矩形とぶつかっているか調べる。
        return self.rect.colliderect(other)

# target_value を value の最も近い倍数に丸める。
def round_to_nearest_multiple_of(number, multiple):
    assert multiple > 0, "multiple は正の整数でなければなりません。"
    # 1. 求めたい倍数の1/2の値を足す(2で行う切り捨てを四捨五入相当の処理に変換している)。
    # 2. 整数除算で multiple の倍数を求めている。
    # 3. 2で求めた値に multiple を掛けて、元の値に最も近い multiple の倍数に戻している。
    return (number + multiple // 2) // multiple * multiple

def random_location(area, assigned_blocks):
    import random
    while True:
        x = random.randint(area.left, area.right - 32) # 障害物の左上の x 座標
        y = random.randint(area.top, area.bottom - 32) # 障害物の左上の y 座標
        x = round_to_nearest_multiple_of(x, 32)   # x を32の倍数に丸める。
        y = round_to_nearest_multiple_of(y, 32)   # y を32の倍数に丸める。
        new_rect = pg.Rect(x, y, 32, 32)  # 新しい障害物を作成する。
        # 既存の障害物とぶつかっていなければ、その位置を返す。
        if new_rect.collidelist(assigned_blocks) == -1:
            return (x, y)

# 障害物を n 個、area の範囲内に配置する関数。
# すでに存在するオブジェクト(assigned_rects)には重ならないようにする。
def generate(n, area, assigned_objects):
    results = []
    blocks = assigned_objects
    # 障害物が n 個になるまで繰り返す。
    while len(results) < n:
        (x, y) = random_location(area, blocks)
        new_block = Block(x, y)  # 新しい障害物を作成する。
        results.append(new_block)
        blocks.append(new_block.rect)
    return results

次のようなプログラム(ex12-2.py)で障害物を配置しましょう。 メインループの前に障害物を30個作成し、blocks リストに保存します。 そして、blocksリストから各障害物を表す矩形を取り出して blocks_rects リストに保存します。 Player オブジェクトを描画するとき、blit メソッドに blocks_rects リストを渡し、移動先が障害物と重ならないようにしています。

import pygame as pg # PyGame をインポートし、pg という名前で使えるようにする。
import sys          # sys モジュールをインポートする(終了処理で使う)。
import player       # 別の player.py に定義されている Player 型のインポートする。
import blocks       # 別の blocks.py に定義されている Block 型のインポートする。

# 1. ゲームの準備をする。
pg.init()
screen = pg.display.set_mode((800, 600))
clock = pg.time.Clock()
player = player.Player(sys.argv[1])
blocks = blocks.generate(30, screen.get_rect(), [player.rect]) # 障害物を30個作成する。
blocks_rects = [b.rect for b in blocks]      # 障害物の矩形リスト

# 2. メインループ
while True:
    # 2-1 画面を初期化する。
    screen.fill(pg.Color("WHITE"))  # 画面を白色で塗りつぶす。

    # 2-3. 絵を描いたり、判定したりする。
    player.blit(screen, blocks_rects) # プレイヤの描画と移動判定
    for b in blocks:                  # 各障害物を描画する。
        b.blit(screen)

    # 2-4. 画面を表示する。
    pg.display.update()
    clock.tick(60) # 画面の更新を1秒間に60回以下のスピードに設定する。

    # 2-5. 閉じるボタンが押されたら終了する。
    for event in pg.event.get():  # イベント一覧を取得して、各イベントを調べる。
        if event.type == pg.QUIT: # もし、閉じるボタンが押されたら。
            pg.quit()             # PyGame を終了する。これだけではウィンドウは閉じない。
            sys.exit()            # ウィンドウを閉じて、プログラムを終了する。

このとき、Player型の blit メソッドを以下のように修正します。

    def blit(self, parent, blocks_rects):
        # ...  前半を省略している。

        nbox = self.rect.move(self.dx, self.dy) # 移動先の座標を調べる。
        # 移動先の座標がブロックに重なっていない かつ screen の内側であれば、移動先を更新する。
        if nbox.collidelist(blocks_rects) == -1 and parent.get_rect().contains(nbox):
            self.rect = nbox
        self.character.blit(parent, self.rect)

実行結果
#

以下の画像は何度か実行した結果をクリックで切り替えられるようになっています。