#!/usr/bin/python3 # The MIT License (MIT) # # Copyright © 2024 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the “Software”), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from curses import \ color_content, color_pair, init_color, init_pair, start_color, \ use_default_colors, initscr, cbreak, curs_set, echo, endwin, nocbreak, \ noecho, set_escdelay, \ A_BOLD, A_DIM, A_ITALIC, A_NORMAL, A_REVERSE, A_UNDERLINE from itertools import islice from math import sqrt from random import randint from sys import argv, exit, stderr from typing import Any, List, Tuple info = ''' mines Play the MINESweeper game in a text user-interface (TUI). The Escape key quits the game immediately, the arrow keys let you move around the board, and both the Space bar/key and Enter reveal the cell you're on. The only options are one of the help options, which show this message: these are `-h`, `--h`, `-help`, and `--help`, without the quotes. You can also view this help message by pressing the F1 key while browsing folders: when done reading the message, press the Escape key to go back to the current folder entries. ''' if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'): print(info.strip(), file=stderr) exit(0) # original is later filled with the RGB-color palette at the time this script # started running, so it can be restored when the script is done original = [] # custom colors/style for the game colors = ( (192, 192, 192), (65, 79, 188), (32, 105, 1), (169, 6, 8), (1, 0, 128), (117, 2, 3), (3, 123, 127), (0, 0, 0), (128, 128, 128), ) class Cell: def __init__(self) -> None: self.mines_around = 0 self.showing = False self.mined = False class UI: def __init__(self) -> None: self.screen = None self.pos = (0, 0) self.board: List[List[Cell]] = [] def move(self, x: int, y: int) -> None: w, h = self.size() self.pos = ( wrap(self.pos[0] + x, w), wrap(self.pos[1] + y, h), ) def size(self) -> Tuple[int, int]: h = len(self.board) w = len(self.board[0]) if h > 0 else 0 return w, h def is_valid_pos(x: int, y: int) -> bool: w, h = self.size() return 0 <= x < w and 0 <= y < h def update(self) -> None: scr.erase() for y, row in enumerate(self.board): for x, cell in enumerate(row): color = 0 sel = self.pos[0] == x and self.pos[1] == y if not cell.showing: s = ' ' elif cell.mined: s = '*' else: n = cell.mines_around s = str(n) if n > 0 else ' ' color = n + 1 style = A_NORMAL if sel ^ cell.showing else A_REVERSE self.screen.addstr(y, x, s, style | color_pair(color)) scr.refresh() def wrap(x: int, max: int) -> int: return x + max if x < 0 else x % max def new_game(ui: UI) -> None: h, w = ui.screen.getmaxyx() mines = max(int(w * h / 15), 2) ui.board = make_board(w - 1, h - 1, mines) def make_board(w: int, h: int, num_mines: int) -> List: num_mines = min(num_mines, w * h) board = [[Cell() for _ in range(w)] for _ in range(h)] for _ in range(num_mines): while True: x = randint(0, w - 1) y = randint(0, h - 1) if not board[y][x].mined: cell = Cell() cell.mined = True board[y][x] = cell break for y, row in enumerate(board): for x, cell in enumerate(row): board[y][x].mines_around = count_around(board, x, y) return board def count_around(board: List[List[Cell]], x: int, y: int) -> int: def count(dx: int, dy: int) -> int: try: return int(board[y + dy][x + dx].mined) except Exception: return 0 if count(0, 0) == 1: return -1 left = count(-1, -1) + count(-1, 0) + count(-1, +1) center = count(0, -1) + count(0, +1) right = count(+1, -1) + count(+1, 0) + count(+1, +1) return left + center + right def find_distance(xy0, xy1) -> float: dx = xy1[0] - xy0[0] dy = xy1[1] - xy0[1] return sqrt(dx * dx + dy * dy) # return abs(dx) + abs(dy) def reveal(board: List[List[Cell]], x: int, y: int, xy0) -> None: h = len(board) w = len(board[0]) if h > 0 else 0 def rec(x, y): if x < 0 or y < 0 or y >= h or x >= w: return if find_distance((x, y), xy0) > 20: return try: if board[y][x].mined or board[y][x].showing: return except Exception: return board[y][x].showing = True # for dy in (-1, 0, +1): # for dx in (-1, 0, +1): # rec(x + dx, y + dy) rec(x - 1, y) rec(x + 1, y) rec(x, y - 1) rec(x, y + 1) rec(x, y) def solved(board: List[List[Cell]]) -> bool: for row in board: for cell in row: if not cell.showing and not cell.mined: return False return True def handle_help_screen(help: str, ui: UI, h: int) -> None: start = 0 lines = help.splitlines() maxstart = max(len(lines) - (h - 1), 0) while True: ui.screen.erase() msg = 'Help page: press Escape key to go back' ui.screen.addstr(0, 0, msg, A_REVERSE) for i, l in enumerate(islice(lines, start, start + (h - 1))): ui.screen.addstr(i + 1, 0, l) ui.screen.refresh() inp = ui.screen.getkey() if inp == '\x1b': return if inp == 'KEY_UP': start = max(start - 1, 0) if inp == 'KEY_DOWN': start = min(start + 1, maxstart) if inp == 'KEY_NPAGE': start = min(start + (h - 1), maxstart) if inp == 'KEY_PPAGE': start = max(start - (h - 1), 0) if inp == 'KEY_HOME': start = 0 if inp == 'KEY_END': start = maxstart def handle_keys(ui: UI, inp: str, h: int) -> None: if inp == '\x1b': return if inp == 'KEY_RESIZE': new_game(ui) return if inp in (' ', '\n'): lost = ui.board[ui.pos[1]][ui.pos[0]].mined if not lost: reveal(ui.board, ui.pos[0], ui.pos[1], ui.pos) if lost or solved(ui.board): for row in ui.board: for cell in row: cell.showing = True ui.update() ui.screen.getkey() new_game(ui) return if inp == 'KEY_F(1)': handle_help_screen(info.strip(), ui, h) return if inp == 'KEY_F(5)': new_game(ui) return if inp == 'KEY_RIGHT': ui.move(+1, 0) return if inp == 'KEY_LEFT': ui.move(-1, 0) return if inp == 'KEY_DOWN': ui.move(0, +1) return if inp == 'KEY_UP': ui.move(0, -1) return if inp == 'KEY_HOME': ui.pos = (0, ui.pos[1]) return if inp == 'KEY_END': w, _ = ui.size() ui.pos = (w - 1, ui.pos[1]) return if inp == 'KEY_NPAGE': _, h = ui.size() ui.pos = (ui.pos[0], h - 1) return if inp == 'KEY_PPAGE': ui.pos = (ui.pos[0], 0) return def restore(scr) -> None: if scr is None: return for i, rgb in enumerate(original): init_color(i, rgb[0], rgb[1], rgb[2]) curs_set(1) scr.keypad(False) echo() nocbreak() endwin() scr = None ui = UI() # avoid using func curses.wrapper, so that func curses.start_color isn't # called, which in turn would otherwise force a black background even on # terminals configured to use any other background color try: scr = initscr() noecho() cbreak() start_color() scr.keypad(True) curs_set(0) set_escdelay(5) use_default_colors() for i in range(len(colors)): original.append(color_content(i)) for i, rgb in enumerate(colors): r, g, b = (int(1000.0/255.0 * e) for e in rgb) init_color(i, r, g, b) for i in range(len(colors)): init_pair(i, i, 0) ui.screen = scr new_game(ui) while True: ui.update() try: inp = ui.screen.getkey() except KeyboardInterrupt: break if inp == '\x1b': break h, _ = ui.screen.getmaxyx() handle_keys(ui, inp, h - 1) except Exception as e: restore(scr) print(f'\x1b[31m{e}\x1b[0m', file=stderr) exit(2) restore(ui.screen)