【AI + pygame】pygameで作るインベーダー風ゲーム 第2回 改造編その2(壁の爆発・敵の移動範囲変更・追加攻撃)

公開:2019年04月19日

(2019年4月19日更新)

エンジニアのMです。
強化学習(DQN)で使うネットワーク構成、パラメータ設定に毎日頭を悩ませています。
そちらはなんとか間に合わせるとして、pygame によるインベーダー風ゲームの改造記事の第2回を書いていきたいと思います。

前回はPython3用の移植作業に半分くらい割いたので、ゲームの改造自体は壁を作っただけでした。
今回からはゲーム内容の改造に本腰を入れていきましょう。

改造後のコードと画像データは、こちらからダウンロードできます。
-> pygame_invader_2.zip

壁に爆発エフェクトを適用する

壁爆発

壁が壊れたとき、壁にミサイル、ビームが当たったときに爆発エフェクトを表示するようにしましょう。
エイリアン撃破時のエフェクトがあるので、そのまま使い回します。
壁のほうは2倍に引き伸ばした画像を使って対応します(explosion2.png)。

別画像を登録できるように、 Explosion クラスを継承して別のクラスを用意しましょう。

class Invader:
    def init_game(self):
        """ゲームオブジェクトを初期化"""
        # 略
        Explosion.containers = self.all
        ExplosionWall.containers = self.all
        # 略

    def load_images(self):
        """イメージのロード"""
        # 略
        Explosion.images = split_image(load_image("explosion.png"), 16)
        ExplosionWall.images = split_image(load_image("explosion2.png"), 16)


class Explosion(pygame.sprite.Sprite):
    # 略

class ExplosionWall(Explosion):
    pass

中身は一切いじらないので、ExplosionWall クラスに必要なのは pass の一文だけです。
もっとエフェクトを追加する場合は、 Effect クラスを作るなどしてまとめて管理できる体制を整えたほうが良いでしょう。

2倍サイズの爆発エフェクトも登録できたので、衝突判定を以下のように変更します。
爆発の効果音についてはエイリアン用のものを流用します。

class Invader:
    def collision_detection(self):
        # 略
        # 壁とミサイル、ビームの衝突判定
        hit_dict = pygame.sprite.groupcollide(self.walls, self.shots, False, True)
        hit_dict.update(pygame.sprite.groupcollide(self.walls, self.beams, False, True))
        for hit_wall in hit_dict:
            hit_wall.shield -= len(hit_dict[hit_wall])
            for hit_beam in hit_dict[hit_wall]:
                Alien.kill_sound.play()
                Explosion(hit_beam.rect.center)  # ミサイル・ビームの当たった場所で爆発
            if hit_wall.shield <= 0:
                hit_wall.kill()
                Alien.kill_sound.play()
                ExplosionWall(hit_wall.rect.center)  # 壁の中心で爆発

hit_dict[hit_wall] で取得できるミサイル・ビームは、その前の groupcollide による判定で削除済みのもので、リスト型になっています。
削除に使っているメソッド kill() はスプライトグループからの削除を行うだけで実体はそのまま残るため、座標の取得などは問題なく行えます。
作成時の返り値を保存するなどして実体にアクセスできるようにしておけば、後でスプライトグループに再登録することもできます。

この仕様だと、使い終わったミサイル・ビームが溜まっていくように感じるかもしれません。
しかし、hit_dict が破棄された時点でこれらの実体にアクセスできなくなるので、すぐに削除されると考えられます。
(参考:Google検索「python ガベージコレクション」

エイリアンの移動範囲変更

今の仕様ではエイリアンの移動範囲は、それぞれの初期位置から230pxとなっています。
そのため、エイリアンの数が少なくなっても同じ狭い範囲を往復しつづけます。
これを、両端のエイリアンがいる位置を基準に方向転換するようにしましょう。

class Invader:
    def update(self):
        """ゲーム状態の更新"""
        if self.game_state == PLAY:
            self.all.update()
            # エイリアンの方向転換判定
            turn_flag = False
            for alien in self.aliens:
                if (alien.rect.center[0] < 15 and alien.speed < 0) or \
                        (alien.rect.center[0] > SCR_RECT.width-15 and alien.speed > 0):
                    turn_flag = True
                    break
            if turn_flag:
                for alien in self.aliens:
                    alien.speed *= -1
            # ミサイルとエイリアン、壁の衝突判定
            self.collision_detection()
            # エイリアンをすべて倒したらゲームオーバー
            if len(self.aliens.sprites()) == 0:
                self.game_state = GAMEOVER

残っているエイリアンについて、1体でもx座標が端から15px以内に存在すれば、全てのエイリアンの速度を反転させます。
この時エイリアンの速度も条件にしているのは、エイリアンの初期位置設定に対する保険の意味合いが強いです。

不要になった Alien クラス側の反転処理の削除も忘れずに行いましょう。

class Alien(pygame.sprite.Sprite):
    """エイリアン"""
    speed = 2  # 移動速度
    animcycle = 18  # アニメーション速度
    frame = 0
    #move_width = 230  # 横方向の移動範囲
    prob_beam = 0.005  # ビームを発射する確率
    def __init__(self, pos):
        # imagesとcontainersはmain()でセット
        pygame.sprite.Sprite.__init__(self, self.containers)
        self.image = self.images[0]
        self.rect = self.image.get_rect()
        self.rect.center = pos
        '''
        self.left = pos[0]  # 移動できる左端
        self.right = self.left + self.move_width  # 移動できる右端
        '''
    def update(self):
        # 横方向への移動
        self.rect.move_ip(self.speed, 0)
        # 移動方向反転はまとめて処理するので封印
        '''
        if self.rect.center[0] < self.left or self.rect.center[0] > self.right:
            self.speed = -self.speed
        '''
        # ビームを発射
        if random.random() < self.prob_beam:
            Beam(self.rect.center)
        # キャラクターアニメーション
        self.frame += 1
        self.image = self.images[self.frame//self.animcycle%2]

ここではコメントアウトにしていますが、実際には削除してしまったほうが見やすいと思います。

プレイヤーの位置を認識して攻撃させる

今のエイリアンはランダムに弾をばらまくだけなので、プレイヤーが近くにいるときは弾を多く撃つように変更しましょう。

しかし Alien クラスが直接プレイヤーの位置を知ることはできません。
後から Invader クラスの update() の度に、

Alien.target_x_pos = self.player.rect.center[0]

のように座標を更新し続けることもできますが、今回は別の方法を採ります。

ここでは Alien クラスに shoot_extra_beam メソッドを実装して、Invader クラス側からプレイヤーの位置情報などを渡して判定します。
この方法だと、ぴったり重なった2発の弾が飛んでくる可能性がありますが、壁の破壊が多少早まるだけなので気にしないことにします。

class Alien(pygame.sprite.Sprite):
    """エイリアン"""
    # 略
    def shoot_extra_beam(self, target_x_pos, border_dist, rate):
        if random.random() < self.prob_beam*rate and \
                abs(self.rect.center[0] - target_x_pos) < border_dist:
            Beam(self.rect.center)

target_x_pos はプレイヤーのx座標、
border_dist はx軸上の距離に対する閾値、
rate は通常の発射確率に対する倍率です。

class Invader:
    def update(self):
        """ゲーム状態の更新"""
        if self.game_state == PLAY:
            # エイリアンの方向転換判定
            # 略
            # エイリアンの追加ビーム判定(プレイヤーが近くにいると反応する)
            for alien in self.aliens:
                alien.shoot_extra_beam(self.player.rect.center[0], 32, 2)
            # ミサイルとエイリアン、壁の衝突判定
            # 略

追加攻撃は、通常の発射判定とは別に判定するので、追加攻撃の確率が2倍でも実質的に弾数は3倍になります。
この記事のトップ画像を見返すとわかるように、2倍程度の設定でもプレイヤーの周りは弾があふれるようになります。

同じようにshoot_extra_beam メソッドに壁の座標を渡した場合、全力で壁を破壊しにいくエイリアンが誕生します。

まとめ

今回は3点の改造について紹介しました。
pygame は便利な機能をたくさん備えたゲームライブラリですが、クラスの概念がある程度分かっていないと使いづらい印象があります。
逆に言うと、ゲームを作りながらクラスの勉強になるライブラリでもあると言えそうです。

今回のデータはこちらから(再掲) -> pygame_invader_2.zip

コード全体は以下をクリックすると表示されます。

#!/usr/bin/env python
#coding: utf-8
import pygame
from pygame.locals import *
import os
import random
import sys

START, PLAY, GAMEOVER = (0, 1, 2)  # ゲーム状態
SCR_RECT = Rect(0, 0, 640, 480)

class Invader:
    def __init__(self):
        pygame.init()
        screen = pygame.display.set_mode(SCR_RECT.size)
        pygame.display.set_caption("Invader Part2")
        # 素材のロード
        self.load_images()
        self.load_sounds()
        # ゲームオブジェクトを初期化
        self.init_game()
        # メインループ開始
        clock = pygame.time.Clock()
        while True:
            clock.tick(60)
            self.update()
            self.draw(screen)
            pygame.display.update()
            self.key_handler()

    def init_game(self):
        """ゲームオブジェクトを初期化"""
        # ゲーム状態
        self.game_state = START
        # スプライトグループを作成して登録
        self.all = pygame.sprite.RenderUpdates()
        self.aliens = pygame.sprite.Group()  # エイリアングループ
        self.shots = pygame.sprite.Group()   # ミサイルグループ
        self.beams = pygame.sprite.Group()   # ビームグループ
        self.walls = pygame.sprite.Group()  # 壁グループ
        # デフォルトスプライトグループを登録
        Player.containers = self.all
        Shot.containers = self.all, self.shots
        Alien.containers = self.all, self.aliens
        Beam.containers = self.all, self.beams
        Wall.containers = self.all, self.walls
        Explosion.containers = self.all
        ExplosionWall.containers = self.all
        # 自機を作成
        self.player = Player()
        # エイリアンを作成
        for i in range(0, 50):
            x = 20 + (i % 10) * 40
            y = 20 + (i // 10) * 40
            Alien((x,y))
        # 壁を作成
        for i in range(4):
            x = 95 + i * 150
            y = 400
            Wall((x, y))

    def update(self):
        """ゲーム状態の更新"""
        if self.game_state == PLAY:
            self.all.update()
            # エイリアンの方向転換判定
            turn_flag = False
            for alien in self.aliens:
                if (alien.rect.center[0] < 15 and alien.speed < 0) or \
                        (alien.rect.center[0] > SCR_RECT.width-15 and alien.speed > 0):
                    turn_flag = True
                    break
            if turn_flag:
                for alien in self.aliens:
                    alien.speed *= -1
            # エイリアンの追加ビーム判定(プレイヤーが近くにいると反応する)
            for alien in self.aliens:
                alien.shoot_extra_beam(self.player.rect.center[0], 32, 2)
            # ミサイルとエイリアン、壁の衝突判定
            self.collision_detection()
            # エイリアンをすべて倒したらゲームオーバー
            if len(self.aliens.sprites()) == 0:
                self.game_state = GAMEOVER

    def draw(self, screen):
        """描画"""
        screen.fill((0, 0, 0))
        if self.game_state == START:  # スタート画面
            # タイトルを描画
            title_font = pygame.font.SysFont(None, 80)
            title = title_font.render("INVADER GAME", False, (255,0,0))
            screen.blit(title, ((SCR_RECT.width-title.get_width())//2, 100))
            # エイリアンを描画
            alien_image = Alien.images[0]
            screen.blit(alien_image, ((SCR_RECT.width-alien_image.get_width())//2, 200))
            # PUSH STARTを描画
            push_font = pygame.font.SysFont(None, 40)
            push_space = push_font.render("PUSH SPACE KEY", False, (255,255,255))
            screen.blit(push_space, ((SCR_RECT.width-push_space.get_width())//2, 300))
            # クレジットを描画
            credit_font = pygame.font.SysFont(None, 20)
            credit = credit_font.render("2019 http://pygame.skr.jp", False, (255,255,255))
            screen.blit(credit, ((SCR_RECT.width-credit.get_width())//2, 380))
        elif self.game_state == PLAY:  # ゲームプレイ画面
            self.all.draw(screen)
            shield_font = pygame.font.SysFont(None, 30)
            for wall in self.walls:
                shield = shield_font.render(str(wall.shield), False, (0,0,0))
                text_size = shield_font.size(str(wall.shield))
                screen.blit(shield, (wall.rect.center[0]-text_size[0]//2,
                                     wall.rect.center[1]-text_size[1]//2))
        elif self.game_state == GAMEOVER:  # ゲームオーバー画面
            # GAME OVERを描画
            gameover_font = pygame.font.SysFont(None, 80)
            gameover = gameover_font.render("GAME OVER", False, (255,0,0))
            screen.blit(gameover, ((SCR_RECT.width-gameover.get_width())//2, 100))
            # エイリアンを描画
            alien_image = Alien.images[0]
            screen.blit(alien_image, ((SCR_RECT.width-alien_image.get_width())//2, 200))
            # PUSH STARTを描画
            push_font = pygame.font.SysFont(None, 40)
            push_space = push_font.render("PUSH SPACE KEY", False, (255,255,255))
            screen.blit(push_space, ((SCR_RECT.width-push_space.get_width())//2, 300))

    def key_handler(self):
        """キーハンドラー"""
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()
            elif event.type == KEYDOWN and event.key == K_ESCAPE:
                pygame.quit()
                sys.exit()
            elif event.type == KEYDOWN and event.key == K_SPACE:
                if self.game_state == START:  # スタート画面でスペースを押したとき
                    self.game_state = PLAY
                elif self.game_state == GAMEOVER:  # ゲームオーバー画面でスペースを押したとき
                    self.init_game()  # ゲームを初期化して再開
                    self.game_state = PLAY

    def collision_detection(self):
        """衝突判定"""
        # エイリアンとミサイルの衝突判定
        alien_collided = pygame.sprite.groupcollide(self.aliens, self.shots, True, True)
        for alien in alien_collided.keys():
            Alien.kill_sound.play()
            Explosion(alien.rect.center)  # エイリアンの中心で爆発
        # プレイヤーとビームの衝突判定
        beam_collided = pygame.sprite.spritecollide(self.player, self.beams, True)
        if beam_collided:  # プレイヤーと衝突したビームがあれば
            Player.bomb_sound.play()
            self.game_state = GAMEOVER  # ゲームオーバー!
        # 壁とミサイル、ビームの衝突判定
        hit_dict = pygame.sprite.groupcollide(self.walls, self.shots, False, True)
        hit_dict.update(pygame.sprite.groupcollide(self.walls, self.beams, False, True))
        for hit_wall in hit_dict:
            hit_wall.shield -= len(hit_dict[hit_wall])
            for hit_beam in hit_dict[hit_wall]:
                Alien.kill_sound.play()
                Explosion(hit_beam.rect.center)  # ミサイル・ビームの当たった場所で爆発
            if hit_wall.shield <= 0:
                hit_wall.kill()
                Alien.kill_sound.play()
                ExplosionWall(hit_wall.rect.center)  # 壁の中心で爆発

    def load_images(self):
        """イメージのロード"""
        # スプライトの画像を登録
        Player.image = load_image("player.png")
        Shot.image = load_image("shot.png")
        Alien.images = split_image(load_image("alien.png"), 2)
        Beam.image = load_image("beam.png")
        Wall.image = load_image("wall.png")
        Explosion.images = split_image(load_image("explosion.png"), 16)
        ExplosionWall.images = split_image(load_image("explosion2.png"), 16)

    def load_sounds(self):
        """サウンドのロード"""
        Alien.kill_sound = load_sound("kill.wav")
        Player.shot_sound = load_sound("shot.wav")
        Player.bomb_sound = load_sound("bomb.wav")


class Player(pygame.sprite.Sprite):
    """自機"""
    speed = 5  # 移動速度
    reload_time = 15  # リロード時間
    def __init__(self):
        # imageとcontainersはmain()でセット
        pygame.sprite.Sprite.__init__(self, self.containers)
        self.rect = self.image.get_rect()
        self.rect.bottom = SCR_RECT.bottom  # プレイヤーが画面の一番下
        self.reload_timer = 0
    def update(self):
        # 押されているキーをチェック
        pressed_keys = pygame.key.get_pressed()
        # 押されているキーに応じてプレイヤーを移動
        if pressed_keys[K_LEFT]:
            self.rect.move_ip(-self.speed, 0)
        elif pressed_keys[K_RIGHT]:
            self.rect.move_ip(self.speed, 0)
        self.rect.clamp_ip(SCR_RECT)
        # ミサイルの発射
        if pressed_keys[K_SPACE]:
            # リロード時間が0になるまで再発射できない
            if self.reload_timer > 0:
                # リロード中
                self.reload_timer -= 1
            else:
                # 発射!!!
                Player.shot_sound.play()
                Shot(self.rect.center)  # 作成すると同時にallに追加される
                self.reload_timer = self.reload_time


class Alien(pygame.sprite.Sprite):
    """エイリアン"""
    speed = 2  # 移動速度
    animcycle = 18  # アニメーション速度
    frame = 0
    #move_width = 230  # 横方向の移動範囲
    prob_beam = 0.005  # ビームを発射する確率
    def __init__(self, pos):
        # imagesとcontainersはmain()でセット
        pygame.sprite.Sprite.__init__(self, self.containers)
        self.image = self.images[0]
        self.rect = self.image.get_rect()
        self.rect.center = pos
        '''
        self.left = pos[0]  # 移動できる左端
        self.right = self.left + self.move_width  # 移動できる右端
        '''
    def update(self):
        # 横方向への移動
        self.rect.move_ip(self.speed, 0)
        # 移動方向反転はまとめて処理するので封印
        '''
        if self.rect.center[0] < self.left or self.rect.center[0] > self.right:
            self.speed = -self.speed
        '''
        # ビームを発射
        if random.random() < self.prob_beam:
            Beam(self.rect.center)
        # キャラクターアニメーション
        self.frame += 1
        self.image = self.images[self.frame//self.animcycle%2]

    def shoot_extra_beam(self, target_x_pos, border_dist, rate):
        if random.random() < self.prob_beam*rate and \
                abs(self.rect.center[0] - target_x_pos) < border_dist:
            Beam(self.rect.center)


class Shot(pygame.sprite.Sprite):
    """プレイヤーが発射するミサイル"""
    speed = 9  # 移動速度
    def __init__(self, pos):
        # imageとcontainersはmain()でセット
        pygame.sprite.Sprite.__init__(self, self.containers)
        self.rect = self.image.get_rect()
        self.rect.center = pos  # 中心座標をposに
    def update(self):
        self.rect.move_ip(0, -self.speed)  # 上へ移動
        if self.rect.top < 0:  # 上端に達したら除去
            self.kill()


class Beam(pygame.sprite.Sprite):
    """エイリアンが発射するビーム"""
    speed = 5  # 移動速度
    def __init__(self, pos):
        # imagesとcontainersはmain()でセット
        pygame.sprite.Sprite.__init__(self, self.containers)
        self.rect = self.image.get_rect()
        self.rect.center = pos
    def update(self):
        self.rect.move_ip(0, self.speed)  # 下へ移動
        if self.rect.bottom > SCR_RECT.height:  # 下端に達したら除去
            self.kill()


class Explosion(pygame.sprite.Sprite):
    """爆発エフェクト"""
    animcycle = 2  # アニメーション速度
    frame = 0

    def __init__(self, pos):
        # imagesとcontainersはmain()でセット
        pygame.sprite.Sprite.__init__(self, self.containers)
        self.image = self.images[0]
        self.rect = self.image.get_rect()
        self.rect.center = pos
        self.max_frame = len(self.images) * self.animcycle  # 消滅するフレーム

    def update(self):
        # キャラクターアニメーション
        self.image = self.images[self.frame//self.animcycle]
        self.frame += 1
        if self.frame == self.max_frame:
            self.kill()  # 消滅

class ExplosionWall(Explosion):
    pass

class Wall(pygame.sprite.Sprite):
    """ミサイル・ビームを防ぐ壁"""
    shield = 100  # 耐久力

    def __init__(self, pos):
        # imagesとcontainersはmain()でセット
        pygame.sprite.Sprite.__init__(self, self.containers)
        self.rect = self.image.get_rect()
        self.rect.center = pos

    def update(self):
        pass


def load_image(filename, colorkey=None):
    """画像をロードして画像と矩形を返す"""
    filename = os.path.join("data", filename)
    try:
        image = pygame.image.load(filename)
    except pygame.error as message:
        print("Cannot load image:", filename)
        raise SystemExit(message)
    image = image.convert()
    if colorkey is not None:
        if colorkey is -1:
            colorkey = image.get_at((0,0))
        image.set_colorkey(colorkey, RLEACCEL)
    return image


def split_image(image, n):
    """横に長いイメージを同じ大きさのn枚のイメージに分割
    分割したイメージを格納したリストを返す"""
    image_list = []
    w = image.get_width()
    h = image.get_height()
    w1 = w // n
    for i in range(0, w, w1):
        surface = pygame.Surface((w1,h))
        surface.blit(image, (0,0), (i,0,w1,h))
        surface.set_colorkey(surface.get_at((0,0)), RLEACCEL)
        surface.convert()
        image_list.append(surface)
    return image_list


def load_sound(filename):
    """サウンドをロード"""
    filename = os.path.join("data", filename)
    return pygame.mixer.Sound(filename)


if __name__ == "__main__":
    Invader()

この記事が気に入ったら
いいね ! しよう

Twitter で
フォームズ編集部
オフィスで働く方、ネットショップやホームページを運営されている皆様へ、ネットを使った仕事の効率化、Webマーケティングなど役立つ情報をお送りしています。