🦔

にゃんこスイーパー

に公開
4

Python+Cursesで、にゃんこスイーパーを書いてみました。
Ubuntu/FreeBSDのターミナルで動きます。
ファイルに、chmod +x nyankosweeper.pyとして、実行権を付けて動かして下さい。

$./nyankosweeper.py [number of cats]として、動かします。
[number of cats]は、省略できます。省略すると、猫の数は56個となります。

スペースキーで開き、’z’、’m’キーで、マークを付けます。カーソルは矢印キーとviキーのhjklで動かします。’q’キーで抜け出せられます。

pyinstallerで、コンパイルできます。コンパイルしなくとも、そもそも速度はあんまり問題ないですが。

shiracamusさんの指摘を受けたので、該当する箇所を修正して、ついでにChatGPTでリファクタリングしてものを上げておきます。

2025/08/06 ゲーム終了時、flagでマークされたものが正解だった場合、そのままにしておく修正をしました。

nyankosweeper.py
#!/usr/bin/env python3

import curses
import random
import locale
import sys

class Config:
    XSIZE = 40
    BOARD_HEIGHT = 22
    YSIZE = BOARD_HEIGHT + 1
    MARKS = ['#', '🚩', '?']
    DIGITS = " 123456789"
    MAX_MINES = 300

    DIRECTIONS = {
        'LEFT': (-1, 0), 'RIGHT': (1, 0),
        'UP': (0, -1), 'DOWN': (0, 1)
    }

    KEY_BINDINGS = {
        '4': 'LEFT', 'h': 'LEFT', 'KEY_LEFT': 'LEFT',
        '6': 'RIGHT', 'l': 'RIGHT', 'KEY_RIGHT': 'RIGHT',
        '8': 'UP', 'k': 'UP', 'KEY_UP': 'UP',
        '2': 'DOWN', 'j': 'DOWN', 'KEY_DOWN': 'DOWN',
    }

class Cell:
    def __init__(self):
        self.mine = False
        self.revealed = False
        self.mark = 0
        self.adjacent = 0

    def display(self, game_over=False):
        if not self.revealed:
            if game_over and not self.mine and self.mark == 1:
                return "X"
            return Config.MARKS[self.mark]
        if self.mine:
            return "😺"
        return Config.DIGITS[self.adjacent] if self.adjacent < len(Config.DIGITS) else "?"

class Board:
    def __init__(self, mines):
        self.width = Config.XSIZE
        self.height = Config.BOARD_HEIGHT
        self.mines = mines
        self.grid = [[Cell() for _ in range(self.height)] for _ in range(self.width)]
        self.place_mines()
        self.calculate_adjacents()

    def place_mines(self):
        placed = 0
        while placed < self.mines:
            x = random.randint(0, self.width - 1)
            y = random.randint(0, self.height - 1)
            cell = self.grid[x][y]
            if not cell.mine:
                cell.mine = True
                placed += 1

    def calculate_adjacents(self):
        for x in range(self.width):
            for y in range(self.height):
                if self.grid[x][y].mine:
                    continue
                count = sum(
                    self.grid[nx][ny].mine
                    for dx in (-1, 0, 1)
                    for dy in (-1, 0, 1)
                    if (dx or dy) and 0 <= (nx := x + dx) < self.width and 0 <= (ny := y + dy) < self.height
                )
                self.grid[x][y].adjacent = count

    def reveal(self, x, y):
        cell = self.grid[x][y]
        if cell.revealed or Config.MARKS[cell.mark] != '#':
            return
        cell.revealed = True
        if cell.adjacent == 0 and not cell.mine:
            for dx in range(-1, 2):
                for dy in range(-1, 2):
                    nx, ny = x + dx, y + dy
                    if 0 <= nx < self.width and 0 <= ny < self.height:
                        self.reveal(nx, ny)

    def toggle_mark(self, x, y):
        cell = self.grid[x][y]
        if not cell.revealed:
            cell.mark = (cell.mark + 1) % len(Config.MARKS)

    def all_clear(self):
        return all(cell.revealed or cell.mine for row in self.grid for cell in row)

class GameState:
    def __init__(self, board):
        self.board = board
        self.cursor_x = board.width // 2
        self.cursor_y = board.height // 2
        self.game_over = False

class Renderer:
    def __init__(self, stdscr):
        self.stdscr = stdscr

    def draw(self, state: GameState):
        board = state.board
        for x in range(board.width):
            for y in range(board.height):
                self.draw_cell(x, y, state)

    def draw_cell(self, x, y, state: GameState):
        cell = state.board.grid[x][y]
        ch = cell.display(game_over=state.game_over)
        attr = curses.A_REVERSE if (x, y) == (state.cursor_x, state.cursor_y) else 0
        self.stdscr.addstr(y, x * 2, ch, attr)

    def show_message(self, message):
        self.stdscr.addstr(Config.YSIZE - 1, 0, message.ljust(Config.XSIZE * 2))

class Game:
    def __init__(self, stdscr, mines):
        self.stdscr = stdscr
        self.board = Board(mines)
        self.state = GameState(self.board)
        self.renderer = Renderer(stdscr)

    def run(self):
        while True:
            self.renderer.draw(self.state)
            self.stdscr.refresh()

            # ✅ 修正済:ゲームオーバー時は勝利処理をスキップ
            if not self.state.game_over and self.board.all_clear():
                self.handle_win()
                return 0

            key = self.stdscr.getkey()
            if not self.handle_key(key):
                return 1

    def handle_key(self, key):
        action = Config.KEY_BINDINGS.get(key)
        if action:
            dx, dy = Config.DIRECTIONS[action]
            self.state.cursor_x = max(0, min(self.board.width - 1, self.state.cursor_x + dx))
            self.state.cursor_y = max(0, min(self.board.height - 1, self.state.cursor_y + dy))
        elif key in ('z', 'm'):
            self.board.toggle_mark(self.state.cursor_x, self.state.cursor_y)
        elif key == ' ':
            self.handle_reveal()
        elif key == 'q':
            return False
        return True

    def handle_reveal(self):
        x, y = self.state.cursor_x, self.state.cursor_y
        cell = self.board.grid[x][y]
        if cell.mine:
            self.state.game_over = True
            self.reveal_all(show_mines=True)
            self.renderer.draw(self.state)
            self.renderer.show_message("You lose. hit key")
            self.stdscr.refresh()
            self.stdscr.getkey()
        else:
            self.board.reveal(x, y)

    def reveal_all(self, show_mines=False):
        for x in range(self.board.width):
            for y in range(self.board.height):
                cell = self.board.grid[x][y]
                if show_mines:
                    if cell.mine:
                        if cell.mark != 1:
                            cell.revealed = True
                    else:
                        cell.revealed = True
                else:
                    cell.revealed = True

    def handle_win(self):
        for x in range(self.board.width):
            for y in range(self.board.height):
                cell = self.board.grid[x][y]
                if cell.mine:
                    cell.mark = 1
                    cell.revealed = False
                else:
                    cell.revealed = True
        self.renderer.draw(self.state)
        self.renderer.show_message("You win. hit key")
        self.stdscr.refresh()
        self.stdscr.getkey()

def get_mine_count():
    try:
        if len(sys.argv) == 2:
            count = int(sys.argv[1])
            if count > Config.MAX_MINES:
                print("too many nyankos")
                exit(1)
            return count
    except ValueError:
        print("not a number")
        exit(1)
    return 56

def run(stdscr):
    curses.curs_set(0)
    stdscr.keypad(True)
    locale.setlocale(locale.LC_ALL, '')
    mines = get_mine_count()
    game = Game(stdscr, mines)
    game.run()

def main():
    curses.wrapper(run)

if __name__ == "__main__":
    main()

Discussion

shiracamusshiracamus
            for dx in range(-1, 2):
                for dy in range(-1, 2):
                    if x+dx >= 0 and y+dy >= 0 and x+dx < xsize and y+dy < ysize:
                        reveal(x+dx, y+dy)

これはこんな書き方ができたりします。

            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    if 0 <= x+dx < xsize and 0 <= y+dy < ysize:
                        reveal(x+dx, y+dy) 
            elif (c >= 0 and c <= 9):

これも同様に。

            elif 0 <= c <= 9:
1
fygar256fygar256

しらかみゅさん、ありがとうございます。参考にします。

shiracamusshiracamus

クラスにもっと役割・責務を与えたら良さそうな気がしたので実装してみました。
残数も表示するようにしてみました。
1メソッドはエディタ1画面で見渡せるように25行以内に収めました。
何か参考になることがありましたら幸いです。

#!/usr/bin/env python3

import curses
import locale
import random
import sys


class Cell:
    """掃除場所"""
    DARTY = "#"
    MINE = "😺"
    UNKNOWN = "?"
    NOTSWEEPED = DARTY, MINE, UNKNOWN
    SWEEPED = " 123456789"

    def __init__(self) -> None:
        self.mine = False
        self.sweeped = False
        self.mark = 0  # NOTSWEEPEDのインデックス番号
        self.adjacent_mines = 0  # 自セルを含む隣接セルの爆弾数

    def char(self) -> str:
        """表示文字列を返す"""
        if not self.sweeped:
            return self.NOTSWEEPED[self.mark]
        if self.mine:
            return self.MINE
        else:
            return self.SWEEPED[self.adjacent_mines]

    def toggle_mark(self) -> None:
        """掃除してないときのマークを変更する"""
        if not self.sweeped:
            self.mark = (self.mark+1) % len(self.NOTSWEEPED)

    def sweep(self) -> bool:
        """掃除して周囲に爆弾がなければTrueを返す"""
        if self.sweeped:
            return False
        self.sweeped = True
        return self.adjacent_mines == 0 and not self.mine

    def draw(self, screen, x: int, y: int, reverse: bool = False) -> None:
        """screenの座標(x, y)に非反転か反転か(reverse)で描画する"""
        screen.addstr(y, x*2, self.char(), curses.A_REVERSE if reverse else curses.A_NORMAL)


class Board:
    """掃除場面"""
    WIDTH = 40
    HEIGHT = 22
    RANGE_X = range(WIDTH)
    RANGE_Y = range(HEIGHT)

    def __init__(self, mines: int) -> None:
        """mines個の爆弾を置いた掃除場面を作る"""
        self._cells = [[Cell() for _ in self.RANGE_X] for _ in self.RANGE_Y]
        self._place_mines(mines)
        self._adjacent_mines()

    def _place_mines(self, mines: int) -> None:
        """mines個の爆弾をランダムな掃除箇所に置く"""
        while mines > 0:
            x, y = random.choice(self.RANGE_X), random.choice(self.RANGE_Y)
            cell = self._cells[y][x]
            if not cell.mine:
                cell.mine = True
                mines -= 1

    def _adjacent_mines(self) -> None:
        """各掃除場所の周囲の爆弾数を設定する"""
        for y, cells in enumerate(self._cells):
            for x, cell in enumerate(cells):
                cell.adjacent_mines = sum(self.is_mine(x+dx, y+dy)
                                          for dy in (-1, 0, 1)
                                          for dx in (-1, 0, 1))

    def is_valid(self, x: int, y: int) -> bool:
        """座標(x, y)が有効な場所ならTrueを返す"""
        return x in self.RANGE_X and y in self.RANGE_Y

    def is_mine(self, x: int, y: int) -> bool:
        """座標(x, y)に爆弾があればTrueを返す"""
        return self.is_valid(x, y) and self._cells[y][x].mine

    def is_sweeped(self) -> bool:
        """爆弾以外すべて掃除完了したらTrueを返す"""
        return all(cell.sweeped or cell.mine
                   for cells in self._cells
                   for cell in cells)

    def toggle_mark(self, x: int, y: int) -> None:
        """座標(x, y)爆弾があればTrueを返す"""
        if self.is_valid(x, y):
            self._cells[y][x].toggle_mark()

    def sweep(self, x: int, y: int):
        """座標(x, y)を掃除する"""
        if self.is_valid(x, y) and self._cells[y][x].sweep():
            # 爆弾がなかったので隣接も掃除する
            for dy in (-1, 0, 1):
                for dx in (-1, 0, 1):
                    self.sweep(x+dx, y+dy)

    def sweep_all(self) -> None:
        """全ての掃除場所を掃除する"""
        for cells in self._cells:
            for cell in cells:
                cell.sweeped = True

    def count_darty(self) -> int:
        """未掃除場所の数を返す"""
        return self.WIDTH*self.HEIGHT - sum(cell.sweeped
                                            for cells in self._cells
                                            for cell in cells)

    def draw(self, screen) -> None:
        """screenに描画する"""
        for y, cells in enumerate(self._cells):
            for x, cell in enumerate(cells):
                cell.draw(screen, x, y)

    def draw_reverse(self, screen, x: int, y: int) -> None:
        self._cells[y][x].draw(screen, x, y, reverse=True)


class Sweeper:
    """掃除人(プレイヤー)"""
    DIRECTIONS = {
        "LEFT": (-1, 0), "RIGHT": (1, 0), "UP": (0, -1), "DOWN": (0, 1)
    }

    def __init__(self, board: Board, x: int, y: int) -> None:
        """掃除場面board上の座標(x, y)から掃除を開始する"""
        self.board = board
        self.x = x
        self.y = y

    def move(self, direction: str) -> bool:
        """direction方向に移動する。移動できたらTrueを返す"""
        if direction not in self.DIRECTIONS:
            return False
        dx, dy = self.DIRECTIONS[direction]
        if self.board.is_valid(self.x+dx, self.y+dy):
            self.x += dx
            self.y += dy
        return True

    def toggle_mark(self) -> None:
        """現在位置のマークを変更する"""
        self.board.toggle_mark(self.x, self.y)

    def sweep(self):
        """開いて爆弾だったらTrueを返す"""
        self.board.sweep(self.x, self.y)
        return self.board.is_mine(self.x, self.y)

    def draw(self, screen) -> None:
        """screenに描画する"""
        self.board.draw_reverse(screen, self.x, self.y)


class MineSweeper:
    """爆弾掃除ゲーム"""
    MOVE_KEYS = {
        "4": "LEFT", "h": "LEFT", "KEY_LEFT": "LEFT",
        "6": "RIGHT", "l": "RIGHT", "KEY_RIGHT": "RIGHT",
        "8": "UP", "k": "UP", "KEY_UP": "UP",
        "2": "DOWN", "j": "DOWN", "KEY_DOWN": "DOWN",
    }
    MARK_KEYS = "z", "m"
    REVEAL_KEY = " "
    QUIT_KEY = "q"

    def __init__(self, screen, mines: int) -> None:
        """mines個の爆弾が仕掛けられた掃除場所を作る、表示はscreenに行う"""
        self.screen = screen
        self.mines = mines
        self.board = Board(mines)
        self.sweeper = Sweeper(self.board, Board.WIDTH//2, Board.HEIGHT//2)

    def play(self):
        """遊ぶ"""
        while True:
            if self.board.is_sweeped():
                self.draw("You win. hit key")
                self.screen.getkey()
                return

            self.draw(f"{self.board.count_darty()-self.mines} / "
                      f"{self.board.WIDTH*self.board.HEIGHT-self.mines}")

            key = self.screen.getkey()

            if key in self.MOVE_KEYS:
                self.sweeper.move(self.MOVE_KEYS[key])
            elif key in self.MARK_KEYS:
                self.sweeper.toggle_mark()
            elif key == self.REVEAL_KEY:
                if self.sweeper.sweep():
                    self.board.sweep_all()
                    self.draw("You lose. hit key")
                    self.screen.getkey()
                    return
            elif key == self.QUIT_KEY:
                return

    def draw(self, message: str) -> None:
        """messageとともに全て表示する"""
        self.board.draw(self.screen)
        self.sweeper.draw(self.screen)
        self.screen.addstr(Board.HEIGHT, 0, message.ljust(Board.WIDTH*2))
        self.screen.refresh()


def number_of_mines(default_mines: int = 56, max_mines: int = 300) -> int:
    """コマンド引数があれば爆弾数を取り込む、なければdefault_mines"""
    if len(sys.argv) != 2:
        return default_mines
    try:
        count = int(sys.argv[1])
        if count > max_mines:
            print("too many nyankos")
            exit(1)
        return count
    except ValueError:
        print("not a number")
        exit(1)


def run(screen) -> None:
    """画面screenで実行する"""
    curses.curs_set(0)
    screen.keypad(True)
    locale.setlocale(locale.LC_ALL, "")

    game = MineSweeper(screen, number_of_mines())
    game.play()


def main() -> None:
    """爆弾掃除ゲームで遊ぶ"""
    curses.wrapper(run)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        exit(1)
1
fygar256fygar256

しらかみゅさんのものの方がChatGPTの出力したものより徹底していますね。やはりまだ、機械より人間の方が上ということなんでしょうか。参考にいたします。ありがとうございました。

1