0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Fletで画像からステータス生成!「フェチバトル」を作ってGitHub Pagesで公開してみた

0
Last updated at Posted at 2026-04-22

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の強力さを改めて実感しました。
次はスキルの追加や、もっと「素材」の種類を増やしてみたいと思います。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?