1. はじめに
友人たちと「フェチ」と「バーコードバトラー」の話で盛り上がった勢いで、画像からステータスを生成して対戦するアプリを作ってみました。
技術スタックは、最近お気に入りの Flet (Python) です。
なお、4月も中盤ですね~
何でもよいので、楽しみつつ5月の連休に入りたいですね~
こんな感じのアプリです。良ければ遊んでみてください。
画像を2つ読み込みさせて、バトルスタートするだけ!
自己満足の世界です。
2. 技術スタック
- 言語: Python 3.x
-
パッケージ管理:
uv(爆速で快適です) - GUIフレームワーク: Flet (FlutterベースのPythonライブラリ)
- 公開環境: GitHub Pages (PyodideによるWasm動作)
3. 実装のポイント
ここからは、こだわったポイントや苦労した点を紹介します。
① 画像から「素材」と「ステータス」を抽出するロジック
画像バイナリを hashlib.sha256 でハッシュ化し、その文字列の特定の場所をシード値としてステータスを決定しています。
これにより、**「同じ画像なら必ず同じステータスになる」**という、かつてのバーコードバトラーのようなワクワク感を再現しました。
# ハッシュ値をスライスして16進数から数値へ変換
seed = hashlib.sha256(img_bytes).hexdigest()
hp = int(seed[0:4], 16) % 5000 + 3000
atk = int(seed[4:8], 16) % 1000 + 500
② Fletでの非同期処理(async/await)の罠
開発中、RuntimeWarning: coroutine was never awaited というエラーに悩まされました。
Fletのイベントハンドラに lambda で非同期関数を渡すと、コルーチンが実行されずに放置されてしまうのが原因です。
NG例:
on_click=lambda e: handle_pick(e, 0)
OK例:
専用の非同期ハンドラを定義して直接渡すことで解決しました。
async def pick_player_1(e):
await handle_pick(0)
# 中略
ft.FilledButton("UNIT 1 選択", on_click=pick_player_1)
4. GitHub Pagesへの公開
FletはWebアプリとしてビルドできるのが強みですが、GitHub Pagesで公開する際はリポジトリ名に合わせたパスの設定が必要です。
flet publish main.py --base-url /feti-battler/
この --base-url を忘れると、JavaScriptやアセットの読み込みで 404 エラー祭りになるので注意が必要です。
5. ソースコード
フェチバトラー ソース全量
import flet as ft
import hashlib
import base64
import asyncio
import random
async def main(page: ft.Page):
page.title = "フェチバトル: デュエル"
page.theme_mode = ft.ThemeMode.DARK
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
page.scroll = ft.ScrollMode.AUTO
players = [
{"data": None, "ui": None},
{"data": None, "ui": None}
]
battle_log = ft.Column(spacing=5)
battle_button = ft.FilledButton(
"BATTLE START!",
visible=False,
width=200,
height=50,
style=ft.ButtonStyle(color=ft.Colors.WHITE, bgcolor=ft.Colors.RED)
)
def analyze_image(img_bytes):
seed = hashlib.sha256(img_bytes).hexdigest()
hp = int(seed[0:4], 16) % 5000 + 3000
atk = int(seed[4:8], 16) % 1000 + 500
spd = int(seed[8:12], 16) % 100 + 10
material_val = int(seed[10:13], 16) % 255
if material_val < 50:
m, col = "漆黒 of ラバー", ft.Colors.AMBER_ACCENT_200
elif material_val < 110:
m, col = "至高 of スキン", ft.Colors.ORANGE_200
elif material_val < 170:
m, col = "伝統 of スク水 / 制服", ft.Colors.BLUE_800
elif material_val < 230:
m, col = "聖なるシルク / ナース", ft.Colors.WHITE
else:
m, col = "未知の魔導素材", ft.Colors.GREY_400
return {"hp": hp, "max_hp": hp, "atk": atk, "spd": spd, "material": m, "color": col}
# --- バトルロジック (HPリアルタイム更新版) ---
async def run_battle(e):
battle_button.disabled = True
battle_log.controls.clear()
# データの参照を短く定義
p1_data, p2_data = players[0]["data"], players[1]["data"]
p1_ui, p2_ui = players[0]["ui"], players[1]["ui"]
# バトル開始時にHPを最大まで回復
p1_data["hp"], p2_data["hp"] = p1_data["max_hp"], p2_data["max_hp"]
# UI上のHP表示をリセット
p1_ui.content.controls[3].value = f"HP:{p1_data['hp']} ATK:{p1_data['atk']} SPD:{p1_data['spd']}"
p2_ui.content.controls[3].value = f"HP:{p2_data['hp']} ATK:{p2_data['atk']} SPD:{p2_data['spd']}"
log_add("── BATTLE START ──", ft.Colors.YELLOW)
page.update()
while p1_data["hp"] > 0 and p2_data["hp"] > 0:
# 素早さに基づく先行判定
if p1_data["spd"] + random.randint(0, 20) >= p2_data["spd"] + random.randint(0, 20):
turn_order = [(p1_data, p2_data, p2_ui, "Player 1"), (p2_data, p1_data, p1_ui, "Player 2")]
else:
turn_order = [(p2_data, p1_data, p1_ui, "Player 2"), (p1_data, p2_data, p2_ui, "Player 1")]
for attacker, defender, def_ui, attacker_name in turn_order:
if attacker["hp"] <= 0 or defender["hp"] <= 0:
continue
# ダメージ計算
dmg = int(attacker["atk"] * random.uniform(0.9, 1.2))
defender["hp"] -= dmg
if defender["hp"] < 0: defender["hp"] = 0
# --- ここで防御側のHP表示を更新 ---
def_ui.content.controls[3].value = f"HP:{defender['hp']} ATK:{defender['atk']} SPD:{defender['spd']}"
# ダメージを受けた際に少し赤く光らせる演出(おまけ)
def_ui.border = ft.Border.all(2, ft.Colors.RED_400)
log_add(f"{attacker_name}:{attacker['material']} の攻撃! {dmg} ダメージ!", attacker["color"])
await asyncio.sleep(0.3) # 演出用ウェイト
def_ui.border = ft.Border.all(1, ft.Colors.WHITE24) # 枠の色を戻す
page.update()
if defender["hp"] <= 0:
log_add(f"── {attacker['material']} WIN! ──", ft.Colors.RED_ACCENT)
break
battle_button.disabled = False
page.update()
def log_add(text, color):
battle_log.controls.insert(0, ft.Text(text, color=color, weight="bold"))
page.update()
# コアとなる画像選択ロジック
async def handle_pick(idx):
# picker = ft.FilePicker()
# page.overlay.append(picker)
# page.update()
# ファイルピッカーを起動(Web版対応のためwith_data=True)
files = await ft.FilePicker().pick_files(
file_type=ft.FilePickerFileType.IMAGE,
allow_multiple=False,
with_data=True
)
if not files or not files[0].bytes:
# page.overlay.remove(picker)
page.update()
return
img_bytes = files[0].bytes
res = analyze_image(img_bytes)
players[idx]["data"] = res
img_base64 = base64.b64encode(img_bytes).decode()
players[idx]["ui"].content.controls[1].src = f"data:image/png;base64,{img_base64}"
players[idx]["ui"].content.controls[2].value = res["material"]
players[idx]["ui"].content.controls[2].color = res["color"]
players[idx]["ui"].content.controls[3].value = f"HP:{res['hp']} ATK:{res['atk']} SPD:{res['spd']}"
players[idx]["ui"].visible = True
if players[0]["data"] and players[1]["data"]:
battle_button.visible = True
page.update()
# --- 重要:lambdaを使わず、awaitを明示した専用ハンドラを作成 ---
async def pick_player_1(e):
await handle_pick(0)
async def pick_player_2(e):
await handle_pick(1)
def create_card(idx):
return ft.Container(
visible=False,
padding=10,
border=ft.Border.all(1, ft.Colors.WHITE24),
border_radius=10,
content=ft.Column([
ft.Text(f"UNIT {idx+1}", size=12, color="grey"),
ft.Image(src="", width=140, height=140, fit="cover", border_radius=5),
ft.Text("", size=16, weight="bold"),
ft.Text("", size=12),
], horizontal_alignment="center")
)
players[0]["ui"] = create_card(0)
players[1]["ui"] = create_card(1)
battle_button.on_click = run_battle
page.add(
ft.Text("フェチバトル: デュエル", size=32, weight="black"),
ft.Row([
ft.Column([
# lambdaではなく、定義したasync関数を直接渡す
ft.FilledButton("UNIT 1 選択", on_click=pick_player_1),
players[0]["ui"]
], horizontal_alignment="center"),
ft.Text("VS", size=20, weight="bold"),
ft.Column([
# lambdaではなく、定義したasync関数を直接渡す
ft.FilledButton("UNIT 2 選択", on_click=pick_player_2),
players[1]["ui"]
], horizontal_alignment="center"),
], alignment="center", spacing=20),
ft.Divider(height=30, color="transparent"),
battle_button,
ft.Container(
content=battle_log,
height=200,
width=400,
bgcolor="black",
padding=10,
border_radius=5
)
)
if __name__ == "__main__":
ft.run(main)
6. おわりに
勢いで作ったアプリですが、Pythonだけでフロントエンドまで完結できるFletの強力さを改めて実感しました。
次はスキルの追加や、もっと「素材」の種類を増やしてみたいと思います。