🦒

Webカメラの被写体で遊ぶどうぶつタワーバトル風ゲームを作った

2022/06/14に公開

作ったもの

Webカメラの被写体で遊ぶ、どうぶつタワーバトル風のゲームを作りました。


遊んでいる様子

https://github.com/3w36zj6/webcam-tower-battle

(よろしければstarを頂けると幸いです)

有名なので知らない方は少ないとは思いますが元ネタのゲームはこちらです。

https://apps.apple.com/jp/app/id1173389241

動機

  • 物理演算を使った何かを作りたい
  • 大学の学園祭で展示したら盛り上がりそうなものを作りたい

技術

Python Arcade Library

Python Arcade LibraryというPythonでの2Dゲーム開発用ライブラリを使用しました。

https://github.com/pythonarcade/arcade

Pythonで2Dゲーム開発といえば未だにPygameが有名ですが、公式ドキュメントのPygameとの比較にあるようにPygameよりArcadeの方が優れている点が多いように感じます。

https://api.arcade.academy/en/latest/pygame_comparison.html

たとえばスプライトのヒットボックス(当たり判定)を自動で設定してくれたり、

画面スクロールを簡単に実装できたりなど、

便利な機能が沢山あります。特に公式のチュートリアルやサンプルコードが充実しているのは、便利なArcadeの機能をすぐに活かせそうで初心者にとってはとても嬉しいです。

ただPygameに比べると知名度が非常に低いため、公式ドキュメント以外の情報を探すのが大変です。(特に日本語ユーザーが増えてほしいと願っています)

arcade.Windowを継承したクラスを用意し、以下のようにon_で始まるメソッドをオーバーライドして使用します。__init__()とは別にゲーム内パラメータの初期化用にsetup()を用意し、ゲーム内の状態のリセットを簡単に行えるようにしています。

class MyGame(arcade.Window):
    def __init__(self, camera_id):
        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, "Tower Battle")
        self.set_update_rate(1 / 1000)
	# 省略
	self.setup()

    def setup(self):
        # 省略

    def on_draw(self):
        # 省略

    def on_update(self, delta_time):
        # 省略

    def on_key_press(self, key, modifiers):
        # 省略

    def on_key_release(self, key, modifiers):
        # 省略

if __name__ == "__main__":
    MyGame(int(sys.argv[1]) if len(sys.argv) >= 2 else 0) # 使用するカメラのIDをコマンドライン引数から渡す
    arcade.run()

OpenCV/Pillow

クロマキー透過処理で利用しました。処理の内容についてはほぼ参考記事そのままなのでそちらをご覧ください。

SCREEN_WIDTH = 1280
SCREEN_HEIGHT = 720

class Camera:
    def __init__(self, camera_id):
        self.capture = cv2.VideoCapture(camera_id)
        self.count = 0
        self.position = [SCREEN_WIDTH / 2, 650]
        self.angle = 0

    def update(self):
        self.sprite = arcade.Sprite()
        self.sprite.position = self.position
        self.sprite.angle = self.angle
        ret, frame_image_cv = self.capture.read()
        frame_image_cv = cv2.resize(frame_image_cv, (320, 180))
        frame_image_cv = cv2.cvtColor(frame_image_cv, cv2.COLOR_RGB2RGBA)

        # HSV変換
        hsv = cv2.cvtColor(frame_image_cv, cv2.COLOR_BGR2HSV)
        # 2値化
        bin_img = ~cv2.inRange(hsv, (62, 100, 0), (79, 255, 255))
        # 輪郭抽出
        contours = cv2.findContours(
            bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
        )[1]
        # 面積が最大の輪郭を取得
        if not contours:  # 何も写っていない場合
            self.sprite.texture = None
            return
        contour = max(contours, key=lambda x: cv2.contourArea(x))
        # マスク画像作成
        mask = np.zeros_like(bin_img)
        cv2.drawContours(mask, [contour], -1, color=255, thickness=-1)
        # 幅、高さは前景画像と背景画像の共通部分をとる
        w = frame_image_cv.shape[1]
        h = frame_image_cv.shape[0]
        # 合成する領域
        fg_roi = frame_image_cv[:h, :w]
        bg_roi = np.zeros((h, w, 4), np.uint8)
        # 合成
        frame_image_cv = np.where(mask[:h, :w, np.newaxis] == 0, bg_roi, fg_roi)

        frame_img_pil = cv2pil(frame_image_cv)
        self.sprite.texture = arcade.Texture(
            name=f"{self.count}", image=frame_img_pil, hit_box_algorithm="Detailed"
        )

    def draw(self):
        if self.sprite.texture:
            self.sprite.draw()

    def move_x(self, change_x):
        self.position[0] += change_x
        self.position[0] = max(self.position[0], 0)
        self.position[0] = min(self.position[0], 1280)

    def move_y(self, change_y):
        self.position[1] += change_y
        self.position[1] = max(self.position[1], 650)

    def rotate(self, change_angle):
        self.angle += change_angle

    def get_sprite(self):
        self.count += 1
        return self.sprite


def cv2pil(image):
    # OpenCV型 -> PIL型
    new_image = image.copy()
    if new_image.ndim == 2:  # モノクロ
        pass
    elif new_image.shape[2] == 3:  # カラー
        new_image = cv2.cvtColor(new_image, cv2.COLOR_BGR2RGB)
    elif new_image.shape[2] == 4:  # 透過
        new_image = cv2.cvtColor(new_image, cv2.COLOR_BGRA2RGBA)
    new_image = Image.fromarray(new_image)
    return new_image

https://qiita.com/derodero24/items/f22c22b22451609908ee

https://pystyle.info/opencv-mask-image/

Pymunk

PymunkはPythonでの2D物理演算ライブラリです。Arcadeと組み合わせることで物理演算を使ったゲームの作成が可能になります。

https://github.com/viblo/pymunk

最初にpymunk.Spaceを作成し、足場となるステージを追加しておきます。

# Pymunk
self.space = pymunk.Space()
self.space.gravity = (0.0, -400.0)

# Terrain
self.terrain = arcade.Sprite(
    filename="terrain.png",
    center_x=SCREEN_WIDTH / 2,
    center_y=SCREEN_HEIGHT / 2,
)
body = pymunk.Body(body_type=pymunk.Body.STATIC)
body.position = self.terrain.position
shape = pymunk.Poly(body, self.terrain.texture.hit_box_points)
shape.friction = 1
shape.elasticity = 0
self.space.add(body, shape)

カメラの現在の被写体からpymunk.Bodyを生成するメソッドを用意して、任意のタイミングでスプライトを生成できるようにします。

def generate_sprite(self, sprite):
    if not sprite.texture:
        return
    mass = 0.5
    inertia = pymunk.moment_for_poly(mass, sprite.texture.hit_box_points)
    body = pymunk.Body(mass, inertia)
    body.position = sprite.position
    body.angle = math.radians(sprite.angle)
    shape = pymunk.Poly(body, sprite.texture.hit_box_points)
    shape.friction = 1
    shape.elasticity = 0
    self.space.add(body, shape)
    sprite.pymunk_shape = shape
    self.object_list.append(sprite)

後は1フレーム毎にself.space.step(1 / 60)を呼び出してあげればSpace上の全ての物体が更新されます。あとは画面外に物体が落下したらゲームオーバーなどの処理を実装すればゲームは完成です。

タイルセット

こちらのタイルセット素材を使わさせて頂きました。

https://foxlybr.itch.io/23421124244

(おまけ)Webカメラとクロマキー合成を使ったアプリを作るときのTips

プログラムから直接Webカメラの画像を取得して処理しようとすると、素人が用意した環境では照明や光の反射などのせいで大抵うまくいかないことが多いので、OBS Studioなどの仮想カメラを経由させてプレビューを見ながら色補正のパラメータを調整してあげるとうまくいきます。また仮想カメラを使用することで画像を用意しておけば物理的に環境を整える必要がなくなるので動作確認が楽になります。

おわりに

n番煎じですが物理演算を使った複数人で楽しめそうなゲームを作ることができました。またPython Arcade Libraryのユーザーが増えることを願っております。この記事がどなたかの参考になれば幸いです。

Discussion