2024年の年末暇だったので、Pyxel でマインスイーパーを実装してみた。かなり手軽に、けっこういい感じにできたのでQiitaでシェアする。
画像リソース
Pyxelは音楽や画像を専用のリソースファイルとして管理できる。うまく利用すればコードが簡潔になる。
画像の並びが重要で、座標0〜8までは爆弾の数と一致するようにしておくとコードが簡潔になる。
0:セルオープン&周囲に爆弾なし
1〜8:セルオープン&周囲の爆弾の数
9:セルクローズ
10:旗設置済
11:爆弾表示(ゲーム終了時)
12:爆弾爆発(ゲーム失敗時)
処理の流れ
-
状態を初期化する
- 爆弾配置マップ
BOMB_MAP
(0:爆弾なし、1:爆弾あり):すべて0
- セルの状態
FIELD
0〜12(画像リソースと連動):すべて9
(セルクローズ) - その他の状態:
- 初手か:
True
- ゲームオーバーか:
False
- 経過時間(秒):
0
- 残り爆弾数(=旗を立てると減る):
99
(上級)
- 初手か:
- 爆弾配置マップ
-
初手の処理(爆弾をランダム配置)
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
-
左クリック時の処理(セルオープン)※再帰処理を伴う探索を伴う
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]
-
中クリック時の処理(周囲セル同時オープン)
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マスのセルを開ける
-
右クリック時の処理(旗の設置)
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増やす
-
描画処理
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()