Pyxelでマインスイーパーを作ってみた。

2024年の年末暇だったので、[Pyxel](https://github.com/kitao/pyxel/blob/main/docs/README.ja.md) でマインスイーパーを実装してみた。かなり手軽に、けっこういい感じにできたのでQiitaでシェアする。

![pyxel-20250113-140044](https://github.com/user-attachments/assets/a0d0548c-5964-4fad-b81d-867d5fd972ad)

[こちら](https://kitao.github.io/pyxel/wasm/launcher/?run=tiquer17.pyxel.minesweeper.minesweeper) でプレイできます！

遊び方

- Q: ゲーム終了
- R: リトライ
- 左クリック (左矢印キー): セルを開ける
- 右クリック (右矢印キー): 旗を立てる・下ろす
- 中クリック（下矢印キー）：セルの周りをすべて開ける（数字分の旗が立ってるとき）

# 画像リソース

Pyxelは音楽や画像を専用のリソースファイルとして管理できる。うまく利用すればコードが簡潔になる。

セルの全状態を描いてIMAGE 0 に保存しておく。
![Screenshot 2025-01-02 at 21.16.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445370/4021c5bf-8096-0adf-ea24-5df0fda1f5e0.png)

画像の並びが重要で、座標0〜8までは爆弾の数と一致するようにしておくとコードが簡潔になる。
0：セルオープン&周囲に爆弾なし
1〜8：セルオープン&周囲の爆弾の数
9：セルクローズ
10：旗設置済
11：爆弾表示（ゲーム終了時）
12：爆弾爆発（ゲーム失敗時）

# 処理の流れ

1. 状態を初期化する
      - 爆弾配置マップ`BOMB_MAP`（0：爆弾なし、1：爆弾あり）：すべて`0`
      - セルの状態`FIELD` 0〜12（画像リソースと連動）：すべて`9`（セルクローズ）
      - その他の状態：
        - 初手か：`True`
        - ゲームオーバーか：`False`
        - 経過時間（秒）：`0`
        - 残り爆弾数（=旗を立てると減る）：`99`（上級）

1. 初手の処理（爆弾をランダム配置）

    ```python
        pos = y * WIDTH + x
        pyxel.rseed(int(time.time())) # いつも同じ配置にならないように乱数シードを与える
        for r in sorted([((pyxel.rndf(0, 1), i)) for i in range(WIDTH * HEIGHT)]): # 乱数が大きいポジションから順に爆弾を置いていく。
            if r[1] == pos: # 初手は必ず成功させるために、初手位置には爆弾を置かない
                continue
            if i == NUM_BOMBS: # 爆弾数に達したら処理を抜ける。
                break
            BOMB_MAP[r[1] // WIDTH][r[1] % WIDTH] = 1 # 爆弾の場所に 1 を設定
            i += 1
    ```

1. 左クリック時の処理（セルオープン）※再帰処理を伴う探索を伴う

    ```python
    def openRecursive(x, y):
        if x < 0 or x >= WIDTH or y < 0 or y >= HEIGHT:
            return # マウスが枠外のときは何もしない
        if FIELD[y][x] != 9:
            return # セルクローズ状態以外は何もしない
        b = 0 # 爆弾数
        for j in [-1, 0, 1]:
            for i in [-1, 0, 1]:
                b += getBomb(x+i, y+j) # 周辺の爆弾数を加算
        FIELD[y][x] = b # 爆弾数を設定（セルオープン状態になる）
        if b ==  0: # 爆弾数がゼロ（空欄）なら再帰的に周辺のセルも探索オープンする
            for j in [-1, 0, 1]:
                for i in [-1, 0, 1]:
                    openRecursive(x+i, y+j) 

    def getBomb(x, y):
        if x < 0 or x >= WIDTH or y < 0 or y >= HEIGHT:
            return 0 # 枠外なら0を返す
        else:
            return BOMB_MAP[y][x]
    ```

1. 中クリック時の処理（周囲セル同時オープン）

    ```python
        if num <= 8 and num >= 1 and getNumFlags(x, y) == num: # セルに爆弾数表示があり、かつ旗の数と一致していれば
        for j in [-1, 0, 1]:
            for i in [-1, 0, 1]:
                open(x+i, y+j) #周囲8マスのセルを開ける
    ```

1. 右クリック時の処理（旗の設置）

    ```python
        if FIELD[y][x] == 9: # セルが閉じていて旗がなければ
            FIELD[y][x] = 10 # 旗を設置する
            STATE['bombs'] -= 1 # 爆弾数表示を1減らす
        elif FIELD[y][x] == 10: #セルに旗があれば
            FIELD[y][x] = 9 # 旗を除去する
            STATE['bombs'] += 1 #爆弾数表示を1増やす
    ```

1. 描画処理

    ```python
    pyxel.cls(1) # 濃い青色で初期化（縁の色）
    time, bombs = STATE['time'], STATE['bombs']
    pyxel.text(3 * TILE, 2,  f"{bombs:3}", 6) # 爆弾数を左側に表示
    pyxel.text((WIDTH - 4) * TILE, 2, f"{time:3}", 6) # 経過時間を右側に表示
    for y in range(HEIGHT):
        for x in range(WIDTH):
            # 全セルを（再）描画する。FIELDの値が画像の座標位置と連動。
            pyxel.blt((x+1) * TILE, (y+1) * TILE, 0, FIELD[y][x] * TILE, 0, TILE, TILE)
    ```

# Pyxelで実装した感想

pyxelは`__init__()`で初期化処理、`update()`で入力に応じた状態更新処理、`draw()`で描画処理を書けばいいだけだから、とっても楽。
本プログラムでは毎フレーム毎に、すべてを描画し直しているが、全くチラつかない。初手クリックの瞬間に乱数を480個生成して爆弾設置も行っているが、この程度の処理なら1フレーム（1/30秒）以内に余裕で収まる。レトロゲーム作成では余程のことをしないかぎり、性能を気にする必要はないだろう。
画像や音楽などのマルチメディア素材はリソースファイルに管理されているので、コーディングでロジック作成に注力できるのも良いところだ。

試作ながらも、プレイ感覚はWindowsに付属していたマインスイーパーそのもので、当時のように熱中プレイしてしまった。これがPythonコードたった140行で書けるのは、いい時代になった思う。

# 全ソースコード
```python
import pyxel
import time

NUM_BOMBS = 99 
WIDTH = 30
HEIGHT = 16
TILE = 8
BOMB_MAP = [[0] * WIDTH for _ in range(HEIGHT)] # bomb allocation map
FIELD = [[9] * WIDTH for _ in range(HEIGHT)] # state of each field cell
# 0 open with no bombs around.
# 1 2 3 4 5 6 7 8 open with number of bombs around.
# 9 closed, 10 flag, 11 bomb, 12 explosion

STATE = {
    'isGameOver': False,
    'isFirst': True,
    'time': 0,
    'bombs': NUM_BOMBS,
}

def getBomb(x, y):
    if x < 0 or x >= WIDTH or y < 0 or y >= HEIGHT:
        return 0
    else:
        return BOMB_MAP[y][x]

def getNumFlags(x, y):
    ret = 0
    for j in [-1, 0, 1]:
        for i in [-1, 0, 1]:
            new_x, new_y = x + i, y + j
            if new_x < 0 or new_x >= WIDTH or new_y < 0 or new_y >= HEIGHT:
                continue
            if FIELD[new_y][new_x] == 10:
                ret += 1
    return ret

def openRecursive(x, y):
    if x < 0 or x >= WIDTH or y < 0 or y >= HEIGHT:
        return
    if FIELD[y][x] != 9:
        return
    b = 0
    for j in [-1, 0, 1]:
        for i in [-1, 0, 1]:
            b += getBomb(x+i, y+j)
    FIELD[y][x] = b
    if b ==  0:
        for j in [-1, 0, 1]:
            for i in [-1, 0, 1]:
                openRecursive(x+i, y+j)

def open(x, y):
    if x < 0 or x >= WIDTH or y < 0 or y >= HEIGHT:
        return
    if FIELD[y][x] == 9:
        if BOMB_MAP[y][x] == 1:
            openBombs()
            FIELD[y][x] = 12
            STATE['isGameOver'] = True
        else:
            openRecursive(x, y)

def checkClear():
    target = WIDTH * HEIGHT - NUM_BOMBS
    for y in range(HEIGHT):
        for x in range(WIDTH):
            if FIELD[y][x] < 9:
                target -= 1
    if target <= 0:
        STATE['isGameOver'] = True
        STATE['bombs'] = 0
        openBombs()

def openBombs():
    for y in range(HEIGHT):
        for x in range(WIDTH):
            if BOMB_MAP[y][x] == 1:
                FIELD[y][x] = 11

class App:

    def __init__(self):
        pyxel.init((WIDTH + 2) * TILE, (HEIGHT + 2) * TILE, title="Minesweeper")
        pyxel.load("minesweeper.pyxres")
        pyxel.mouse(True)
        pyxel.run(self.update, self.draw)

    def update(self):
        if pyxel.btnp(pyxel.KEY_Q):
            pyxel.quit()
        if STATE['isGameOver']:
            return
        if pyxel.frame_count % 30 == 0 and not STATE['isFirst']:
            STATE['time'] += 1
        x, y = pyxel.mouse_x // TILE - 1, pyxel.mouse_y // TILE - 1
        if x < 0 or x >= WIDTH or y < 0 or y >= HEIGHT:
            return
        if pyxel.btnr(pyxel.MOUSE_BUTTON_MIDDLE):
            num = FIELD[y][x]
            if num <= 8 and num >= 1 and getNumFlags(x, y) == num:
                for j in [-1, 0, 1]:
                    for i in [-1, 0, 1]:
                        open(x+i, y+j)
                checkClear()
        elif pyxel.btnr(pyxel.MOUSE_BUTTON_RIGHT):
            if FIELD[y][x] == 9:
                FIELD[y][x] = 10
                STATE['bombs'] -= 1
            elif FIELD[y][x] == 10:
                FIELD[y][x] = 9
                STATE['bombs'] += 1
        elif pyxel.btnr(pyxel.MOUSE_BUTTON_LEFT):
            if STATE['isFirst']:
                i = 0
                pos = y * WIDTH + x
                pyxel.rseed(int(time.time()))
                for r in sorted([((pyxel.rndf(0, 1), i)) for i in range(WIDTH * HEIGHT)]):
                    if r[1] == pos:
                        continue
                    if i == NUM_BOMBS:
                        break
                    BOMB_MAP[r[1] // WIDTH][r[1] % WIDTH] = 1
                    i += 1
                STATE['isFirst'] = False
            open(x, y)
            checkClear()

    def draw(self):
        pyxel.cls(1)
        time, bombs = STATE['time'], STATE['bombs']
        pyxel.text(3 * TILE, 2,  f"{bombs:3}", 6)
        pyxel.text((WIDTH - 4) * TILE, 2, f"{time:3}", 6)
        for y in range(HEIGHT):
            for x in range(WIDTH):
                pyxel.blt((x+1) * TILE, (y+1) * TILE, 0, FIELD[y][x] * TILE, 0, TILE, TILE)

App()
```