はじめに
PythonのみでWebアプリを作れる系のフレームワーク、Reflex。
今回は画面をタップしてスコアを競う簡単なゲームを作ってみました。(音が鳴りますのでご注意ください。)
※スマートフォン推奨
例によってPWA化しているので、ホーム画面に追加して暇つぶしにどうぞ。
※利用している画像素材はすべてChatGPT(Dall-E 3)を利用して生成しています。
コード解説
※コード全体はリポジトリを参照ください。
ページの切り替え
rx.cond
を利用して状態に応じてレンダリングする画面を切り替えています。
このコンポーネントは、コンポーネントを条件付きでレンダリングするために使用されます。
condコンポーネントは、条件と2つのコンポーネントを受け取る。条件が True の場合は最初のコンポーネントがレンダリングされ、そうでない場合は 2 番目のコンポーネントがレンダリングされます。
GameState
で状態を管理しており、GameState.game_active(ゲームが開始しているかどうか)
と GameState.time_remaining(カウントダウン)
を見て判断しています。
ちょっと分かりづらいですが、
@rx.page(route="/", title="Tap!Tap!Tap!", meta=meta)
def index() -> rx.box:
"""ゲームのインデックスページを生成します。
Returns:
rx.box: ゲームのUIを構成するボックス。
"""
return rx.box(
rx.cond(
# ゲームスタート前、かつ カウントダウンタイマーが0じゃない
~GameState.game_active & GameState.time_remaining,
# タイトル画面,
rx.cond(
# ゲームスタート後
GameState.game_active,
# ゲーム画面
),
rx.cond(
# カウントダウンタイマーが0
GameState.time_remaining == 0,
# ゲームオーバー画面
)
ボタンを押すことでon_click=GameState.start_game
が発火して
rx.button(
rx.chakra.image(
src="start.png",
width="90%",
loading="lazy",
border_radius="50px",
border="5px solid black",
),
on_click=GameState.start_game,
variant="ghost",
position="absolute",
bottom="15vh", # 下からの位置を指定
left="50%", # 左右中央に配置
transform="translateX(-50%)", # 中央揃えの調整
)
ゲームが開始します。(self.game_active = True
)
def start_game(self) -> list:
"""ゲームを開始し、初期スコアと時間を設定します。
Returns:
list: ゲームの進行を管理するための関数リスト。
"""
self.score = 0
self.time_remaining = GAME_DURATION
self.game_active = True
self.generate_positions()
return [GameState.tick, rx.call_script("playBGM()")]
ゲーム画面のターゲットボタン表示・非表示
ボタンを表示する処理の実体はrx.cond
で、後述するrx.foreach
から受け取ったパラメータ(ボタンのIDと表示・非表示の真偽値)を使って制御しています。
=(ボタンID,真)だったら表示、(ボタンID,偽)だったら非表示
ボタンをクリックするとon_click=GameState.hide_button(info[0])
が発火し、ボタンIDに対する真偽値を偽に更新します。
def button(info: tuple[str, bool]) -> rx.cond:
"""ボタンを生成します。
Args:
info (tuple[str, bool]): ボタンの情報(IDと可視性)。
Returns:
rx.cond: ボタンの表示制御。
"""
return rx.cond(
info[1],
rx.button(
rx.chakra.image(
src="/button.png",
loading="lazy",
border_radius="full",
width=BUTTON_WIDTH,
),
on_click=GameState.hide_button(info[0]),
position="absolute",
top=rx.cond(
GameState.button_positions.contains(info[0]),
GameState.button_positions[info[0]]["top"],
"0vh",
),
left=rx.cond(
GameState.button_positions.contains(info[0]),
GameState.button_positions[info[0]]["left"],
"0vw",
),
variant="ghost",
),
rx.text(""),
)
先ほどのボタンを指定した数だけ画面上に展開するためにrx.foreach
を使います。
rx.foreachコンポーネントは、iterable(リスト、タプル、またはdict)と、リスト内の各項目をレンダリングする関数を取ります。
これは、ステートで定義されたアイテムのリストを動的にレンダリングするのに便利です。
# ボタンの数を指定しておいて
class GameState(rx.State):
button_visibility: dict[str, bool] = {
f"button{num}": True for num in range(BUTTON_NUM)
}
# ここでリスト分を繰り返してbuttonを呼び出す(=rx.condで判定された結果が表示される)
rx.cond(
GameState.game_active,
rx.box(
rx.foreach(
GameState.button_visibility,
button,
),
special_button(),
penalty_button(),
bg="lightblue",
width="100vw",
height="100vh",
),
ボタンが押されると、↓が動いてボタンIDに対する真偽値を偽にする。
def hide_button(self, button_id: str) -> None:
"""指定されたボタンを消し、スコアを更新します。
Args:
button_id (str): 消すボタンのID。
"""
if not self.game_active:
return None
self.button_visibility[button_id] = False
self.score += 1
if all(not visibility for visibility in self.button_visibility.values()):
self.generate_positions()
return rx.call_script("playFromStart('button_sfx')")
タイマー処理
ゲームといえばタイマーかなと思いますが、こちらの例ではrx.background
を利用して、ゲームがアクティブの間は1秒ごとにカウントダウンを行います。
0秒になるとゲームを非アクティブにし、BGMを終了します。
バックグラウンドタスクは、他のEventHandler関数と同時に実行できる特別なタイプのEventHandlerです。
これにより、UIのインタラクティブ性をブロックすることなく、長時間タスクを実行することができます。
バックグラウンド・タスクがステートを操作する必要があるときは、必ずasync with selfコンテキスト・ブロックに入り、ステートをリフレッシュし、他のタスクやイベント・ハンドラが同時にステートを変更できないように排他ロックをかけます。
class GameState(rx.State):
~~
time_remaining: int = GAME_DURATION
game_active: bool = False
~~
@rx.background
async def tick(self) -> None:
"""ゲームの残り時間をカウントダウンします。"""
while self.game_active:
await asyncio.sleep(1)
async with self:
self.time_remaining -= 1
if self.time_remaining == 0:
async with self:
self.game_active = False
return rx.call_script("stopBGM()")
return None
BGM及びSEの再生
BGMとSEを動的に操作するいい方法が見つからず、JavaScriptを利用する形になりました。(いい方法があったら教えてください。。)
Scriptコンポーネントは、インラインjavascriptやURLによるjavascriptファイルをインクルードするために使用できます。
スクリプトの注入にはnext/scriptコンポーネントを使用し、スクリプトの副作用を状態によって制御できるように、条件付きレンダリングで安全に使用できます。
なお、rx.script
で定義したJavaScriptの関数はrx.call_script()
でPython側から呼び出すことができます(!)黒魔術感
rx.script(
"""
var button_sfx = new Audio("/button_se.mp3")
var button_sfx2 = new Audio("/button_se2.mp3")
var penalty_sfx = new Audio("/penalty_se.mp3")
var bgm = new Audio("/bgm.mp3")
function playFromStart(sfx) {
if (sfx === 'button_sfx') {
button_sfx.currentTime = 0;
button_sfx.play();
} else if (sfx === 'button_sfx2') {
button_sfx2.currentTime = 0;
button_sfx2.play();
} else if (sfx === 'penalty_sfx') {
penalty_sfx.currentTime = 0;
penalty_sfx.play();
}
}
function playBGM() {
bgm.currentTime = 0;
bgm.play();
}
function stopBGM() {
bgm.pause();
bgm.currentTime = 0;
}
"""
),
※playBGM()
を呼び出す
def start_game(self) -> list:
"""ゲームを開始し、初期スコアと時間を設定します。
Returns:
list: ゲームの進行を管理するための関数リスト。
"""
self.score = 0
self.time_remaining = GAME_DURATION
self.game_active = True
self.generate_positions()
return [GameState.tick, rx.call_script("playBGM()")]
OGP設定(meta属性の追加)
ゲームと全然関係ないですが、Xなどでリンクを張るとカード表示されるやつです。
meta = [
{"name": "twitter:card", "content": "summary"},
{"property": "og:description", "content": "Tapして遊ぶ簡単ゲーム。目指せ高得点!"},
{"property": "og:title", "content": "Tap!Tap!Tap!"},
{"property": "og:image", "content": "https://ul.h3z.jp/V4SJUWtE.png"},
{"property": "og:type", "content": "website"},
{"property": "og:url", "content": "https://reflex-tap.reflex.run/"},
]
@rx.page(route="/", title="Tap!Tap!Tap!", meta=meta)
def index() -> rx.box:
~~
おわりに
なんといっても公式サイトのAIチャットが非常に優秀で、コンポーネントの使い方などを詳細に教えてくれるのでサクサク開発が進みます!