どうもです、タドスケです。
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リファレンスを見ながら関数を呼ぶ
今後はこのやり方で他のゲームも作っていきたいと思います。
コメント
コメント一覧 (2件)
[…] 【PyScript】斜方蹴射をPyscriptで作ってみた(コードあり)どうもです、タド… […]
[…] 【PyScript】斜方蹴射をPyscriptで作ってみた(コードあり)どうもです、タド… […]