3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-01-02

2024年の年末暇だったので、Pyxel でマインスイーパーを実装してみた。かなり手軽に、けっこういい感じにできたのでQiitaでシェアする。

Screenshot 2025-01-02 at 23.27.04.png

画像リソース

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

セルの全状態を描いてIMAGE 0 に保存しておく。
Screenshot 2025-01-02 at 21.16.16.png

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

処理の流れ

  1. 状態を初期化する

    • 爆弾配置マップBOMB_MAP(0:爆弾なし、1:爆弾あり):すべて0
    • セルの状態FIELD 0〜12(画像リソースと連動):すべて9(セルクローズ)
    • その他の状態:
      • 初手か:True
      • ゲームオーバーか:False
      • 経過時間(秒):0
      • 残り爆弾数(=旗を立てると減る):99(上級)
  2. 初手の処理(爆弾をランダム配置)

        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
    
  3. 左クリック時の処理(セルオープン)※再帰処理を伴う探索を伴う

    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]
    
  4. 中クリック時の処理(周囲セル同時オープン)

        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マスのセルを開ける
    
  5. 右クリック時の処理(旗の設置)

        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増やす
    
  6. 描画処理

    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行で書けるのは、いい時代になった思う。

全ソースコード

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()
3
3
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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?