1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

The Story of Creating a Pyxel Game with Almost No Handwritten Code

Last updated at Posted at 2024-12-24

pyxel_rd.png

日本語の記事はこちら

Introduction

This article is my contribution for Day 23 of the Pyxel Advent Calendar 2024.

Using Pyxel, a lightweight game engine built with Python, I embarked on a mini-experiment to see how much of the game creation process could be automated. My endeavor culminated in a reasonably functional game, thanks largely to ChatGPT's assistance in writing the code.

The game in question is "SameGame," a simple puzzle game where players aim to eliminate all tiles on the screen by selecting clusters of tiles of the same color.

Here is the final version at this point:

Pyxel SameGame - AI-Assisted, Advent Calendar Final Version

The link will play audio when opened.

Playable on smartphones as well.


Why Not Let Generative AI Create a Game in Pyxel?

This project stemmed from a simple question: "How quickly and effectively can generative AI create a game?"

Given Pyxel's lightweight nature and Python's compatibility with ChatGPT, I anticipated a good match for AI-generated code.

Most significantly, the greatest hurdle in game development—or programming in general—is often acquiring a basic, functional prototype. If generative AI can help lower that initial barrier, it could encourage more people to dive into coding and game creation.

The First Step:

I began by asking ChatGPT:

What is SameGame?

This was not a trivial question 🙄 but a way to establish a shared understanding of the game rules. After discussing variations of the rules, I prompted:

Write a basic implementation of SameGame for Pyxel, with a minimal grid and few colors—just enough to get it running.

The following code was generated in seconds:

import pyxel

# Constants
GRID_WIDTH = 5
GRID_HEIGHT = 5
CELL_SIZE = 16
COLORS = [1, 2, 3]  # Pyxel color indices

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:
            for bx, by in blocks_to_remove:
                self.grid[by][bx] = -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()

It only took seconds for ChatGPT to generate code that looked plausible. It seemed like it might just work, don’t you think?

That fleeting, optimistic thought—"Oh, this might actually work!"—turns out to be surprisingly important.


Getting Pyxel Up and Running

I was new to Pyxel, so setting up the environment took some effort. Here's a brief outline of the steps I followed:

  1. Uninstalled an older version (2.0.10) that couldn’t be upgraded via brew update / brew upgrade.
  2. Followed the official installation guide.
brew install pipx
pipx ensurepath
pipx install pyxel

The Very First Functional "SameGame"

At first glance, the code generated above seemed ready to run, but it didn’t work at all. So, I turned back to ChatGPT for assistance.

Here’s the Pyxel documentation. Please use it to create a basic SameGame. Below is the SameGame source code written without the documentation. Feel free to reference it as needed.
(I pasted the code from before)

I provided the official documentation in Markdown format as an attachment. While the AI didn’t seem to utilize it much, it quickly produced new code. When I tried running it, errors occurred, which I pasted back into the prompt:

$ pyxel run samegame.py
Traceback (most recent call last):
  File "/Users/xxx/.local/bin/pyxel", line 8, in <module>
    sys.exit(cli())
             ~~~^^
  File "/Users/xxx/.local/pipx/venvs/pyxel/lib/python3.13/site-packages/pyxel/cli.py", line 62, in cli
    command[1](*sys.argv[2:])
    ~~~~~~~~~~^^^^^^^^^^^^^^^
  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 "<frozen runpy>", line 287, in run_path
  File "<frozen runpy>", line 98, in _run_module_code
  File "<frozen runpy>", line 88, in _run_code
  File "samegame.py", line 7, in <module>
    COLORS = [pyxel.COLOR_RED, pyxel.COLOR_GREEN, pyxel.COLOR_BLUE]  # Pyxel color indices
                                                  ^^^^^^^^^^^^^^^^
AttributeError: module 'pyxel' has no attribute 'COLOR_BLUE'. Did you mean: 'COLOR_BLACK'?

I didn’t ask it to "fix this" or "resolve the error"—I just pasted the error message as-is. After three iterations of this process, the code became error-free and executable. However, it didn’t yet perform as expected.

It works now, but there’s no mouse cursor, so I can’t see where I’m clicking.

Following this pattern, I kept pointing out issues with simple feedback:

The blocks fall upward and to the left. Could you reverse that to downward and to the left?

and...

Add a sound effect when blocks disappear.

and...

Before further customization, I’d like to publish this using the Pyxel web launcher. I’ve attached the official documentation. Could you guide me on how to do that?

Step by step, I managed to get the game running locally and even playable in a web browser. The state at that point looked like this:

Pyxel SameGame - AI-Assisted, Early Functional Version

It was far from a polished game, but the "SameGame mechanics" were functional!

From this point onward, most of the work on my end was either pasting error messages or describing what I wanted the AI to do in plain language.


The Journey of AI-Assisted Game Development

The full prompts I used can be found in this shared link, but the general process involved the following steps:

1. Implementing Basic Mechanics

  • Designed transitions: Title Screen → Game Screen → End Screen → Score Display → High Score Display.
  • Added buttons for Retry and Quit.

2. Enhancing the User Interface

  • Displayed the score at the bottom of the screen.
  • Added difficulty settings and time limits.

3. Incorporating Audio and Music

  • Added sound effects for tile removal and background music during gameplay, using resources like 8bit BGM Generator.

SS 2024-12-23 20.02.37.png

Top Left: The very first "just barely working" version
Top Right: Added Retry button and screen transitions
Bottom Left: Difficulty levels implemented
Bottom Right: Background music added (though you can’t tell from the screenshot)


The Code

The final version of the code is available on GitHub:

GitHub - hnsol/pyxel-samegame

final code (click to open)
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()

The Outcome and Challenges

The game is not complete, but the "functional SameGame" now includes the following features:

[Reposting] Pyxel SameGame - AI-Assisted, Advent Calendar Final Version

Features Implemented:

  • Tile removal mechanics.
  • Five difficulty levels.
  • Score calculation with bonuses.
  • Retry and quit functionality.
  • Dynamic background music.

Challenges Faced:

  1. Model Limitations: Balancing high-accuracy models with usage constraints.
  2. Unfamiliar Territory: Debugging AI-generated code for lesser-known libraries like Pyxel.
  3. Regression Issues: Code regressions when reusing prompts.

What I Really Wanted to Achieve

  1. Visual and Audio Customization
    I wanted to add animation effects when tiles disappeared and implement conditional sound effects, but time constraints prevented me from achieving this.

  2. Expanding the Gameplay Experience

    • Replacing tiles with bitmap images (with variations and selection options).
    • Implementing joystick controls to make the game compatible with retro gaming devices.
  3. Adding Unique Elements
    I had planned to include a "career fortune-telling" feature using hidden scores, but I couldn’t get around to it.


While this project revealed the great potential of generative AI for game development and code creation, it also highlighted the importance of human understanding and adjustments. Leveraging AI’s strength in quickly turning rough ideas into working prototypes, I found myself wishing for more time to focus on adding creativity and playfulness to the game.

Conclusion

This endeavor was greatly aided by the code generation capabilities of generative AI. At the same time, it underscored the ongoing need for human oversight and fine-tuning. Nonetheless, being able to create a reasonably functional game with minimal manual coding was a remarkable experience that demonstrated how far technology has advanced.

For those interested in developing games with Pyxel, I encourage you to explore the possibilities of leveraging generative AI. Even with limited experience, having a "working product" in your hands quickly can spark new ideas and learning opportunities.

While I regret not being able to add more "playfulness" and "juiciness" to the game, I’m reasonably satisfied with how much I was able to accomplish within the limited time available.

Considering I started from setting up the environment, worked a full day, and finished writing this Advent Calendar article all within 24 hours, I think I can give myself some credit 🙄.

I also learned a lot about Pyxel, and above all, I had a truly enjoyable time experimenting and problem-solving. "I’m exhausted, but it was fun!" I hope this article offers at least one useful insight for you as well.

Addendum

Even after the Advent Calendar, I’ve continued debugging and improving the game here:

Pyxel SameGame - Ongoing Updates Post-Advent Calendar Version

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?