21
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyxelAdvent Calendar 2024

Day 23

ほとんど手でコードを書かずにPyxelのゲームを作ろうとした話

Last updated at Posted at 2024-12-23

pyxel_rd.png

English version available here

はじめに

この記事は、Pyxel Advent Calendar 2024の23日目の記事となります。

Python製の軽量なゲームエンジンであるPyxelを使って、ゲームを作るのにどれだけ人間の手を省けるか試してみました。その結果、ほとんどChatGPTにコードを書いてもらいながらそこそこゲームっぽくなるところまでもっていけたのではないかとおもいます。

題材は「さめがめ(SameGame)」というシンプルなパズルゲーム。同色のタイルがつながっていれば消すことができ、画面のタイルをすべて消すことを目指します。

まずは、現時点での最終版をご覧ください:

Pyxel版さめがめ、コード書かない版、アドベントカレンダー用最終版

リンクを開くと音が出ます

スマホでも動かせます


そうだ、生成AIにPyxelのゲームを作らせてみよう

このミニプロジェクトを思いついたきっかけは、「生成AIにコードを書かせたらどれだけ素早く、どれくらいのゲームが作れるのか?」という好奇心からです。

Pyxelは非常に軽量ですし、Pythonのコードは生成AI(ChatGPT)もよく食べているでしょうし、コード生成能力との相性が良さそうでした。

なにより、ゲームづくり、あるいはプログラミング全般に、いちばん敷居が高いのは「素朴でいいから動き始めているコードを手にすること」ではないかとおもいます。

もし生成AIに、最初の敷居を越えてもらえるなら、もっとたくさんの人が気軽にコードを書いて、手軽にゲームをつくれるのではないかと。

最初の一歩:

まず、ChatGPTに質問しました。

さめがめってどういうゲームだっけ?

ふざけてるのではなくて🙄、ゲームのルールというコンテクスト共有が重要だとおもいまして。ルールのバリエーションを少し議論したのち、

なるべく素朴に、Pyxelで動くさめがめのコードを書いて。ブロックの色数も少なめ、マス数も少なめで、まずは動くところを見たい

と投げました。するとかえってきたコードが下記です。

import pyxel

# 定数設定
GRID_WIDTH = 5
GRID_HEIGHT = 5
CELL_SIZE = 16
COLORS = [1, 2, 3]  # Pyxelの色番号

class SameGame:
    def __init__(self):
        pyxel.init(GRID_WIDTH * CELL_SIZE, GRID_HEIGHT * CELL_SIZE, caption="SameGame")
        self.grid = [[pyxel.rndi(0, len(COLORS) - 1) for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
        self.score = 0
        pyxel.run(self.update, self.draw)

    def update(self):
        # マウスクリックを検出
        if pyxel.btnp(pyxel.MOUSE_LEFT_BUTTON):
            x = pyxel.mouse_x // CELL_SIZE
            y = pyxel.mouse_y // CELL_SIZE
            if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
                self.handle_click(x, y)

    def handle_click(self, x, y):
        # クリックしたセルの色を取得
        color = self.grid[y][x]
        if color == -1:  # 空白セルは無視
            return

        # 消すブロックを探索
        blocks_to_remove = self.find_connected_blocks(x, y, color)
        if len(blocks_to_remove) > 1:  # 隣接するブロックが2個以上なら消去
            for bx, by in blocks_to_remove:
                self.grid[by][bx] = -1  # 消去済みのセルは -1 に設定
            self.score += len(blocks_to_remove)
            self.apply_gravity()

    def find_connected_blocks(self, x, y, color):
        # 再帰的に隣接ブロックを探索
        stack = [(x, y)]
        visited = set()
        connected = []

        while stack:
            cx, cy = stack.pop()
            if (cx, cy) in visited:
                continue
            visited.add((cx, cy))
            if self.grid[cy][cx] == color:
                connected.append((cx, cy))
                # 隣接するセルをスタックに追加
                for nx, ny in [(cx - 1, cy), (cx + 1, cy), (cx, cy - 1), (cx, cy + 1)]:
                    if 0 <= nx < GRID_WIDTH and 0 <= ny < GRID_HEIGHT:
                        stack.append((nx, ny))
        return connected

    def apply_gravity(self):
        # ブロックを下に詰める
        for x in range(GRID_WIDTH):
            column = [self.grid[y][x] for y in range(GRID_HEIGHT) if self.grid[y][x] != -1]
            for y in range(GRID_HEIGHT):
                self.grid[y][x] = column[y] if y < len(column) else -1

    def draw(self):
        pyxel.cls(0)
        # グリッド描画
        for y in range(GRID_HEIGHT):
            for x in range(GRID_WIDTH):
                color = self.grid[y][x]
                if color != -1:
                    pyxel.rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE, COLORS[color])
        # スコア表示
        pyxel.text(5, 5, f"Score: {self.score}", 7)

# ゲーム開始
SameGame()

それっぽいコードが秒で返ってきて、なんとかなりそうでしょう?

「おっ、なんとかなりそう」という勘違いが、案外と大事な気がします

実際にPyxelで動かすところまでが意外と大変だった

Pyxelでの開発はほとんどしたことなかったので、環境づくりに悩まされました。ここでは詳細を省きますが、ごく大まかには下記のとおりでした。

  • 古いバージョン(2.0.10)がインストールされており、brew update / brew upgrade では2.2.8にバージョンアップできず
  • brewでアンインストールし公式ドキュメントに沿ってインストール
brew install pipx
pipx ensurepath
pipx install pyxel

ごく初期の、なんとかうごく「さめがめ」

上記コードは、ぱっと見では動きそうに見えますが、ぜんぜん動きません。ですので、またChatGPTに丸投げします。

添付は、pyxelのドキュメントです。これを参考にして、素朴なさめがめを書いてください。下記はドキュメント無しでChatGPTが書いたさめがめのソースコードです。必要に応じて参考にしてください。
(コードをペースト)

添付ファイルとして、公式ドキュメントをMarkdown形式で、プロンプトに渡しました。あんまり活用してもらえませんでしたが。

すぐにコードが返ってきます。それを動かすとエラーが起きるので、エラーをプロンプトに貼ります。

$ pyxel run samegame.py
Traceback (most recent call last):
File "/Users/xxx/.local/bin/pyxel", line 8, in
sys.exit(cli())
~~~^^
File "/Users/xxx/.local/pipx/venvs/pyxel/lib/python3.13/site-packages/pyxel/cli.py", line 62, in cli
command1
~~~~~~~~~~^^^^^^^^^^^^^^^
File "/Users/xxx/.local/pipx/venvs/pyxel/lib/python3.13/site-packages/pyxel/cli.py", line 219, in run_python_script
runpy.run_path(python_script_file, run_name="main")
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "", line 287, in run_path
File "", line 98, in _run_module_code
File "", line 88, in _run_code
File "samegame.py", line 7, in
COLORS = [pyxel.COLOR_RED, pyxel.COLOR_GREEN, pyxel.COLOR_BLUE] # Pyxelの色番号
^^^^^^^^^^^^^^^^
AttributeError: module 'pyxel' has no attribute 'COLOR_BLUE'. Did you mean: 'COLOR_BLACK'?

「エラーが起きましたが」とか「直してください」とも言わない。エラーメッセージを貼るだけの手抜き作業。

3回繰り返したらエラーは出ず動くコードになりました。しかし期待する動作ではない。

動くようになりましたが、マウスカーソルがでなくて、どこを触っているかわかりません

こんな調子で、次々に、思い通りじゃないところを言葉で伝えます。

ブロックの落ちる向きが、上・左です。逆で、下・左にしてほしい

消えるときにサウンドをつけて

これ以上カスタマイズを進める前に、
pyxel web launcherをつかって、公開したい。
公式ドキュメントを添付したので、やりかたをおしえて

こうやってなんとなくローカルで動き、ウェブブラウザからでも動く状態まで持ってきました。そのときの状態が下記です。

Pyxel版さめがめ、コード書かない版、極初期のなんとなく動いた版

ぜんぜんゲームの体裁になってないですが、「メカニクスとしてのさめがめ」は動いてます!!

このあとも、人間がやる作業は、だいたい「エラーメッセージを貼る」か「やってほしいことを言葉で伝える」です

生成AIと二人三脚で進めたゲーム制作のステップ

こと細かなプロンプトなプロンプトはリンクを見ていただくとして、おおまかな過程はこんな感じでした。

1. 画面遷移をつくる

タイトル画面 → ゲーム画面 → 消せるタイルなし(ゲームエンド) → 今回のスコア → ハイスコア画面、という大きな流れをつくる

2. UIの改良

ゲーム画面の上にボタンを付けて、再挑戦や、ギブアップができるように。
ゲーム画面の下の方に、スコアが表示されるように。

3. 難易度の追加

5段階の難易度設定に。これに関して、時間制限も追加。

画面遷移は、タイトル画面 → 難易度選択画面 → ゲーム画面 → 打ち手なし(ゲームエンド)またはタイムアップ → 今回のスコア → ハイスコア画面、という流れに変更。

UIも、難易度表示と、残り時間表示を追加。

4. BGMや効果音追加

タイルを消したときの音や、ゲーム中BGMを追加。

BGMの曲の作成にあたっては、しろもふファクトリーさん謹製のGitHub - shiromofufactory/8bit-bgm-generatorをつかわせていただきました。

SS 2024-12-23 20.02.37.png

左上:極初期とりあえず動く版
右上:Retryボタンや画面遷移がつき始めた
左下:難易度追加
右下:BGM追加(スクショではわかりませんが)

コード

今回作成したコードはGitHubリポジトリにあります。

pyxel-samegame/pyxel-samegame1847.py at main · hnsol/pyxel-samegame · GitHub

コード(クリックで開きます)
import pyxel
import json
import copy
from enum import Enum

# 定数の設定
WINDOW_WIDTH = 240
WINDOW_HEIGHT = 240

#BUTTON_WIDTH = 80
#BUTTON_HEIGHT = 20
BUTTON_WIDTH = 75
BUTTON_HEIGHT = 15
BUTTON_SPACING = 10
BUTTON_AREA_HEIGHT = 100  # ボタンエリアの高さ(縦にボタンを並べるため拡大)
STATUS_AREA_HEIGHT = 30   # 表示エリアの高さ

COLORS = [8, 11, 12, 13, 14, 15, 6, 7]  # 使用可能なPyxelの色番号
DEFAULT_TOP_SCORES = [10000, 5000, 2500, 1000, 500, 250, 100, 50, 25, 10]  # デフォルトのトップ10スコア

class GameState(Enum):
    OPENING = "opening"
    DIFFICULTY_SELECTION = "difficulty_selection"
    GAME_START = "game_start"
    GAME_MID = "game_mid"
    GAME_END = "game_end"
    TIME_UP = "time_up"
    NO_MOVES = "no_moves"
    GAME_CLEARED = "game_cleared"
    SCORE_DISPLAY = "score_display"
    HIGH_SCORE_DISPLAY = "high_score_display"

class Button:
    def __init__(self, x, y, width, height, label):
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.label = label

    def is_hovered(self, mx, my):
        return self.x <= mx <= self.x + self.width and self.y <= my <= self.y + self.height

    def draw(self, is_hovered):
        color = pyxel.COLOR_LIGHT_BLUE if is_hovered else pyxel.COLOR_GRAY
        pyxel.rect(self.x, self.y, self.width, self.height, color)
        text_x = self.x + (self.width // 2) - (len(self.label) * 2)
        text_y = self.y + (self.height // 2) - 4
        pyxel.text(text_x, text_y, self.label.capitalize(), pyxel.COLOR_WHITE)

class SameGame:
    def __init__(self):
        # BGM関連の初期化
        self.bgm_files = {
            GameState.OPENING: "assets/opening_music.json",            # オープニング画面のBGM
            GameState.DIFFICULTY_SELECTION: "assets/selection_music.json", # 難易度選択画面のBGM
            GameState.GAME_START: "assets/gameplay_start_music.json", # ゲーム序盤のBGM
            GameState.GAME_MID: "assets/gameplay_mid_music.json",     # ゲーム中盤のBGM
            GameState.GAME_END: "assets/gameplay_end_music.json",     # ゲーム終盤のBGM
            GameState.TIME_UP: "assets/time_up_music.json",           # タイムアップ時のBGM
            GameState.NO_MOVES: "assets/no_moves_music.json",         # 動ける手がなくなった時のBGM
            GameState.GAME_CLEARED: "assets/cleared_music.json",      # ゲームクリア時のBGM
        }
        self.bgm_data = {}
        self.current_bgm = None

        self.load_bgms()

        self.difficulty_levels = {
            "Easy": {"grid_rows": 5, "grid_cols": 5, "colors": 3, "time_limit": None, "score_multiplier": 1.0},
            "Normal": {"grid_rows": 7, "grid_cols": 12, "colors": 4, "time_limit": None, "score_multiplier": 1.2},
            "Hard": {"grid_rows": 9, "grid_cols": 15, "colors": 5, "time_limit": 60, "score_multiplier": 1.5},
            "Very Hard": {"grid_rows": 8, "grid_cols": 15, "colors": 6, "time_limit": 45, "score_multiplier": 2.0},
            "Expert": {"grid_rows": 10, "grid_cols": 20, "colors": 8, "time_limit": 30, "score_multiplier": 3.0},
        }
        self.current_difficulty = "Easy"
        self.grid_rows = self.difficulty_levels[self.current_difficulty]["grid_rows"]
        self.grid_cols = self.difficulty_levels[self.current_difficulty]["grid_cols"]
        self.num_colors = self.difficulty_levels[self.current_difficulty]["colors"]
        self.time_limit = self.difficulty_levels[self.current_difficulty]["time_limit"]
        self.score_multiplier = self.difficulty_levels[self.current_difficulty]["score_multiplier"]

        pyxel.init(WINDOW_WIDTH, WINDOW_HEIGHT)
        pyxel.mouse(True)
        pyxel.title = "SameGame"
        self.state = GameState.OPENING
        self.high_scores = DEFAULT_TOP_SCORES[:]
        self.current_score_rank = None
        self.start_time = None
        self.initial_grid = []
        self.bgm_tracks = self.setup_bgm()
        self.current_bgm = None

        self.reset_game(initial=True)
        self.create_sounds()

        self.difficulty_buttons = []
        self.create_difficulty_buttons()

        self.current_bgm = None  # 現在再生中のBGMを記録
        pyxel.run(self.update, self.draw)

    def load_bgms(self):
        for state, file_path in self.bgm_files.items():
            try:
                with open(file_path, "rt") as fin:
                    self.bgm_data[state] = json.loads(fin.read())
#                    print(f"BGM data loaded for {state.name}: {self.bgm_data[state]}")  # デバッグ用
            except FileNotFoundError:
                print(f"BGM file not found: {file_path}")
            except json.JSONDecodeError:
                print(f"BGM file is not valid JSON: {file_path}")
            except Exception as e:
                print(f"Error loading BGM file for state {state.name}: {e}")

    def setup_bgm(self):
        """Initialize BGM mappings for states and game logic."""
        return {
            GameState.OPENING: 0,              # Intro BGM (track 0)
            GameState.DIFFICULTY_SELECTION: 1, # Difficulty selection BGM (track 1)
            GameState.GAME_START: 2,           # Main game BGM (track 2)
            GameState.TIME_UP: 3,              # Game over BGM (track 3)
            GameState.NO_MOVES: 4,             # No moves BGM (track 4)
            GameState.GAME_CLEARED: 5,         # Game cleared BGM (track 5)
        }

    def play_bgm(self, state):
        """指定された状態に対応するBGMを再生"""
        if self.current_bgm == state:
            return  # 既に再生中の場合は何もしない
    
        print(f"Switching to BGM for state in play_bgm: {state.name}")  # デバッグ用

        # 現在のBGMを停止
        self.stop_bgm()

        self.current_bgm = state
    
        # 指定されたステートのBGMが存在する場合、再生
        if state in self.bgm_data:
            bgm_channels = [1, 2, 3]  # チャンネル1〜3をBGM用に使用
            for ch, sound in zip(bgm_channels, self.bgm_data[state]):
                pyxel.sounds[ch].set(*sound)
                pyxel.play(ch, ch, loop=True)  # チャンネルごとにループ再生
#                if not pyxel.play_pos(ch):  # チャンネルが再生されていない場合のみ再生
#                    pyxel.play(ch, ch, loop=True)
        else:
            print(f"BGM data not found for state: {state.name}")  # デバッグ用
    
    def stop_bgm(self):
#        """現在再生中のBGMを停止する"""
#        if self.current_bgm is not None:
#            bgm_channels = [1, 2, 3]  # BGM用のチャンネル
#            for ch in bgm_channels:
#                pyxel.stop(ch)
#            self.current_bgm = None  # 現在のBGM状態をリセット
#        bgm_channels = [1, 2, 3]  # BGM用のチャンネル
        bgm_channels = [0, 1, 2, 3]  # 全チャンネル消す
        for ch in bgm_channels:
            pyxel.stop(ch)  # チャンネルごとに停止
        self.current_bgm = None  # 現在のBGM状態をリセット

    def create_difficulty_buttons(self):
        # 各難易度のラベルと説明
        difficulties = [
            {"label": "Easy",      "description": "Small grid, few colors"},
            {"label": "Normal",    "description": "Larger grid, more colors"},
            {"label": "Hard",      "description": "Timed play, more colors"},
            {"label": "Very Hard", "description": "Shorter time, even more colors"},
            {"label": "Expert",    "description": "Maximum grid size, most colors"},
        ]
        # ボタンを縦に並べるための開始位置を計算(中央に配置)
        start_x = (WINDOW_WIDTH - BUTTON_WIDTH) // 2 - 60
        start_y = 40
        for i, diff in enumerate(difficulties):
            x = start_x
            y = start_y + i * (BUTTON_HEIGHT + BUTTON_SPACING)
            self.difficulty_buttons.append(Button(x, y, BUTTON_WIDTH, BUTTON_HEIGHT, diff["label"]))
        self.difficulties = difficulties  # 説明のために保持

    def create_sounds(self):
        """ゲーム内の効果音を準備"""
        self.base_notes = ["c2", "d2", "e2", "f2", "g2", "a2", "b2", "c3"]
        pyxel.sounds[0].set(
            notes=self.base_notes[0], #消したマスの色によって音を変えるのは未実装
            tones="p",
            volumes="5",
            effects="n",
            speed=15,
        )

    def reset_game(self, initial=False):
        if initial or not hasattr(self, 'initial_grid'):
            self.grid = [
                [pyxel.rndi(0, self.num_colors - 1) for _ in range(self.grid_cols)]
                for _ in range(self.grid_rows)
            ]
            self.initial_grid = copy.deepcopy(self.grid)
        else:
            self.grid = copy.deepcopy(self.initial_grid)
        self.start_time = pyxel.frame_count if self.time_limit else None
        self.score = 0

    def calculate_progress(self):
        """盤面の進行状況を計算"""
        total_cells = self.grid_rows * self.grid_cols
        remaining_cells = sum(1 for row in self.grid for cell in row if cell != -1)
        removed_percentage = (total_cells - remaining_cells) / total_cells
        return remaining_cells, removed_percentage

    def reset_game(self, initial=False):
        """ゲームをリセット"""
        if initial or not hasattr(self, 'initial_grid'):
            self.grid = [
                [pyxel.rndi(0, self.num_colors - 1) for _ in range(self.grid_cols)]
                for _ in range(self.grid_rows)
            ]
            # 初期盤面を保存
            self.initial_grid = copy.deepcopy(self.grid)
        else:
            # 保存した初期盤面を復元
            self.grid = copy.deepcopy(self.initial_grid)

        self.start_time = pyxel.frame_count if self.time_limit else None
        self.score = 0

    def update(self):
        """ゲームの状態を更新"""
        mx, my = pyxel.mouse_x, pyxel.mouse_y

        # Retryボタンの処理
        retry_x = BUTTON_SPACING
        retry_y = (BUTTON_AREA_HEIGHT - BUTTON_HEIGHT) // 2
        if (
            retry_x <= mx <= retry_x + BUTTON_WIDTH
            and retry_y <= my <= retry_y + BUTTON_HEIGHT
            and pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT)
        ):
            print("Retry button clicked")
            self.reset_game(initial=False)  # 保存済みの初期状態に戻す
            self.state = GameState.GAME_START  # ゲームを最初から開始
            return

        # Quitボタンの処理
        quit_x = BUTTON_SPACING + BUTTON_WIDTH + BUTTON_SPACING
        quit_y = (BUTTON_AREA_HEIGHT - BUTTON_HEIGHT) // 2
        if (
            quit_x <= mx <= quit_x + BUTTON_WIDTH
            and quit_y <= my <= quit_y + BUTTON_HEIGHT
            and pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT)
        ):
            print("Quit button clicked")
            self.state = GameState.SCORE_DISPLAY  # SCORE_DISPLAY画面に遷移
            return

        previous_state = self.state  # ステータスの変更を追跡
    
        if self.state == GameState.OPENING:
#            print("GameState is OPENING")  # デバッグ出力
            if self.current_bgm != GameState.OPENING:
                self.play_bgm(GameState.OPENING)
            if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                print("Clicked in opening screen")  # デバッグ出力
                self.state = GameState.DIFFICULTY_SELECTION
                print(f"State changed to: {self.state}")  # 状態変更後の確認

        elif self.state == GameState.DIFFICULTY_SELECTION:
#            print(f"GameState is: {self.state}") # デバッグ出力
            if self.current_bgm != GameState.DIFFICULTY_SELECTION:
                self.play_bgm(GameState.DIFFICULTY_SELECTION)
                print(f"Switching to BGM for state state name: {state.name}")  # デバッグ用
                print(f"Switching to BGM for state game state: {GameState.DIFFICULTY_SELECTION}")  # デバッグ用
            for button in self.difficulty_buttons:
                if button.is_hovered(mx, my):
                    if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                        self.current_difficulty = button.label
                        self.apply_difficulty_settings()
                        self.state = GameState.GAME_START
    
        elif self.state in [GameState.GAME_START, GameState.GAME_MID, GameState.GAME_END]:
            # 序盤、中盤、終盤の進行状態を確認
            remaining_cells, removed_percentage = self.calculate_progress()

            if self.state == GameState.GAME_START:
                if self.current_bgm != GameState.GAME_START:
                    self.play_bgm(GameState.GAME_START)

                if removed_percentage >= 0.2:  # コマ数が20%減少したら中盤へ移行
                    self.state = GameState.GAME_MID
    
            elif self.state == GameState.GAME_MID:
                if self.current_bgm != GameState.GAME_MID:
                    self.play_bgm(GameState.GAME_MID)
                is_low_time = (
                    self.time_limit
                    and (self.time_limit - (pyxel.frame_count - self.start_time) // 30) <= 10
                )
                if remaining_cells / (self.grid_rows * self.grid_cols) <= 0.25 or is_low_time:
                    self.state = GameState.GAME_END
            elif self.state == GameState.GAME_END:
                if self.current_bgm != GameState.GAME_END:
                    self.play_bgm(GameState.GAME_END)
    
            # 共通ゲーム進行処理
            if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                self.handle_click(mx, my)
            if self.time_limit and pyxel.frame_count - self.start_time > self.time_limit * 30:
                self.state = GameState.TIME_UP
            elif not self.has_valid_moves():
                self.state = GameState.NO_MOVES
            elif self.is_grid_empty():
                self.state = GameState.GAME_CLEARED
    
        elif self.state == GameState.TIME_UP:
            if self.current_bgm != GameState.TIME_UP:
                self.play_bgm(GameState.TIME_UP)
            if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                self.update_high_scores()
                self.state = GameState.SCORE_DISPLAY
    
        elif self.state == GameState.NO_MOVES:
            if self.current_bgm != GameState.NO_MOVES:
                self.play_bgm(GameState.NO_MOVES)
            if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                self.update_high_scores()
                self.state = GameState.SCORE_DISPLAY
    
        elif self.state == GameState.GAME_CLEARED:
            if self.current_bgm != GameState.GAME_CLEARED:
                self.play_bgm(GameState.GAME_CLEARED)

            # ボーナススコアを加算{
            bonus_score = int(self.score * 0.5)  # 現在のスコアの50%をボーナス
            self.score += bonus_score
            print(f"Bonus Score Added: {bonus_score}")  # デバッグ用

            if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                self.update_high_scores()
                self.state = GameState.SCORE_DISPLAY
    
        elif self.state == GameState.SCORE_DISPLAY:
            if self.current_bgm != GameState.OPENING:
                self.play_bgm(GameState.OPENING)
            if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                self.state = GameState.HIGH_SCORE_DISPLAY
    
        elif self.state == GameState.HIGH_SCORE_DISPLAY:
            if self.current_bgm != GameState.OPENING:
                self.play_bgm(GameState.OPENING)
            if pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT):
                self.state = GameState.OPENING
    
        # ステータス変更時のBGM切り替え
        if self.state != previous_state:
            self.handle_state_change()

    def apply_difficulty_settings(self):
        settings = self.difficulty_levels[self.current_difficulty]
        self.grid_rows = settings["grid_rows"]
        self.grid_cols = settings["grid_cols"]
        self.num_colors = settings["colors"]
        self.time_limit = settings["time_limit"]
        self.score_multiplier = settings["score_multiplier"]
        self.reset_game(initial=True)

    def handle_click(self, mx, my):
        """盤面クリック時の処理"""
        game_area_y = BUTTON_AREA_HEIGHT
        game_area_height = WINDOW_HEIGHT - BUTTON_AREA_HEIGHT - STATUS_AREA_HEIGHT
        cell_size = min(WINDOW_WIDTH // self.grid_cols, game_area_height // self.grid_rows)
        grid_x_start = (WINDOW_WIDTH - (cell_size * self.grid_cols)) // 2
        grid_y_start = game_area_y + (game_area_height - (cell_size * self.grid_rows)) // 2
    
        x = (mx - grid_x_start) // cell_size
        y = (my - grid_y_start) // cell_size
    
        if 0 <= x < self.grid_cols and 0 <= y < self.grid_rows:
            color = self.grid[y][x]
            if color == -1:
                return
    
            # 消去処理
            blocks_to_remove = self.find_connected_blocks(x, y, color)
            if len(blocks_to_remove) > 1:
                for bx, by in blocks_to_remove:
                    self.grid[by][bx] = -1
    
                # 効果音専用チャンネル(0番)で再生
                pyxel.play(0, color)
                self.score += int(len(blocks_to_remove) * (len(blocks_to_remove) ** 2) * self.score_multiplier)
                self.apply_gravity()
                self.shift_columns_left()

    def handle_state_change(self):
        """ステータス変更時のBGMを再生"""
        bgm_mapping = {
            GameState.GAME_START: GameState.GAME_START,
            GameState.GAME_MID: GameState.GAME_MID,
            GameState.GAME_END: GameState.GAME_END,
            GameState.TIME_UP: GameState.TIME_UP,
            GameState.NO_MOVES: GameState.NO_MOVES,
            GameState.GAME_CLEARED: GameState.GAME_CLEARED,
            GameState.OPENING: GameState.OPENING,
            GameState.DIFFICULTY_SELECTION: GameState.DIFFICULTY_SELECTION,
        }
        bgm_state = bgm_mapping.get(self.state)
        if bgm_state:
            self.play_bgm(bgm_state)

    def find_connected_blocks(self, x, y, color):
        stack = [(x, y)]
        visited = set()
        connected = []

        while stack:
            cx, cy = stack.pop()
            if (cx, cy) in visited:
                continue
            visited.add((cx, cy))
            if self.grid[cy][cx] == color:
                connected.append((cx, cy))
                for nx, ny in [(cx - 1, cy), (cx + 1, cy), (cx, cy - 1), (cx, cy + 1)]:
                    if 0 <= nx < self.grid_cols and 0 <= ny < self.grid_rows:
                        stack.append((nx, ny))
        return connected

    def apply_gravity(self):
        for x in range(self.grid_cols):
            column = [self.grid[y][x] for y in range(self.grid_rows) if self.grid[y][x] != -1]
            for y in range(self.grid_rows):
                self.grid[self.grid_rows - y - 1][x] = column[-(y + 1)] if y < len(column) else -1

    def shift_columns_left(self):
        new_grid = []
        for x in range(self.grid_cols):
            # 列が全て -1 ではないときだけ新しいグリッドに追加
            if any(self.grid[y][x] != -1 for y in range(self.grid_rows)):
                new_grid.append([self.grid[y][x] for y in range(self.grid_rows)])
        # 空の列を追加してグリッドサイズを維持
        while len(new_grid) < self.grid_cols:
            new_grid.append([-1] * self.grid_rows)
        # グリッドを更新
        for x in range(self.grid_cols):
            for y in range(self.grid_rows):
                self.grid[y][x] = new_grid[x][y]

    def has_valid_moves(self):
        for y in range(self.grid_rows):
            for x in range(self.grid_cols):
                color = self.grid[y][x]
                if color != -1 and len(self.find_connected_blocks(x, y, color)) > 1:
                    return True
        return False

    def is_grid_empty(self):
        for row in self.grid:
            for cell in row:
                if cell != -1:
                    return False
        return True

    def update_high_scores(self):
        if self.score not in self.high_scores:
            self.high_scores.append(self.score)
        self.high_scores.sort(reverse=True)
        self.high_scores = self.high_scores[:10]
        try:
            self.current_score_rank = self.high_scores.index(self.score)
        except ValueError:
            self.current_score_rank = None

    def draw(self):
        # 画面をクリア
        pyxel.cls(0)
    
        if self.state == GameState.OPENING:
#            pyxel.text(WINDOW_WIDTH // 2 - 60, WINDOW_HEIGHT // 2 - 10, "Welcome to SameGame", pyxel.COLOR_WHITE)
#            pyxel.text(WINDOW_WIDTH // 2 - 50, WINDOW_HEIGHT // 2 + 10, "Click to Start", pyxel.COLOR_WHITE)
            pyxel.text(80, 50, "Welcome to SameGame", pyxel.COLOR_WHITE)
            pyxel.text(30, 70, "How to Play:", pyxel.COLOR_YELLOW)
            pyxel.text(30, 90, "1. Click connected blocks to remove them.", pyxel.COLOR_WHITE)
            pyxel.text(30, 100, "2. Remove more blocks at once for higher scores.", pyxel.COLOR_WHITE)
            pyxel.text(30, 110, "3. Clear all blocks for a bonus!", pyxel.COLOR_WHITE)
            pyxel.text(30, 120, "4. Higher difficulty means higher scores!", pyxel.COLOR_WHITE)
            pyxel.text(30, 130, "5. No moves left? Game over.", pyxel.COLOR_WHITE)
            pyxel.text(80, 160, "Click to Start", pyxel.COLOR_WHITE)

        elif self.state == GameState.DIFFICULTY_SELECTION:
            pyxel.text(WINDOW_WIDTH // 2 - 60, 10, "Select Difficulty", pyxel.COLOR_YELLOW)
            for i, button in enumerate(self.difficulty_buttons):
                is_hovered = button.is_hovered(pyxel.mouse_x, pyxel.mouse_y)
                button.draw(is_hovered)
                # 説明文をボタンの右側に表示
                description = self.difficulties[i]["description"]
                pyxel.text(button.x + button.width + 10, button.y + 5, description, pyxel.COLOR_WHITE)
    
        elif self.state in [GameState.GAME_START, GameState.GAME_MID, GameState.GAME_END]:
            # 盤面とボタン・ステータスを描画
            self.draw_buttons()
            self.draw_grid()
            self.draw_score_and_time()
    
        elif self.state in [GameState.TIME_UP, GameState.NO_MOVES, GameState.GAME_CLEARED]:
            # 盤面を消さずにそのまま描画し、上にテキストを重ねる
            self.draw_buttons()
            self.draw_grid()
            self.draw_score_and_time()
    
            # それぞれの状態に応じたメッセージを上書き
            if self.state == GameState.TIME_UP:
                pyxel.text(WINDOW_WIDTH // 2 - 30, WINDOW_HEIGHT // 2 - 10, "Time's Up!", pyxel.COLOR_RED)
            elif self.state == GameState.NO_MOVES:
                pyxel.text(WINDOW_WIDTH // 2 - 50, WINDOW_HEIGHT // 2 - 10, "No Moves Available!", pyxel.COLOR_RED)
            elif self.state == GameState.GAME_CLEARED:
                pyxel.text(WINDOW_WIDTH // 2 - 70, WINDOW_HEIGHT // 2 - 10, "Congratulations!", pyxel.COLOR_GREEN)
                pyxel.text(WINDOW_WIDTH // 2 - 80, WINDOW_HEIGHT // 2 + 10, "You cleared the game!", pyxel.COLOR_WHITE)
                pyxel.text(WINDOW_WIDTH // 2 - 50, WINDOW_HEIGHT // 2 + 30, f"Bonus: {int(self.score * 0.5)}", pyxel.COLOR_YELLOW)
                pyxel.text(WINDOW_WIDTH // 2 - 40, WINDOW_HEIGHT // 2 + 50, "Click to Continue", pyxel.COLOR_WHITE)

        elif self.state == GameState.SCORE_DISPLAY:
            pyxel.text(WINDOW_WIDTH // 2 - 30, WINDOW_HEIGHT // 2 - 20, "Your Score", pyxel.COLOR_YELLOW)
            pyxel.text(WINDOW_WIDTH // 2 - 20, WINDOW_HEIGHT // 2, f"{int(self.score)}", pyxel.COLOR_YELLOW)
            pyxel.text(WINDOW_WIDTH // 2 - 40, WINDOW_HEIGHT // 2 + 20, "Click to Continue", pyxel.COLOR_WHITE)
    
        elif self.state == GameState.HIGH_SCORE_DISPLAY:
            pyxel.text(WINDOW_WIDTH // 2 - 60, 10, "Top 10 High Scores", pyxel.COLOR_YELLOW)
            for i, score in enumerate(self.high_scores):
                color = pyxel.COLOR_YELLOW if i == self.current_score_rank else pyxel.COLOR_WHITE
                pyxel.text(WINDOW_WIDTH // 2 - 30, 30 + i * 10, f"{i + 1}: {score}", color)
            pyxel.text(WINDOW_WIDTH // 2 - 40, WINDOW_HEIGHT - 20, "Click to Return", pyxel.COLOR_WHITE)

    def draw_buttons(self):
        """
        ボタンエリア(上部)の描画
        Retry/ Quit ボタンを左に配置し、
        難易度を右端に表示する。
        """
        # Retry ボタン
        retry_x = BUTTON_SPACING
        retry_y = (BUTTON_AREA_HEIGHT - BUTTON_HEIGHT) // 2
        pyxel.rect(retry_x, retry_y, BUTTON_WIDTH, BUTTON_HEIGHT, pyxel.COLOR_GRAY)
        pyxel.text(retry_x + 10, retry_y + 5, "Retry", pyxel.COLOR_WHITE)

        # Quit ボタン
        quit_x = BUTTON_SPACING + BUTTON_WIDTH + BUTTON_SPACING
        quit_y = (BUTTON_AREA_HEIGHT - BUTTON_HEIGHT) // 2
        pyxel.rect(quit_x, quit_y, BUTTON_WIDTH, BUTTON_HEIGHT, pyxel.COLOR_GRAY)
        pyxel.text(quit_x + 10, quit_y + 5, "Quit", pyxel.COLOR_WHITE)

        # 難易度名をボタンエリア右端に表示
        difficulty_text_x = WINDOW_WIDTH - 60
        difficulty_text_y = (BUTTON_AREA_HEIGHT - 8) // 2
        pyxel.text(difficulty_text_x, difficulty_text_y, self.current_difficulty, pyxel.COLOR_WHITE)

    def draw_grid(self):
        """
        盤面を描画
        """
        game_area_y = BUTTON_AREA_HEIGHT
        game_area_height = WINDOW_HEIGHT - BUTTON_AREA_HEIGHT - STATUS_AREA_HEIGHT
        cell_size = min(WINDOW_WIDTH // self.grid_cols, game_area_height // self.grid_rows)
        grid_x_start = (WINDOW_WIDTH - (cell_size * self.grid_cols)) // 2
        grid_y_start = game_area_y + (game_area_height - (cell_size * self.grid_rows)) // 2

        for y in range(self.grid_rows):
            for x in range(self.grid_cols):
                color = self.grid[y][x]
                if color != -1:
                    pyxel.rect(
                        grid_x_start + x * cell_size,
                        grid_y_start + y * cell_size,
                        cell_size,
                        cell_size,
                        COLORS[color]
                    )

    def draw_score_and_time(self):
        """
        画面下部にスコアと時間のみを描画
        """
        # スコア表示
        score_text = f"Score: {int(self.score)}"
        pyxel.text(10, WINDOW_HEIGHT - STATUS_AREA_HEIGHT + 5, score_text, pyxel.COLOR_WHITE)

        # タイマー表示
        if self.time_limit:
            remaining_time = max(0, self.time_limit - (pyxel.frame_count - self.start_time) // 30)
            time_text = f"Time: {remaining_time}s"
        else:
            time_text = "Time: --"
        time_text_width = len(time_text) * 4  # おおまかな文字幅
        pyxel.text(WINDOW_WIDTH - time_text_width - 10, WINDOW_HEIGHT - STATUS_AREA_HEIGHT + 5, time_text, pyxel.COLOR_WHITE)


# ゲームの開始
SameGame()

完成?したゲーム

完成はしていませんが、なんとか動くさめがめには、以下の機能が実装されています:

【再掲】Pyxel版さめがめ、コード書かない版、アドベントカレンダー用最終版

  • タイルを消すロジック
  • 難易度選択(Easy、Normal、Hardなど5段階)
  • スコア計算とボーナス機能
  • リトライと終了ボタン
  • 状況に応じて変わるBGM

コードはほぼすべてChatGPTが生成したもので、人間は主に動作確認と微調整を行うだけでした。

課題

  1. モデルの性能差による制約
    高性能なo1は精度が高く頼りになる反面、使用回数の制限があるため、どこで使うか慎重に判断する必要がありました。特に細かい修正が多いと、思わぬところで時間を消費することがありました。一方、精度が低い4oを活かすために、どれくらいの塊で作業依頼をすればいいのかの知見は溜まってきました

  2. 未知領域での苦戦
    Pyxelのように情報が少ないライブラリでは、AIの生成コードが非効率だったり動作が不安定なこともありました。人間側の知識が薄い部分の修正には時間がかかり、こちらとしての学習がけっきょく必要でした。逆に、学習を促してくれる意味ではありがたかったです

  3. デグレード問題
    以前修正した箇所が新しい生成コードで元に戻る(デグレードする)ことが多発しました。特にPyxelの特有の挙動や実装のクセが反映されにくい場面が散見されました。Pyxelのコードが世間に増えていけば、この問題も改善される可能性が高いので、未来は明るいと思います


本当はやりたかったこと

  1. 見た目と音のカスタマイズ
    タイルが消える際にアニメーション効果や条件ごとの音のエフェクトを追加したかったのですが、時間の都合で実現できませんでした。

  2. プレイ体験の幅を広げる

    • タイルではなくビットマップ画像に(柄のバリエーション、選択機能もつけたかった)
    • ジョイスティック操作の実装(レトロゲーム機対応)
  3. 独自要素の追加
    隠れスコアを利用した「職業占い機能」を付ける計画もしていましたが、こちらも着手できずに終わりました。


生成AIを活用したゲーム開発・コード作成は多くの可能性を感じさせてくれる一方で、人間の理解力や調整力がまだ必要な場面も多いことがわかりました。「アイデアを雑にでもいいから形にする」部分での生成AIの強みを活かしつつ、「遊び心を加える」部分にもっと時間を使えると楽しかも、とおもいました。

終わりに

今回のこころみでは、生成AIのコード生成能力に大いに助けられました。その一方、人間の手による確認や調整が依然として重要なことも痛感しました。それでも、ほとんどコードを書かずにゲームをそれなりの形にできたのは、技術の進化を感じさせる素晴らしい体験でした。

Pyxelを使ったゲーム開発に興味がある方には、生成AIを活用する方法をぜひ試してみていただきたいです。経験が浅くても「動くもの」を素早く手にすることで、新たな発想や学びが得られるかもしれません。

もっとゲームに「遊び心」「ジューシーさ」を加えたかったという反省もありますが、限られた時間の中でここまで進められたことには、ある程度の満足感を得ています。

環境構築から開始して、日中は仕事もし、アドベントカレンダーの記事を書き終えるところまで24時間以内で進めたという意味では、それほど悪くないと、自分で自分をほめてあげたい🙄

Pyxelについても学べましたし、そして何より楽しい試行錯誤の時間を通じて、貴重な経験ができました。「つかれたけど、楽しかった〜!」読んでくださった皆さんにも、何かひとつでも参考になる点があれば幸いです。

追補

アドベントカレンダー後も、こちらでバグ取り・改良を継続しております。

Pyxel版さめがめ、アドベントカレンダー後もちまちまと更新版

21
18
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
21
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?