【PyScript】斜方蹴射をPyscriptで作ってみた(コードあり)

プログラミング

どうもです、タドスケです。

PyScriptでゲームを作るにあたって、最後の課題だったのが描画部分なのですが、以下のようにしてcanvas要素を取得してpythonコード内から操作できることがわかりました。

canvas = document.querySelector('#output')
ctx = canvas.getContext('2d')
ctx.fillText("Hello world!", 10, 10)

要するに、
querySelectorで取得した後の操作方法はJavaScriptと同じ
ということです。

それが分かってからは、Canvasのリファレンスサイトで調べながら描画処理を実装し、ついにPython製のブラウザゲームを完成させることができました!




完成品

このゲームは大半がPythonで動いています。

HTML側コード

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />

    <!-- pyscriptを使えるようにするために必要な2行 -->
    <link rel="stylesheet" href="https://pyscript.net/alpha/pyscript.css" />
    <script defer src="https://pyscript.net/alpha/pyscript.js"></script>

    <!-- 外部モジュール定義 -->
    <py-env>
      - paths:
        - ./shahou_model.py
    </py-env>
  </head>
  <body>
    <canvas id="output" width="400px" height="300px" style="background-color:cyan;"/>
    <py-script src="./shahou_view.py"/>
  </body>
</html>

html側のコードはたったのこれだけ!

やることは以下のみです。

  • py-scriptタグのsrc属性にビュー側のソース(shahou_view.py)を指定する
  • py-envタグ内で、ビューが使用する外部ソース(今回は shahou_model.py のみ)を指定する
  • 描画用のcanvasを置く

Python側コード

できるだけ多くの部分をPythonで書けるように、Model-Viewを分離させています。

Model

PyGame版などでも使っているモデル部分です。

画面サイズや座標系(左上が原点)に合わせて一部加工していますが、ほぼそのまま流用しています。

"""
斜方蹴射 ゲームロジック
"""

import random
from enum import Enum


# ===========================
# ゲームモード
# ===========================
class Mode(Enum):
    INIT = 0  # 初期
    POW_X = 1  # Xパワー決定待ち
    POW_Y = 2  # Yパワー決定待ち
    FLYING = 3  # 飛行中
    WAIT_HIT = 4  # ヒット演出中
    WAIT_OUT = 5  # 外れ演出中
    TIME_UP = 6  # 時間切れ演出中
    RESULT = 7  # リザルト


# ===========================
# ゲーム本体
# ===========================
class Game:
    # クラス変数
    max_pow_x = 100  # パワー横最大値
    max_pow_y = 100  # パワー縦最大値
    max_time = 60  # 制限時間
    init_ball_x = 20  # ボール初期位置x
    init_ball_y = 280  # ボール初期位置y
    waittime_hit = 1.0  # ヒット演出時間
    waittime_end = 2.0  # タイムアップ演出時間
    ground_h = 20  # 地面の高さ
    min_target_x = 150
    max_target_x = 350
    min_target_y = 30
    max_target_y = 180
    scr_w = 400
    scr_h = 300

    # コンストラクタ
    def __init__(self):
        self.stage = Stage(Game.ground_h)
        self.reset()
        self.time = 0  # 残り時間
        self.mode = Mode.INIT  # モード番号
        self.decide = False  # 決定操作がされたか
        self.wait_timer = 0  # 演出タイマー
        self.score = 0  # スコア

    # 状態リセット
    def reset(self):
        self.ball = Ball()
        self.ball.reset(Game.init_ball_x, Game.init_ball_y)
        self.pow_x = 0
        self.pow_y = 0
        self.stage.reset_wind()
        self.target = Target()
        self.target.set_pos(random.randint(Game.min_target_x, Game.max_target_x),
                            random.randint(Game.min_target_y, Game.max_target_y))

    # 更新
    def update(self, delta):
        # 初期設定
        if self.mode == Mode.INIT:
            # インスタンス変数のリセット
            self.reset()
            self.score = 0
            self.time = Game.max_time

            self.mode = Mode.POW_X

        # 横パワー決定待ち
        elif self.mode == Mode.POW_X:
            # 残り時間更新
            self.time -= delta
            if self.time <= 0:
                self.time = 0
                self.wait_timer = Game.waittime_end
                self.mode = Mode.TIME_UP
                return
            # 横パワー更新
            else:
                self.pow_x += 1
                if Game.max_pow_x < self.pow_x:
                    self.pow_x = 0
                if self.decide:
                    self.mode = Mode.POW_Y

        # 縦パワー決定待ち
        elif self.mode == Mode.POW_Y:
            # 残り時間更新
            self.time -= delta
            if self.time <= 0:
                self.time = 0
                self.wait_timer = Game.waittime_end
                self.mode = Mode.TIME_UP
            # 縦パワー更新
            else:
                self.pow_y += 1
                if Game.max_pow_y < self.pow_y:
                    self.pow_y = 0

                if self.decide:
                    self.ball.kick(self.pow_x, self.pow_y)
                    self.mode = Mode.FLYING

        # ボール飛行中
        elif self.mode == Mode.FLYING:
            self.ball.update(delta, self.stage.wind)

            # 的に接触
            if self.target.is_hit(self.ball.x, self.ball.y):
                self.wait_timer = Game.waittime_hit
                self.mode = Mode.WAIT_HIT

            # 地面に接触
            elif Game.scr_h - Game.ground_h <= self.ball.y:
                self.ball.y = Game.scr_h - Game.ground_h
                self.wait_timer = Game.waittime_hit
                self.mode = Mode.WAIT_OUT

        # ヒット演出待ち
        elif self.mode == Mode.WAIT_HIT:
            self.wait_timer -= delta
            if self.wait_timer <= 0:
                self.score += int(self.ball.time)
                self.reset()
                self.mode = Mode.POW_X

        # 外れ演出待ち
        elif self.mode == Mode.WAIT_OUT:
            self.wait_timer -= delta
            if self.wait_timer <= 0:
                self.reset()
                self.mode = Mode.POW_X

        # タイムアップ演出待ち
        elif self.mode == Mode.TIME_UP:
            self.wait_timer -= delta
            if self.wait_timer <= 0:
                self.wait_timer = 0
                self.mode = Mode.RESULT

        # リザルト
        elif self.mode == Mode.RESULT:
            if self.decide:
                self.mode = Mode.INIT

        self.decide = False

    # 決定操作
    def send_decide(self):
        self.decide = True

    # ボール飛行中か
    def is_flying(self):
        return self.mode == Mode.FLYING

    # ヒット演出中か
    def is_hit(self):
        return self.mode == Mode.WAIT_HIT

    # 時間切れか
    def is_timeup(self):
        return self.mode == Mode.TIME_UP

    # リザルト表示中か
    def is_result(self):
        return self.mode == Mode.RESULT


# ===========================
# ステージ
# ===========================
class Stage:
    # クラス変数
    wind_range = (-10, 10)  # 風速の範囲

    # コンストラクタ
    def __init__(self, ground_h):
        self.ground_h = ground_h  # 地面の高さ
        self.wind = 0

    # 風速の再設定
    def reset_wind(self):
        (min, max) = Stage.wind_range
        self.wind = random.randint(min, max)


# ===========================
# ボール
# ===========================
class Ball:
    # クラス変数
    size = 10  # 大きさ
    gravity = 0.5  # ボールにかかる重力
    wind_rate = 0.012  # 風の適用率
    pow_rate_x = 0.15  # パワー横の適用率
    pow_rate_y = 0.20  # パワー縦の適用率

    # コンストラクタ
    def __init__(self):
        self.reset()

    # リセット
    def reset(self, x=0, y=0):
        self.x = x  # X座標
        self.y = y  # Y座標
        self.sx = 0  # X速さ
        self.sy = 0  # Y速さ
        self.time = 0  # 飛行時間

    # キック
    def kick(self, pow_x, pow_y):
        self.sx = pow_x * Ball.pow_rate_x
        self.sy = pow_y * -Ball.pow_rate_y
        self.time = 0

    # 更新
    def update(self, delta, wind):
        self.sx += wind * Ball.wind_rate
        self.sy += Ball.gravity
        self.x += self.sx
        self.y += self.sy
        self.time += delta * 10


# ===========================
# 的
# ===========================
class Target:
    # クラス変数
    size_w = 20
    size_h = 100

    # コンストラクタ
    def __init__(self):
        self.set_pos(0, 0)

    # 座標設定
    def set_pos(self, x, y):
        self.x = x
        self.y = y

    # 当たり判定
    def is_hit(self, x, y):
        if self.x < x and x < self.x + Target.size_w and self.y < y and y < self.y + Target.size_h:
            return True
        return False

View

htmlに依存するゲームの入力・描画部分です。

"""
斜方蹴射 ゲームビュー(pyscript ver.)
"""

import asyncio

from js import document
from pyodide import create_proxy

from shahou_model import Game, Target, Ball

# 定数
_FPS = 1.0 / 60

# エレメント
_canvas = document.querySelector('#output')
_ctx = _canvas.getContext('2d')

# マウス左ボタン
_mouse_left = False


async def draw():
    """描画"""

    global _game

    # リザルト
    if _game.is_result():
        _ctx.fillStyle = "rgb(0, 0, 0)"
        _ctx.fillRect(0, 0, 400, 300)

        # スコア
        _ctx.font = "30px bold sans-serif"
        _ctx.fillStyle = "rgb(255, 255, 255)"
        _ctx.fillText(f"Score:{int(_game.score)}", 110, 135)

    # メインゲーム
    else:
        # 背景をクリア
        _ctx.clearRect(0, 0, 400, 300)

        # 地面
        _ctx.fillStyle = "rgb(128, 64, 0)"
        _ctx.fillRect(0, 280, 400, 20)

        # ゲージX
        _ctx.fillStyle = "rgb(0, 255, 0)"
        _ctx.fillRect(0, 280, _game.pow_x * 400 / Game.max_pow_x, 10)

        # ゲージY
        _ctx.fillStyle = "rgb(255, 0, 0)"
        _ctx.fillRect(0, 290, _game.pow_y * 400 / Game.max_pow_y, 10)

        # 的
        _ctx.fillStyle = "rgb(0, 0, 128)"
        _ctx.fillRect(
            _game.target.x,
            _game.target.y,
            Target.size_w,
            Target.size_h)

        # ボール
        _ctx.fillStyle = "rgb(255, 255, 255)"
        _ctx.fillRect(
            _game.ball.x,
            _game.ball.y - Ball.size,
            Ball.size,
            Ball.size)

        _ctx.font = "15px bold sans-serif"
        _ctx.fillStyle = "rgb(0, 0, 0)"

        # スコア
        _ctx.fillText(f"スコア = {_game.score}", 0, 15)
        # 残り時間
        _ctx.fillText(f"時間 = {int(_game.time)}", 0, 30)
        # 風
        _ctx.fillText(f"風 = {_game.stage.wind}", 0, 45)

        # 滞空時間
        if _game.is_flying() or _game.is_hit():
            _ctx.font = "15px bold sans-serif"
            _ctx.fillStyle = "rgb(0, 0, 128)"
            _ctx.fillText(f'+ {int(_game.ball.time)}', 100, 15)

        # ヒット
        if _game.is_hit():
            _ctx.font = "30px bold sans-serif"
            _ctx.fillStyle = "rgb(255, 0, 0)"
            _ctx.fillText(f'HIT!', 150, 135)

        # タイムアップ
        if _game.is_timeup():
            _ctx.font = "30px bold sans-serif"
            _ctx.fillStyle = "rgb(255, 0, 0)"
            _ctx.fillText('Time up!', 130, 135)


# 呼び出す関数はasyncにする必要がある
async def main():
    """メイン関数."""

    global _game

    _game = Game()
    while True:
        _game.update(_FPS)
        await draw()
        await asyncio.sleep(_FPS)


async def on_mousedown(event):
    """マウスボタンが押されたとき."""

    global _mouse_left
    global _game

    if not _mouse_left:
        _game.send_decide()
    _mouse_left = True


async def on_mouseup(event):
    """マウスボタンが離されたとき."""

    global _mouse_left

    _mouse_left = False


if __name__ == '__main__':
    body = document.querySelector("body")
    body.addEventListener("mousedown", create_proxy(on_mousedown))
    body.addEventListener("mouseup", create_proxy(on_mouseup))

    pyscript_loader.close()
    pyscript.run_until_complete(main())

pyodideがブラウザ専用のライブラリ(pipからインストールしようとするとエラーになる)のため、Pythonの実行環境で直接動かすことはできません。

PyCharmなどで編集した後、ブラウザを更新して挙動を確認する必要があり、そこだけはちょっと手間かもしれません。

View側のコードもクラスに分割したかったのですが、変数の扱いなどでちょっと詰まったで、一旦そのままにしています。

変数にglobalとついているのは、main関数や入力関数をasyncで定義する必要があり、その中で普通に変数を使おうとすると参照エラーが出るためです。
(このあたりのPythonの仕様はまだ詳しくないです…)

まとめ

以下の方法で実装することにより、念願のPython製ブラウザゲームを完成させることができました。

  • main関数をaryncで宣言し、create_proxy(main)で登録する
  • 入力はbodyを取得してaddEventListenerで登録する
  • Python内でcanvas→2dContextを取得し、あとはCanvasリファレンスを見ながら関数を呼ぶ

今後はこのやり方で他のゲームも作っていきたいと思います。

コメント

  1. […] 【PyScript】斜方蹴射をPyscriptで作ってみた(コードあり)どうもです、タド… […]

  2. […] 【PyScript】斜方蹴射をPyscriptで作ってみた(コードあり)どうもです、タド… […]

タイトルとURLをコピーしました