LoginSignup
4
1

OpenCV+pygameで簡単な立ち絵付きシューティングゲームを作るお気軽ゲーム入門(前編)

Last updated at Posted at 2023-12-15

OpenCV+pygameで簡単な立ち絵付きシューティングゲームを作るお気軽ゲーム入門

注意

この記事は大学の課題でゲーム制作をした時のものをまとめ直して書いています。
この記事は長いので2つに分けます。後編はこちら->後編
ゲーム制作自体は初心者です。そのため至らぬ点もあると思います。
この記事はGoogle Developer Student Clubs 12/15のアドカレとして寄稿しています。前後編に分けたのは記事が長かったためであり, 間に合わなかったというわけではありません。本当です。信じてください。

pygameでシューティングゲームを作る

今回はゲーム開発初心者向けにpygameによるゲーム開発入門をしたいと思います。pygameを使う理由は主に以下の2つです。

  1. Pythonなので実装速度が速い
  2. ビルドが楽, 必要なライブラリを揃えてメインファイルを実行するだけ

Unityで作る場合, 自分が前にやった時はまずはUnity Hubを入れてバージョン管理して, roll-a-ballをやって, 何か作りたいものを見つけて作ろうとしたらUnityの仕様に悪戦苦闘した記憶がありましたが今日説明する内容はゲームプログラミングの簡単なロジックとちょっとしたpygameの仕様のみです。これを読んでからUnityやUnreal Engineなどに進んでもいいかなと思います。

今回この記事で作るシューティングゲームの最終的な完成図は以下のようになります。
スクリーンショット 2023-12-14 23.24.57.png

今回の記事で制作したものをかなり改良させたものが大学で制作したものであり, その図は以下のとおりです。
F_7OkwWa8AACQMg.jpeg

目次

  1. OpenCVで素材画像切り抜き
  2. 設計図紹介
  3. 実装
    consts.py
    classes.py
    events.py(後編にて実装)
    main.py(後編にて実装)

1. OpenCVで素材画像切り抜き

OpenCV要素は正直ここだけです。ここの章は大学のOpenCVGL実験でゲーム制作をやったということのために追加しました。素材に使う透過画像が既にある方, 画像処理に特に興味はない方は読み飛ばしてもらっても構いません。
ここに、一枚のシューティングゲームの素材として使うグリーンバック画像があったとします。

applecat.jpg

このままでは画像を読み込ませた時に緑の背景が邪魔をしてしまい, ゲームの素材として使うことができません。そこで画像編集で使われるOpenCVを使います。
背景透過の作業としては以下の手順になります。

  1. 透過する色を決める
  2. 画像を透明度を表すパラメーター付き(BGRA形式)で読み込み, 透過する色に等しい部分にマスク(BGRAのAの成分を0にする)をかける
  3. 画像を出力する

BGRAはpngなどで採用されている画像フォーマットであり, これは画像をB(青)・G(緑)・R(赤)・A(透明度)で表現しますという意味です。このAチャンネルがない場合は画像の透明度を操作することができないため, 一度BGRA形式に変換する必要があると覚えておいてください。
上の1, 2, 3を実行するコードは以下のコードになります。

import cv2
import numpy as np
frame = cv2.imread("applecat.png")
transparent_color = (0, 255, 0)  #背景の色に合わせて調整

mask = np.all(frame == transparent_color, axis=-1) # 透明背景を作成
alpha_channel = np.where(mask, 0, 255) #マスクを制作
rgba_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2BGRA) #BGRA形式に変換 
rgba_frame[:, :, 3] = alpha_channel #Aチャンネルにマスクをかける
cv2.imwrite("transparent_applecat.png",rgba_frame)

transparent_applecat.png

2. 設計図紹介

今回作るゲームのディレクトリ構造などを話します。
今回作るゲームの構造は以下のようにしてください。

.
├── assets
│   ├── fonts
│   │   └── font.ttf
│   ├── images
│   │   ├── background
│   │   │   ├── GAMEOVER.png
│   │   │   ├── black.png
│   │   │   └── background.png
│   │   ├── effects
│   │   │   ├── enemy_bullet.png
│   │   │   └── player_bullet.png
│   │   ├── enemy
│   │   │   └── enemy.png
│   │   └── fighter
│   │       ├── applecat.png
│   │       └── player.png
│   └── music
│       └── bgm.mp3 
├── classes.py
├── consts.py
├── events.py
└── main.py

本当は.pyファイルはsrcディレクトリでまとめた方が良いですが, 今回はゲーム制作ディレクトリの中にそのまま置きました。
assetsはゲームで使う文字フォント, 画像, 音楽などを入れるディレクトリです。
.pyファイルは実行フォルダのmain.py, キーボード操作などを制御する関数を入れておくevents.py, ゲーム中変化することのない定数を管理するconsts.py, ゲームオブジェクトをまとめるclasses.pyの合計4つです。
適切にファイルを分割することで組みやすくなります。

3. 実装

consts.py->classes.py->events.py->main.pyの順番に実装をしていきましょう。なるべくわかりやすく解説していきます。
events.pyとmain.pyは第二回で解説します。

consts.py

consts.pyにはゲーム中の定数をいれておきます。

#game中の定数

FPS = 60
WINDOW_WIDTH = 1000
WINDOW_HEIGHT = 700

#プレイヤーの状態を表す変数
PLAYER_DEAD = -1
PLAYER_ALIVE = 1
PLAYER_SIZE = (28, 33)
BULLET_SIZE = (9,10)
BULLET_SPEED = 600
BULLET_INTERVAL = 100
PLAYER_SPEED = 240 # dx = v * dtを活かすこと

#敵の状態を表す変数
ENEMY_SIZE = (28, 33)
ENEMY_BULLET_SIZE = (9,10)
ENEMY_BULLET_SPEED = 300
ENEMY_SPEED = 30

EXPLODE_SIZE = (38, 36)

classes.py

classes.pyではゲーム内オブジェクトを定義します。ここからはpygameのライブラリの中身がでてきますので, 躓いた際はpygame日本語wikiをご覧ください。

ゲームオブジェクトは, 画像をゲーム画面に表示するためのシンプルな基底クラスであるpygame.sprite.Spriteを継承して制作します。このクラスを継承後、updateメソッドをオーバーライドして, self.imageとself.rectを継承することでゲームオブジェクトとしてゲーム内で扱うことができます。それぞれのメソッドとプロパティの意味は以下の通りになります。

  • updateメソッド
    ゲーム内で1フレームごとに呼び出されるメソッド, 1フレームごとに変化を記述するメソッドとして考える
  • self.image
    ゲーム内で表示される画像, 見た目を決めるもの
  • self.rect
    ゲームオブジェクトの実態,長方形を指定し, 当たり判定の計算などに使われる

今回はプレイヤーを表すPlayer, 敵を表すEnemy, プレイヤーの弾を表すBullet, EnemyBullet, 撃破時の爆発を表すExplode合計5つのクラスを実装します。

classの実装コードを載せる前に, もう少しpygameについて補足しなければなりません,
self.rectを設定した後, それと同時にそのゲームオブジェクトにはself.x, self.yというx座標とy座標を表すプロパティが追加されます。しかし, そのxとyはpygame内部では以下の位置を意味します。
スクリーンショット 2023-12-14 23.24.57.png

このゲームエンジンによる座標の定義の仕方の違い問題はゲーム制作大好きな友達によるとかなり大きな問題らしく, 常に気を配らなければいけません。今回は2Dゲームなので大きな問題にはなりませんが、3Dゲームの場合、y座標が高さを表すこともありますが、z座標が高さを表す場合もあります。

ではまずはPlayer classを見てみましょう。

classes.py class Player
import pygame
from pygame.locals import *
from consts import *
import numpy as np

class Player(pygame.sprite.Sprite):
    def __init__(self):
        pygame.sprite.Sprite.__init__(self, self.containers)
        image = pygame.image.load("assets/images/fighter/player.png")
        self.image = pygame.transform.scale(image, PLAYER_SIZE)
        self.rect = self.image.get_rect(center = (WINDOW_WIDTH/2, WINDOW_HEIGHT/2))
        self.x = WINDOW_HEIGHT - 100
        self.y = WINDOW_WIDTH - 650
        self.bullet_interval = BULLET_INTERVAL 
        self.player_level = 1
        self.hp = 1
        self.player_state = PLAYER_ALIVE

    def update(self):
        self.rect.center = (self.y, self.x) #(width, height)
    
    def shoot_bullet(self):
        if pygame.time.get_ticks() > self.bullet_interval:
            self.bullet_interval = pygame.time.get_ticks() + BULLET_INTERVAL
            Bullet(self.x - 10, self.y + 10)
            Bullet(self.x - 10, self.y - 10)

Player classの実装内容を説明していきます。

  • __init__
    pygame.sprite.Sprite.__init__(self, self.containers)
    これはpygame.sprite.Spriteの継承ももちろんですが、self.containersは複数のゲームオブジェクトを管理するための初期化引数です。詳しくは後編のmain.pyで解説しますが、「pygame pygame.sprite.Sprite containers」で検索するといい説明をしているサイトが見つかるかもしれません。今回の記事のようなmain.pyにする時はとりあえずこの形にしておいて問題ありません。

    image = pygame.image.load("assets/images/fighter/player.png")
    ここで画像を読み込み, pygame.transform.scaleであらかじめ設定しておいた画像サイズにリサイズしています。

    self.rect = self.image.get_rect
    ここは画像を元に矩形情報を読み取ります。

    self.bullet_interval = BULLET_INTERVAL
    self.player_level = 1
    self.hp = 1
    self.player_state = PLAYER_ALIVE
    この辺りはさまざまなプレイヤーの状態を決定しています。bullet_intervalはプレイヤーの銃のクールタイムを, player_levelはプレイヤーのレベルを, hpはプレイヤーの許容されるヒット回数を, player_stateはプレイヤーの生死を表しています。

  • update
    self.rect.center = (self.y, self.x)

    updateではプレイヤーの位置を変更します。playerのみ, events.pyで実装予定のキーボードイベントでself.yとself.xが変化するため, updateメソッドではこういう実装にしています。コードの意味はself.rectの中心座標を設定しています。この座標は(width方向, height方向)の順に指定しなければなりません。つまり, (self.y, self.x)です。

  • shoot_bullet

    if pygame.time.get_ticks() > self.bullet_interval:
        self.bullet_interval = pygame.time.get_ticks() + BULLET_INTERVAL
    

    pygame.time.get_ticks()はゲームが始まってからの時間をミリ秒単位で取得する関数です。BULLET_INTERVALは100でしたから, 一度弾が撃たれてからもう一度撃つためには100ミリ秒末必要があります。これを表したものがこのif文とその次の1文になります。

    Bullet(self.x - 10, self.y + 10)
    Bullet(self.x - 10, self.y - 10)

    詳しい話は後編にしますが, containersプロパティを持つpygame.sprite.Sprite継承オブジェクトはクラスを定義するだけでゲームのオブジェクトとして画面に追加されます。

次に, Bullet classの定義をみてみましょう。

classes.py class Bullet
import pygame
from pygame.locals import *
from consts import *
import numpy as np

class Bullet(pygame.sprite.Sprite):
    def __init__(self, x, y):
        pygame.sprite.Sprite.__init__(self, self.containers)
        image = pygame.image.load("assets/images/effects/player_bullet.png")
        self.image = pygame.transform.scale(image, BULLET_SIZE)
        self.rect = self.image.get_rect(
            center=(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2)
        )
        self.x = x
        self.y = y

    def update(self):
        self.x -= BULLET_SPEED / FPS
        self.rect.center = (self.y, self.x)  # (width, height)
        # x < 0ならkillすると良い

Bullet classの実装内容を説明していきます。

  • __init__
    引数にx, yが追加されました。これはx, yの位置にBulletオブジェクトが出現するように指定したいからです。

  • updateの説明の前に
    ここで、時間で移動するものに関する処理にはゲーム内FPSが絡まないか常に考えるようにしましょう。その理由を解説します。
    少し先取りになりますが, main.pyでは以下のようなロジックでゲームが進行します。

while (ゲームが終了と判定されるまで) then
    (入力キーの処理)
    (ゲーム内オブジェクトの更新処理)
    (プレイヤーと敵の当たり判定処理)
    
FPS制御されるので1秒間にwhile文はFPS回だけ繰り返される

このため, FPSが変化しても問題ないようにupdateメソッドなどでは処理をかかなければなりません。

  • update
    弾は画面上側に飛んでいきます。つまり, 上で述べた画面の座標の定義によるとx座標が減る方向に進みます。
    ここで, self.x -= BULLET_SPEED / FPSの意味について解説します。
    小学生の頃をおもいだしてほしいのですが, 速さ(SPEED)は1秒間に進む距離を表します。
    updateは1フレーム毎に呼び出され, FPSは一秒間に繰り返されるフレーム数であることを考えると, 1フレームの間に1/FPS秒かかるはずです。よって, x座標の変化がFPSを変えても変化しないようにするためには, (距離) = (速さ)×(時間)の式を思い出してあげると, self.x -= BULLET_SPEED / FPSにしなければなりません。これは僕がゲーム開発をした時に初めて陥った罠であり, みんなにも気をつけて欲しいと思います。
    今回はBULLET_SPEED = 600として定義をしたので, 1秒に600ピクセル進む弾が出来上がりました。

次に, Enemy classの定義をみてみましょう。

classes.py class Enemy
import pygame
from pygame.locals import *
from consts import *
import numpy as np

class Enemy(pygame.sprite.Sprite):
    def __init__(self, x, y):
        pygame.sprite.Sprite.__init__(self, self.containers)
        image = pygame.image.load("assets/images/enemy/enemy.png")
        self.image = pygame.transform.scale(image, ENEMY_SIZE)
        self.rect = self.image.get_rect(
            center=(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2)
        )
        self.x = x
        self.y = y
        self.hp = 1

    def update(self):
        self.x += ENEMY_SPEED / FPS
        self.rect.center = (self.y, self.x)  # (width, height)
        self.shoot_bullet()

    def shoot_bullet(self):
        if np.random.rand() < 1 / FPS:
            EnemyBullet(self.x, self.y)

Enemy classの実装内容を説明していきます。initとupdateはほぼ同じなので問題ないと思います。問題はupdateメソッドのたびに呼び出されているshoot_bulletメソッドです。

  • shoot_bullet
    if np.random.rand() < 1 / FPS:
            EnemyBullet(self.x, self.y)
    
    思い出して欲しいのはshoot_bulletメソッドはupdateメソッドの中で1フレームごとに呼び出されているということです。1秒間にFPSフレーム呼び出されることを考えると, 上の文は簡単な頻度計算を考えるとだいたい1秒に一回弾が撃たれるということを意味します。だんだん頭が痛くなってきましたが, FPSに依存しないゲーム処理を書いておくことは, 動的にFPSが変化するマインクラフトなどのゲームを遊んでいてもどうしてFPSによらず普通にゲームオブジェクトが動いて見えるのかなどの理解に近づきます。

次に, EnemyBullet classの定義をみてみましょう。

classes.py class EnemyBullet
import pygame
from pygame.locals import *
from consts import *
import numpy as np

class EnemyBullet(pygame.sprite.Sprite):
    def __init__(self, x, y):
        pygame.sprite.Sprite.__init__(self, self.containers)
        image = pygame.image.load("assets/images/effects/enemy_bullet.png")
        self.image = pygame.transform.scale(image, ENEMY_BULLET_SIZE)
        self.rect = self.image.get_rect(
            center=(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2)
        )
        self.x = x
        self.y = y

    def update(self):
        self.x += ENEMY_BULLET_SPEED / FPS
        self.rect.center = (self.y, self.x)  # (width, height)
        # x < 0ならkillすると良い

これは上までの説明が理解できていれば特に困ることはないかと思います。

次に, 敵がやられた時の爆発エフェクトを表すExplode classの定義をみてみましょう。

classes.py class Explode
class Explode(pygame.sprite.Sprite):
    def __init__(self, x, y):
        pygame.sprite.Sprite.__init__(self, self.containers)
        image = pygame.image.load("assets/images/effects/explode.png")
        self.image = pygame.transform.scale(image, EXPLODE_SIZE)
        self.rect = self.image.get_rect(
            center=(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2)
        )
        self.explode_duration = FPS * 1
        self.rect.center = (y, x)

    def update(self):
        self.explode_duration -= 1
        if self.explode_duration < 0:
            self.kill()
  • __init__
    初期化のためにx, y座標があります。これは敵がやられた時に敵の座標を初期化に使うためです。
    self.explode_duration = FPS * 1
    これは爆破がフィールド上に残る時間を指定しています。

  • update
    updateメソッドは呼び出されるたびにself.explode_durationが1だけ減っていきます。そして0になったときExplodeオブジェクトが削除されます。

class Explodeの発展案

賢い方は気づいたかもしれませんが, 上の案では爆発エフェクトは静止画が1秒間表示されるだけです。しかし, 爆発エフェクトは爆破の起こりから爆破の収束までをアニメーションにしたいと考える人も多いかと思います。そこで今回は大学の課題でアニメーションにした方針だけを話したいと思います。

1. たくさん爆発の画像を用意する

爆発のためにStg Builder @ wiki素材うpろだで爆破の画像素材を集めました。スクリーンショット 2023-12-16 2.01.21.png
↑たくさんの爆破画像

2. updateメソッドでdurationに応じて画像を変更する

アニメーションを作るためにはある一定秒数で画像を変化させる必要があります。アニメとか映画のなんちゃらfpsってのはそういう意味です。
FPS / 10 ごとに画像をexp1->exp2->exp3->....->exp10と変化させるコードを書いてみてください。この時update関数内部で

self.image = pygame.image.load(f"assets/images/effects/exp{idx}.png")

とすることで画像を変化させうことができます。

StateVariable

さて, ゲーム内オブジェクトは以上で定義し終わりましたが, ゲームの進行中に変化するスコア, ゲームの進行度合いといった変数があります。それらもclassでまとめておいたほうが後々便利となります。これをStateVariable classとしてまとめてしまいましょう。

class StateVariable:
    score = 0
    game_clear: bool = False
    # ゲームの進行記録はphaseなどの名前のpropertyを記録すると良い

今回はゲームの進行記録などはないので追加しなかったが, もしあるならphaseといった名前のプロバティを追加すると良い

まとめ

前編ではOpenCVによる画像処理, ディレクトリ構造の紹介, ゲームオブジェクトクラスなどを開設しました。後編では当たり判定, キーボード操作, ゲーム全体の完成を目指します。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1