🎮

Pyxelで簡単なゲームを作ってみた話

2024/12/15に公開

これは GO Inc. Advent Calendar 2024 の 15 日目の記事です。

この記事の内容は業務と全く関係ないですが、Advent Calenderなのでまあいいでしょう。(ゲーミフィケーションかなにかに活用したいという野望はあります)

はじめに

少し前にPyxelというレトロゲーム開発エンジンを見つけました。

使える色は16色、同時に再生できる音は4音までなど、レトロゲーム機を意識してかなり機能が制約されたシンプルな仕様で、簡単なゲームを作る環境としては良さそうです。Pythonで書けるのもうれしいですね。

このエンジンについては作者の方が記事を書いているのでそちらを参照いただければと思います。
https://qiita.com/kitao/items/eae53dd47c663b497352

今回はpyxelを使って簡単なゲームを作成してみたのでその内容を記載します。

作成したもの

こちらから遊べます

コード

猫を操作して制限時間内に重複することなくすべての通行可能なマスを踏むというシンプルなゲームです。1分くらいで遊べます。

pyxelの基本的な機能の使い方は公式や、探せばいくつも出てくるので、ここでは工夫したポイントを書いていきます。

ゲーム部分

ステージはランダムで毎回自動生成するようにしています。高さ、幅、障害物の数と位置を設定した後、深さ優先探索でステージがクリア可能かを判定し、クリア可能なステージが出るまで繰り返しステージ作成を行います。

これにより毎回異なるマップが出現するようにできていますが、ステージが7x7くらいの大きさになるとランダム生成ではクリア可能なパターンが減るためにステージ生成に数秒かかってしまうケースが出てしまうのが課題点です。

余談ですが、今回のようなgridをすべて1回通過するかどうかという判定で、1つのgridをnode、grid間のパスをedgeとすると、すべてのedgeを通るかどうかだと一筆書きの判定は準オイラーグラフでできるのですが、すべてのnodeを通るかどうかだとハミルトングラフで判定する必要があり、これはNP完全問題らしいです。

障害物が少なめで簡単なステージが生成されることが多いですが、時間制約が10秒と短めなのでミスをしたり、たまに難度の高いステージが生成されることがあって悪くない感じになったかなと思います。

ステージ生成コード:

def generate_valid_stage(self):
    while True:
        stage = self.generate_random_stage()
        is_one_stroke_possible, start_x, start_y = self.is_one_stroke_possible(
            stage
        )
        if is_one_stroke_possible:
            return stage, start_x, start_y

def generate_random_stage(self):
    # self.current_stageによって、ステージの難易度を変更
    size_base = (self.current_stage + 1) // 3 + 3
    width = random.randint(size_base, size_base + 3)  # ランダムな幅
    height = random.randint(size_base, size_base + 3)

    # 画面をはみ出すので最大値を7に設定
    width = min(width, 7)
    height = min(height, 7)

    stage = [[0 for _ in range(width)] for _ in range(height)]
    # 1次元目がx, 2次元目がy,widthがx,heightがy

    # ランダムに障害物を配置
    num_obstacles = random.randint(self.current_stage // 2 + 2, width * height // 3)
    num_obstacles = random.randint(min(width, height) - 1, width * height // 3)
    coordinates = [(x, y) for x in range(width) for y in range(height)]
    random.shuffle(coordinates)
    obstacles = set(coordinates[:num_obstacles])

    for x, y in obstacles:
        stage[y][x] = 1  # 障害物を配置

    return stage



def is_one_stroke_possible(self, stage):
    width = len(stage[0])
    height = len(stage)

    # 深さ優先探索、のちの判定のために深さを返す
    def dfs(x, y, visited, depth):
        if x < 0 or x >= width or y < 0 or y >= height:
            return depth - 1
        # 訪問済み、または、障害物の場合は、深さを返す
        if visited[y][x] or stage[y][x] == 1:
            return depth - 1
        visited[y][x] = True
        max_depth = depth
        for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
            max_depth = max(max_depth, dfs(x + dx, y + dy, visited, depth + 1))
            # print(x + dx, y + dy, depth + 1, max_depth)
        return max_depth

    # dfs探索のスタート地点を探す
    start_x, start_y = None, None
    # スタート位置の偏りを無くしたいので、ランダムにスタート位置を選択
    coordinates = [(x, y) for y in range(height) for x in range(width)]
    # print(coordinates)
    random.shuffle(coordinates)
    for x, y in coordinates:
        if stage[y][x] == 0:  # 通行可能マスの場合
            start_x, start_y = x, y
            break
    if start_x is None:
        return False, None, None

    # 連結成分を確認
    visited = [[False for _ in range(width)] for _ in range(height)]
    max_depth = dfs(start_x, start_y, visited, 0)

    valid_point_num = 0
    for y in range(height):
        for x in range(width):
            if stage[y][x] == 0:
                valid_point_num += 1
    if max_depth != valid_point_num - 1:
        return False, None, None
    else:
        return True, start_x, start_y

また、今回素材はほぼ公式のサンプルに含まれているものを利用しているのですが、猫(player)を動かしたかったので1枚だけ差分のドット絵を作成しています。

左:元の絵、右、アニメーション用で作成したドット絵

元絵をコピーして少しずらしただけの一枚を追加しただけですが、この2枚でアニメーションさせることで有機物感が結構上がりました。pyxelだと専用のエディタがあり、pngをドロップするだけでドット絵が取り込めるのも楽でした。

Opning画面

ただ文字を出すだけでは面白くないので、stray感を出すためにタイトルロゴの文字がランダムに動き出すようにしてみました。

少し崩れたところ

文字を動かす部分のコード:

# 10frameに一回ランダムで動かす
if pyxel.frame_count >= 60 and pyxel.frame_count % 30 == 0:
    self.title_positions = [
        (x + random.randint(-1, 1), y + random.randint(-1, 1))
        for x, y in self.title_positions
    ]
# 各文字を個別に描画
for i, (x, y) in enumerate(self.title_positions):
    pyxel.text(x, y, "STRAY STEPS"[i], 9, self.umplus10)

文字をいい感じにしたいときに詰まったところですが、pyxelだと文字サイズを引数で変更することができません。サイズを変更したいときは、そのサイズをもったフォントを読み込んで使うか、画像素材で文字を作るかのどちらかになります。文字のドット絵を描くことも考えましたがちょっと時間がかかりそうなので、今回はサンプルに入っていたumplus10という別のフォントを利用させていただきました。

Result画面

ここもただスコアを表示するだけではつまらない、ということでスコアに応じて猫が増えるようにしました。またそれぞれの猫は個別に動きます。これだけでも猫を増やすために高得点を取りたいというモチベが上がりますね。ちなみに500点ごとに1匹増えます。

猫を個別に動かすコード:

for i, (cat_x, cat_y, state) in enumerate(self.cats):
    # stateは1/30の確率で変化
    if random.randint(0, 30) == 0:
        state = state ^ 1
        # 状態更新
        self.cats[i] = (cat_x, cat_y, state)
    # animation
    if state == 0:
        #猫通常
        pyxel.blt(cat_x, cat_y, 0, 0, 0, 16, 16, 13)
    else:
        #猫しゃがみ
        pyxel.blt(cat_x, cat_y, 0, 16, 0, 16, 16, 13)

deployについて

Pyxelのカスタムタグがあり、以下の内容のhtmlファイルを生成することでgithub pagesで公開できます。

<script src="https://cdn.jsdelivr.net/gh/kitao/pyxel/wasm/pyxel.js"></script>
<pyxel-run name="main.py" gamepad="enabled"></pyxel-run>

参考:https://github.com/kitao/pyxel/blob/main/docs/pyxel-web-ja.md

おわりに

ゲーム制作は楽しい!次はもう少しゲームとして完成度の高いものも作ってみたいですね。

VSバグ

350行程度のコードなのに既に想定外挙動が2つほど見つかっています。

  • 実は斜め移動ができます。十字キーだから油断してたけど同じフレームで同時判定できてしまう…。

  • result画面で猫を無限に増やせる抜け道がありました…。面白いのでこのまま残してあります。

Discussion