3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GDSC JapanAdvent Calendar 2023

Day 19

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

Last updated at Posted at 2023-12-19

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

前編はこちらです。
この記事はGoogle Developer Student Clubs 12/19のアドカレとして寄稿しています。前後編に分けたのは記事が長かったためであり, 間に合わなかったというわけではありません。本当です。信じてください。

目次

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

前編のおさらい

  1. OpenCVで素材画像を切り抜く方法を学んだ
  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

3.ゲームオブジェクトクラスをpygame.sprite.Spriteを継承して作成した。ゲームの状況で変わる変数をまとめたクラスのStateVariableクラスをつくった。

events.py

今回は前編で作ったconsts.pyの値をよく参照するのでここで再掲載しておきます。また, pygameの仕様が詳しく知りたい人向けのために日本語wikiも再掲載しておきます。

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)

ではまずはplayerの動きを制御するplayer_keyevents関数を作っていきましょう。PlayerはWASD方式というよくある移動方式で移動をするように実装します。

events.py player_keyevents
import pygame
from pygame.locals import *
from classes import Player, Explode, Enemy, StateVariable
from consts import (
    PLAYER_SPEED,
    PLAYER_DEAD,
    PLAYER_ALIVE,
    WINDOW_HEIGHT,
    WINDOW_WIDTH,
    FPS,
)
from typing import List
import sys
import numpy as np
from time import sleep


def player_keyevents(player: Player) -> None:
    #引数にstatevariableを入れるとプレイヤーの行動に応じてstatevariableが変化するような状況を扱えるようになります。
    keys: List[bool] = pygame.key.get_pressed()
    if keys[K_w]:
        player.x -= PLAYER_SPEED / FPS
        if player.x < 0:
            player.x = 0
    if keys[K_s]:
        player.x += PLAYER_SPEED / FPS
        if player.x > WINDOW_HEIGHT:
            player.x = WINDOW_HEIGHT
    if keys[K_a]:
        player.y -= PLAYER_SPEED / FPS
        if player.y < 0:
            player.y = 0
    if keys[K_d]:
        player.y += PLAYER_SPEED / FPS
        if player.y > WINDOW_WIDTH - 300:
            player.y = WINDOW_WIDTH - 300
    if keys[K_ESCAPE]:
        pygame.quit()
        sys.exit()
    if keys[K_SPACE]:
        player.shoot_bullet()

player_keyeventsは引数にPlayerクラスのオブジェクトを入れることで動作する関数とします。
最初のif文付近のコードを見てみましょう。

keys: List[bool] = pygame.key.get_pressed()
    if keys[K_w]:
        player.x -= PLAYER_SPEED / FPS
        if player.x < 0:
            player.x = 0

一行目のコードはキーの入力状態を取得しています。返り値のkeysはbool型の変数がまとめられた配列です。
K_wはpygame.localsの中にある定数です。正直from hogehoge import *は名前空間が混ざりそうで非常に怖いのですが, 名前空間が混ざらないように慎重に命名しましょう。
keys[K_w]の意味は, wキーを押したか押してないかをTrueかFalseで表すというものです。
その後のplayerのx座標の変移の仕方は前編で述べたように, FPSを変化させても挙動が変化しないようなコードを書くためにPLAYER_SPEED / FPSを1フレームごとに変化させるコードにしています。
最後のif文ですが

if player.x < 0:
    player.x = 0

これはx座標が端に達したらそれ以上は動かないという意味です。これでゲームで言うところの壁の当たり判定を実装しています。pygameにおける座標の定義は以下のとおりであったことを思い出してください。
スクリーンショット 2023-12-14 23.24.57.png

Tips: if player.x == 0ではダメなのか?

結論: ダメです
playerは1フレームごとにPLAYER_SPEED / FPSだけx座標が変化します。引き算の前後で符号が代わり, player.x > 0からplayer.x < 0となる場合も十分あり得ます。そのために, 壁の当たり判定を考える時は不等式で評価しましょう。

他のsキー, aキーも同じように実装しています。
dキーの実装を見てみましょう。

if keys[K_d]:
    player.y += PLAYER_SPEED / FPS
    if player.y > WINDOW_WIDTH - 300:
        player.y = WINDOW_WIDTH - 300

y座標の壁の判定だけ他のものと異なっています。これは、今回はWINDOW_WIDTHのうち、プレイ領域を700×700にしたためです。(下画像参照)

スクリーンショット 2023-12-14 23.24.57のコピー.png

最後の2つのif文を見てみましょう。

if keys[K_ESCAPE]:
    pygame.quit()
    sys.exit()
if keys[K_SPACE]:
    player.shoot_bullet()

エスケープキーの処理はゲームを終了する処理です。
スペースキーの処理は, スペースキーを押している時はプレイヤーが弾を発射しますという意味です。
ここでもう少しplayer_keyeventsの実装について踏み込んでみましょう。

Tips: else ifではダメなのか?

こう思った人もいるかと思います。こうした場合のコードは以下のようになりますね

events.py player_keyevents
def player_keyevents(player: Player) -> None:
    #引数にstatevariableを入れるとプレイヤーの行動に応じてstatevariableが変化するような状況を扱えるようになります。
    keys: List[bool] = pygame.key.get_pressed()
    if keys[K_w]:
        player.x -= PLAYER_SPEED / FPS
        if player.x < 0:
            player.x = 0
    elif keys[K_s]:
        player.x += PLAYER_SPEED / FPS
        if player.x > WINDOW_HEIGHT:
            player.x = WINDOW_HEIGHT
    elif keys[K_a]:
        player.y -= PLAYER_SPEED / FPS
        if player.y < 0:
            player.y = 0
    elif keys[K_d]:
        player.y += PLAYER_SPEED / FPS
        if player.y > WINDOW_WIDTH - 300:
            player.y = WINDOW_WIDTH - 300
    elif keys[K_ESCAPE]:
        pygame.quit()
        sys.exit()
    elif keys[K_SPACE]:
        player.shoot_bullet()

この場合のコードはwキーを押している間はASDキーで動かせなかったり, 弾が発射できなくなってしまいます。そのため, ifで書きましょう

Tips: 斜め移動の時早くならないですか?

聡い人は気づくかもしれません, これに関してはその通りです。

上の実装ではWキーとDキーを押した時, 上にPLAYER_SPEED / FPS, 右にPLAYER_SPEED / FPSだけ移動します。つまり合計移動距離は$\sqrt{2} \times \text{(PLAYER_SPEED / FPS)}$となります。
速さの定義的には1フレームごとにPLAYER_SPEED / FPSだけ進んで進んで欲しいのでこれでは斜め移動の移動が早くなってしまいます。
これに関しては簡単な算数をして同時に押された時の移動距離を修正してあげましょう。今回は省略します。
(豆知識: 昔のファミコンゲームとかはこの仕様が残っていて斜め移動すると早くなってしまうらしいです。)

player_keyevents関数に関しては以上です。次はプレイヤーの当たり判定をcollision_detectionで実装しましょう。

events.py collision_detection
def collision_detection(
    player: Player, enemies: Enemy, enemy_bullets: EnemyBullet
) -> None:
    enemy_bullet_collided = pygame.sprite.spritecollide(
        player, enemy_bullets, True
    )
    if enemy_bullet_collided:
        player.hp -= 1
        Explode(player.x, player.y)
        if player.hp < 0:
            player.hp = 0
        if player.hp == 0:
            player.kill()
            player.player_state = PLAYER_DEAD

    enemy_collided = pygame.sprite.spritecollide(player, enemies, False)
    if enemy_collided:
        player.hp -= 1
        Explode(player.x, player.y)
        if player.hp < 0:
            player.hp = 0
        if player.hp == 0:
            player.kill()
            player.player_state = PLAYER_DEAD

collision_detectionは引数にPlayerクラス, Enemyクラス, EnemyBulletクラスのオブジェクトを入れることで動作する関数とします。
衝突判定にはpygame.sprite.spritecollideを用います。この関数はpygame.sprite.Spriteのサブクラスを引数に用いて, 第一引数が第二引数のpygame.sprite.Spriteのサブクラスオブジェクトと衝突したか否かを判定する関数である。第三匹数は衝突時に第二引数のオブジェクトを消去するか否かを決めるものである。衝突した場合の判定部分を詳しく見ていこう。

if enemy_bullet_collided:
    player.hp -= 1
    Explode(player.x, player.y)
    if player.hp < 0:
        player.hp = 0
    if player.hp == 0:
        player.kill() #画面から削除
        player.player_state = PLAYER_DEAD

上では敵の弾(enemy_bullet)が当たったらplayerのHPを1減らし, 爆発エフェクトを呼ぶという処理にしている。もしプレイヤーのHPが0ならplayerは死亡した判定としている。

Tips: 敵の種類や敵の弾の種類が増えたらどうしたらいいですか?

Containerを使いましょう。具体的にはenemies = [enemy1, enemy2, ...]のように配列として,

for enemy in enemies:
    enemy_collided = pygame.sprite.spritecollide(
        player, enemy, False
    )
    if enemy_bullet_collided:
        player.hp -= 1
        Explode(player.x, player.y)
        if player.hp < 0:
            player.hp = 0
        if player.hp == 0:
            player.kill() #画面から削除
            player.player_state = PLAYER_DEAD

とすれば対応できます。次は敵とプレイヤーの弾の当たり判定をcollision_enemyで実装しましょう。(過去の僕のcollision系の関数の命名は正直どうかと思う)

events.py collision_enemy
def collision_enemy(
    enemys: Enemy, bullets: Bullet, state_variable: StateVariable
) -> None:
    enemy_collided = pygame.sprite.groupcollide(enemys, bullets, True, True)
    for enemy in list(enemy_collided.keys()):
        enemy.hp -= 1
        if enemy.hp <= 0:
            enemy.kill()
            Explode(enemy.x, enemy.y)
            state_variable.score += 20

collision_enemy関数で気をつけねばいけないことは

enemy_collided = pygame.sprite.groupcollide(enemys, bullets, True, True)

の行です。この行ではpygame.sprite.Groupで管理されたオブジェクト全ての衝突判定を考えます。pygame.sprite.Groupに関してはmain.pyで説明します。enemy_collidedは{enemy: bullet}の形を取る辞書型であり, 衝突した場合にkeyに衝突したenemy, valueに衝突したbulletが追加されます。
collision_enemy関数は衝突したenemyのhpを減らしていき, 0になったら敵をkillしてStateVariableのスコアを加算する処理をしている。これは敵を倒すとスコアが増える処理に該当します。

次は敵のスポーン処理をspawn_enemyで実装しましょう。

events.py spawn_enemy
def spawn_enemy(state_variable: StateVariable, player: Player) -> None:
    prob = 5 / FPS  # 一秒間に5体まで
    if np.random.rand() < prob:
        Enemy(
            np.random.rand() * 5,
            np.clip(
                (0.2 * np.random.randn() + 1) * player.y,
                a_min=10,
                a_max=WINDOW_HEIGHT - 30,
            ),
        )

spawn_enemyは確率prob = 5 / FPSで敵をスポーンさせる関数です。FPSが絡む理由としては, events.pyの関数は全て1フレームごとに呼び出される関数だからです。そのため、1秒に何体ぐらい出すのかの頻度を確率として捉えてあげましょう。
さて, この関数では敵の出現位置のy座標が

np.clip(
    (0.2 * np.random.randn() + 1) * player.y,
    a_min=10,
    a_max=WINDOW_HEIGHT - 30,
)

となっています。これはy座標の位置が10以上, 670未満(WINDOW_HEIGHT=700)になるように, player.yを中心として正規分布に従う確率変数のy座標の位置に敵を出現させようという意味です。(詳しくはnumpy.clip関数を参照ください) 何故一様分布ではダメで, 正規分布にしたのかという理由を説明します。単純にゲームとして面白くなかったからです。
これは図解してみせるのが一番かなと思います。[10, 670]の間の数を出す一様分布に従って敵を出現させた場合のゲームの状況は下図のようになります。

この時右にも左にも敵が出てきてしまい, 敵の処理は大変になってしまいます。もし右の敵を処理している間に左の敵が増えすぎてしまったらプレイヤーの動ける領域は狭くなってしまい, ゲーム体験としては微妙なものになってしまいます。
こういった状況を回避するためにも, 自分のいる位置を中心に, でも少し広がりを持って敵が出現して欲しいと思いますよね, そうした状況を作るために正規分布を使いましょう。

↑遊びやすい‼️

ここまでで1フレームごとに実行される当たり判定やスポーン判定のコードを解説していきました。次はいよいよ最後のmain.pyの解説をしていきましょう。

main.py
import pygame
from pygame.locals import *
import cv2
from events import *
from consts import *
from classes import *

# 目標: 背景の描画とプレイヤーの操作を記述する
import numpy as np
import pygame
import sys
import cv2

"""
座標系早見表
     →y       →WIDTH
    ↓        ↓
    x        HEIGHT
"""


def main():
    bg_x = 0
    img_bg = pygame.image.load("assets/images/background/background.png")
    img_black = pygame.image.load("assets/images/background/black.png")
    img_black = pygame.transform.scale(img_black, (300, 700))
    img_gameover = pygame.image.load("assets/images/background/GAMEOVER.png")
    img_gameover = pygame.transform.scale(img_gameover, (700, 120))
    pygame.init()
    pygame.display.set_caption("SHOOTING")
    screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))

    # 時間を記録する
    clock = pygame.time.Clock()

    all = pygame.sprite.RenderUpdates()
    bullet = pygame.sprite.Group()
    explode = pygame.sprite.Group()
    enemy = pygame.sprite.Group()
    enemy_bullet = pygame.sprite.Group()

    Player.containers = all
    Bullet.containers = all, bullet
    Explode.containers = all, explode
    Enemy.containers = all, enemy
    EnemyBullet.containers = all, enemy_bullet

    player = Player()
    pygame.mixer.music.load("assets/music/bgm.mp3")  # 音楽ファイルの読み込み

    pygame.mixer.music.set_volume(0.7)
    pygame.mixer.music.play(-1)
    font = pygame.font.Font("assets/fonts/font.ttf", 36)
    state_variable = StateVariable()
    while True:
        all.update()
        all.draw(
            screen
        )  # all.updateとall.drawで画面の更新、描画を管理しているのでこれをoffにすればpose画面とかの実装ができる。
        pygame.display.update()
        collision_detection(player, enemy, enemy_bullet)  # プレイヤーのダメージ判定
        collision_enemy(enemy, bullet, state_variable)  # 敵のダメージ判定
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                sys.exit()
        player_keyevents(player=player)
        level_text = font.render(
            f"Lv.: {player.player_level}", True, (255, 255, 255)
        )
        score_text = font.render(
            f"Score: {state_variable.score}", True, (255, 255, 255)
        )
        hp = font.render(f"HP: {player.hp}", True, (255, 255, 255))
        spawn_enemy(
            state_variable,
            player,
        )
        bg_x = (bg_x + 10 / FPS) % 980
        screen.blit(img_bg, [0, bg_x - 980])
        screen.blit(img_bg, [0, bg_x])
        screen.blit(img_black, [700, 0])
        screen.blit(score_text, (750, 500))
        screen.blit(hp, (750, 450))
        screen.blit(level_text, (750, 350))
        if player.player_state == PLAYER_DEAD:
            screen.blit(img_gameover, [0, 250])
            gameover_font = pygame.font.Font("assets/fonts/font.ttf", 48)
            gameover_text = gameover_font.render(
                f"PRESS `Q` TO QUIT", True, (255, 255, 255)
            )
            outline_text = gameover_font.render(
                f"PRESS `Q` TO QUIT", True, (0, 0, 0)
            )
            df = 3
            screen.blit(outline_text, [240 + df, 400])
            screen.blit(outline_text, [240 - df, 400])
            screen.blit(outline_text, [240 + df, 400 + df])
            screen.blit(outline_text, [240 - df, 400 - df])
            screen.blit(outline_text, [240, 400 + df])
            screen.blit(outline_text, [240, 400 - df])
            screen.blit(outline_text, [240 - df, 400 + df])
            screen.blit(outline_text, [240 + df, 400 - df])
            screen.blit(gameover_text, [240, 400])

        clock.tick(FPS)


if __name__ == "__main__":
    main()

main.pyは主にwhile前とwhile後で分けられます。なので, while前を先に解説します。

main.py while前
def main():
    bg_x = 0
    img_bg = pygame.image.load("assets/images/background/background.png")
    img_black = pygame.image.load("assets/images/background/black.png")
    img_black = pygame.transform.scale(img_black, (300, 700))
    img_gameover = pygame.image.load("assets/images/background/GAMEOVER.png")
    img_gameover = pygame.transform.scale(img_gameover, (700, 120))
    pygame.init()
    pygame.display.set_caption("SHOOTING")
    screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))

    # 時間を記録する
    clock = pygame.time.Clock()

    all = pygame.sprite.RenderUpdates()
    bullet = pygame.sprite.Group()
    explode = pygame.sprite.Group()
    enemy = pygame.sprite.Group()
    enemy_bullet = pygame.sprite.Group()

    Player.containers = all
    Bullet.containers = all, bullet
    Explode.containers = all, explode
    Enemy.containers = all, enemy
    EnemyBullet.containers = all, enemy_bullet

    player = Player()
    pygame.mixer.music.load("assets/music/bgm.mp3")  # 音楽ファイルの読み込み

    pygame.mixer.music.set_volume(0.7)
    pygame.mixer.music.play(-1)
    font = pygame.font.Font("assets/fonts/font.ttf", 36)
    state_variable = StateVariable()

while前は主にゲームの起動設定を行っています。一行目のbg_xはbackgroundの位置を表しています。これは背景画像のスクロールのために必要となっております。詳しくはwhile文後で説明します。
img_bg~からimg_gameoverまではゲームに必要な画像を読み込んでいます。具体的に図解するとこうなります。

スクリーンショット 2023-12-19 1.33.28.png
スクリーンショット 2023-12-19 1.33.34のコピー.png

画像を読み込んだ後はpygame.initを実行しています。これはimportした全てのpygameモジュールを初期化する操作です。最初に実行するおまじないのようなものだと思ってください。
その次はscreenの定義です。

pygame.display.set_caption("SHOOTING")
screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))

ここではscreen windowの名前とscreen windowの大きさを決定しています。screenを定義したらゲーム内のオブジェクト定義について話していきます。

    # 時間を記録する
    clock = pygame.time.Clock()

    all = pygame.sprite.RenderUpdates()
    bullet = pygame.sprite.Group()
    explode = pygame.sprite.Group()
    enemy = pygame.sprite.Group()
    enemy_bullet = pygame.sprite.Group()

    Player.containers = all
    Bullet.containers = all, bullet
    Explode.containers = all, explode
    Enemy.containers = all, enemy
    EnemyBullet.containers = all, enemy_bullet

    player = Player()

clockはゲーム内時間の管理に便利なオブジェクトです。FPS制御に使います。
pygame.sprite.RenderUpdatesはpygame.sprite.Groupの派生クラスです。これをcontainersに含むゲームオブジェクトはpygame.sprite.RenderUpdates.updateでupdate命令が実行され, pygame.sprite.RenderUpdates.drawでゲーム画面にレンダリングされます。pygame.sprite.RenderUpdatesをcontainersに入れることで, ゲームオブジェクトのコンストラクタが呼び出されるたびにゲームの盤面にゲームオブジェクトが追加されるようになります。今までのコードでもそうしたコードがありました。この挙動は特殊なので実際にコードを書いて試してみると良いです。

#events.pyのcollision_detection関数にて
if enemy_bullet_collided:
    player.hp -= 1
    Explode(player.x, player.y) #<-ここでコンストラクタ呼び出し

#events.pyのspawn_enemy関数にて
Enemy(
    np.random.rand() * 5,
    np.clip(
        (0.2 * np.random.randn() + 1) * player.y,
        a_min=10,
        a_max=WINDOW_HEIGHT - 30,
    ),
) #<-これもEnemyクラスのコンストラクタを呼び出してスポーンさせている。

その後, allを定義した後はbullet, explode, enemy, enemy_bulletをpygame.sprite.Groupでgroup化しています。group化することでgroup内部の当たり判定の処理などがやりやすくなります。collision_detection関数などでpygame.sprite.groupcollideを使った理由はこれがあります。

そのあとはゲームオブジェクトのcontainersプロパティにこれらを追加しましょう。pygame.sprite.RenderUpdatesのallは全てのオブジェクトに, groupでまとめて当たり判定を処理させたいものはpygame.sprite.Groupもいれておきましょう。

    Player.containers = all
    Bullet.containers = all, bullet
    Explode.containers = all, explode
    Enemy.containers = all, enemy
    EnemyBullet.containers = all, enemy_bullet

    player = Player()

containersクラスを追加してからPlayerクラスの初期化を行ってください。
このあとはBGMを読み込み, ゲーム内で使うフォントを読み込み(手元の環境ではk8x12.ttfというフォントを使いました), ゲーム中変化する変数を管理するStateVariableの初期化を行っています。これにてwhile文前の処理は終了です。

    pygame.mixer.music.load("assets/music/bgm.mp3")  # 音楽ファイルの読み込み

    pygame.mixer.music.set_volume(0.7)
    pygame.mixer.music.play(-1)
    font = pygame.font.Font("assets/fonts/font.ttf", 36)
    state_variable = StateVariable()

それでは次はwhile文後の処理に移りましょう。全体像はこんな感じです。

main.py while文後
while True:
    all.update()
    all.draw(
        screen
    )  # all.updateとall.drawで画面の更新、描画を管理しているのでこれをoffにすればpose画面とかの実装ができる。
    pygame.display.update()
    collision_detection(player, enemy, enemy_bullet)  # プレイヤーのダメージ判定
    collision_enemy(enemy, bullet, state_variable)  # 敵のダメージ判定
    player_keyevents(player=player)
    spawn_enemy(
        state_variable,
        player,
    )
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    level_text = font.render(
        f"Lv.: {player.player_level}", True, (255, 255, 255)
    )
    score_text = font.render(
        f"Score: {state_variable.score}", True, (255, 255, 255)
    )
    hp = font.render(f"HP: {player.hp}", True, (255, 255, 255))
    bg_x = (bg_x + 10 / FPS) % 980
    screen.blit(img_bg, [0, bg_x - 980])
    screen.blit(img_bg, [0, bg_x])
    screen.blit(img_black, [700, 0])
    screen.blit(score_text, (750, 500))
    screen.blit(hp, (750, 450))
    screen.blit(level_text, (750, 350))
    if player.player_state == PLAYER_DEAD:
        screen.blit(img_gameover, [0, 250])
        gameover_font = pygame.font.Font("assets/fonts/font.ttf", 48)
        gameover_text = gameover_font.render(
            f"PRESS `Q` TO QUIT", True, (255, 255, 255)
        )
        outline_text = gameover_font.render(
            f"PRESS `Q` TO QUIT", True, (0, 0, 0)
        )
        df = 3
        screen.blit(outline_text, [240 + df, 400])
        screen.blit(outline_text, [240 - df, 400])
        screen.blit(outline_text, [240 + df, 400 + df])
        screen.blit(outline_text, [240 - df, 400 - df])
        screen.blit(outline_text, [240, 400 + df])
        screen.blit(outline_text, [240, 400 - df])
        screen.blit(outline_text, [240 - df, 400 + df])
        screen.blit(outline_text, [240 + df, 400 - df])
        screen.blit(gameover_text, [240, 400])

    clock.tick(FPS)

ここまで1フレームという単語が飛び交ったと思いますが, このwhile文1回の処理が始まってから終わるまでの時間が1フレームと呼ばれています。
一つ一つコードを眺めてみましょう。

all.update()
all.draw(
    screen
)  # all.updateとall.drawで画面の更新、描画を管理しているのでこれをoffにすればpose画面とかの実装ができる。
pygame.display.update()

始まりの三つはpygame.sprite.RenderUpdatesでゲームオブジェクト全体の状態とスクリーンを更新した後, ディスプレイの表示も更新しています。正直while文の中に複数のロジックを書きすぎたので, update_screenのような関数を作っておくべきだったと思います。

collision_detection(player, enemy, enemy_bullet)  # プレイヤーのダメージ判定
collision_enemy(enemy, bullet, state_variable)  # 敵のダメージ判定
player_keyevents(player=player)
spawn_enemy(
    state_variable,
    player,
)

更新後, ここではevents.pyで制作した関数の実行を行っています。ここで当たり判定と敵のスポーン判定, プレイヤーのキー操作を行っています。

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        pygame.quit()
        sys.exit()

ここはpygameのウィンドウのバツマークを押した時の処理です。pygame.eventの中にバツマークを押された場合, ゲーム終了イベントのフラグが入っているので, それを使います。これもwhile文の中に書く時にはロジックを分割してquit_gameのような関数を作るべきです。

level_text = font.render(
        f"Lv.: {player.player_level}", True, (255, 255, 255)
    )
score_text = font.render(
        f"Score: {state_variable.score}", True, (255, 255, 255)
    )
hp = font.render(f"HP: {player.hp}", True, (255, 255, 255))

ここでは画面に描画するテキストをpygame.font.Fontクラスのrenderメソッドで定義しています。ここで定義しているものは全てゲーム中変化しうるものなのでwhile文の中で毎回renderメソッドを実行しています。

bg_x = (bg_x + 10 / FPS) % 980
screen.blit(img_bg, [0, bg_x - 980])
screen.blit(img_bg, [0, bg_x])

ここの三行は理解が難しいです。ここは背景画像のスクロールを実装しています。やっていることを箇条書きで述べると,

  1. 背景の描画位置を10 / FPSだけ足して, それを980で割った値を新しい背景描画位置としている。
  2. screenの(y, x) = (0, bg_x-980)の位置に背景画像を貼っている。(screen.blit)
  3. screenの(y, x) = (0, bg_x)の位置に背景画像を貼っている。(screen.blit)
    まず前提として, 今回の自分で使ったゲームの背景は縦980, 横700の画像となっております。操作1, 2の980はそういう意味です。操作2, 3はどういう操作をしているのかというと、こういう操作をしています。

IMG_1190.jpg

このように背景画像二枚を位置をずらして貼り, bg_xを変化させることで背景画像の遷移を実装しています。

screen.blit(img_black, [700, 0])
screen.blit(score_text, (750, 500))
screen.blit(hp, (750, 450))
screen.blit(level_text, (750, 350))

背景画像のあとの4行はもう一枚の背景画像(img_black)を貼ったり, ゲーム中のプレイヤーの状態やスコアに関する表示です。
ここまでを実装し, assetsに画像をいれると, ゲーム画面としてはこんな感じになります。

スクリーンショット 2023-12-19 20.49.53.png

次に, ゲームオーバー時の画面の表示の実装を見てみましょう。

if player.player_state == PLAYER_DEAD:
    screen.blit(img_gameover, [0, 250])
    gameover_font = pygame.font.Font("assets/fonts/font.ttf", 48)
    gameover_text = gameover_font.render(
        f"PRESS `Q` TO QUIT", True, (255, 255, 255)
    )
    outline_text = gameover_font.render(
        f"PRESS `Q` TO QUIT", True, (0, 0, 0)
    )
    df = 3
    screen.blit(outline_text, [240 + df, 400])
    screen.blit(outline_text, [240 - df, 400])
    screen.blit(outline_text, [240 + df, 400 + df])
    screen.blit(outline_text, [240 - df, 400 - df])
    screen.blit(outline_text, [240, 400 + df])
    screen.blit(outline_text, [240, 400 - df])
    screen.blit(outline_text, [240 - df, 400 + df])
    screen.blit(outline_text, [240 + df, 400 - df])
    screen.blit(gameover_text, [240, 400])

このコードはgameover_textとoutline_textを作り, 外枠付きの文字をscreen.blitで書いています。その時の画面は下のようになります。

スクリーンショット 2023-12-19 20.49.58.png

PRESS `Q` TO RUITの文字が黒縁文字になっていることがわかると思います。
ここのコードはロジックは簡単ですが長いのでdraw_game_over(player: Player)というような関数でまとめてしまっておいたほうが良かったかもしれません。

ここまでお疲れ様でした。ラスト一行です。

clock.tick(FPS)

ここはpygameのClockオブジェクトによりFPS制御を行っているコードです。このコードがあることによって、while文内部の処理が1秒間にFPS回実行されることを保証してくれています。(FPSが大きすぎる場合はその限りではない。)この処理を挟むことでゲームのCPU使用率などを抑えてくれるので必ずいれておきましょう。

いかがでしたか?

前編後編でかなり詳しくpygameでシューティングゲームを作る方法を述べてきました。何かわからないことや詰まったことがあればコメントやXにて教えてくれるとありがたいです。さて、ここでは僕が大学の授業で作ったものを追加の実装案を話していこうかなと思います。

  • アニメーションをつける
    class Explodeの実装でもいいましたがやはりアニメーションはつけたほうが見栄えもするし面白いです。たくさんの素材画像を用意して少し実装の仕方は混み入りますが実装してあげましょう。
  • 音楽, 効果音を増やす
    pygameではBGM以外にも効果音を追加することができます。弾を撃つ時, 爆発音, 敵にヒットした時, レベルアップした時, アイテムを取得した時などに効果音をつけてあげましょう。
  • レベルシステムを作る
    レベルアップで攻撃方法の追加とかがあるとやっぱりゲームとしては面白いです。僕は授業でビームを追加しました。

スクリーンショット 2023-12-19 21.06.33.png

  • 敵の追加, ボスの追加
    少し強い敵やボスの追加もおすすめです。ボスには体力ゲージをつけてあげるとプレイヤーも遊びやすくなります。

スクリーンショット 2023-12-19 21.06.16.png

  • 敵AIの強化

簡単な数学を駆使することで敵AIの強化を行うことができます
↓大学で発表したスライド

スクリーンショット 2023-12-19 21.04.20.png

みんなも自分で自分が面白いと思うゲームを作って遊んでみてくださいね。

3
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?