File: mines.py 1 #!/usr/bin/python3 2 3 # The MIT License (MIT) 4 # 5 # Copyright © 2024 pacman64 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the “Software”), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be included in 15 # all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 # SOFTWARE. 24 25 26 from curses import \ 27 color_content, color_pair, init_color, init_pair, start_color, \ 28 use_default_colors, initscr, cbreak, curs_set, echo, endwin, nocbreak, \ 29 noecho, set_escdelay, \ 30 A_BOLD, A_DIM, A_ITALIC, A_NORMAL, A_REVERSE, A_UNDERLINE 31 from itertools import islice 32 from math import sqrt 33 from random import randint 34 from sys import argv, exit, stderr 35 from typing import Any, List, Tuple 36 37 38 info = ''' 39 mines 40 41 42 Play the MINESweeper game in a text user-interface (TUI). 43 44 The Escape key quits the game immediately, the arrow keys let you move around 45 the board, and both the Space bar/key and Enter reveal the cell you're on. 46 47 The only options are one of the help options, which show this message: 48 these are `-h`, `--h`, `-help`, and `--help`, without the quotes. 49 50 You can also view this help message by pressing the F1 key while browsing 51 folders: when done reading the message, press the Escape key to go back to 52 the current folder entries. 53 ''' 54 55 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'): 56 print(info.strip(), file=stderr) 57 exit(0) 58 59 60 # original is later filled with the RGB-color palette at the time this script 61 # started running, so it can be restored when the script is done 62 original = [] 63 64 # custom colors/style for the game 65 colors = ( 66 (192, 192, 192), 67 (65, 79, 188), 68 (32, 105, 1), 69 (169, 6, 8), 70 (1, 0, 128), 71 (117, 2, 3), 72 (3, 123, 127), 73 (0, 0, 0), 74 (128, 128, 128), 75 ) 76 77 78 class Cell: 79 def __init__(self) -> None: 80 self.mines_around = 0 81 self.showing = False 82 self.mined = False 83 84 85 class UI: 86 def __init__(self) -> None: 87 self.screen = None 88 self.pos = (0, 0) 89 self.board: List[List[Cell]] = [] 90 91 def move(self, x: int, y: int) -> None: 92 w, h = self.size() 93 94 self.pos = ( 95 wrap(self.pos[0] + x, w), 96 wrap(self.pos[1] + y, h), 97 ) 98 99 def size(self) -> Tuple[int, int]: 100 h = len(self.board) 101 w = len(self.board[0]) if h > 0 else 0 102 return w, h 103 104 def is_valid_pos(x: int, y: int) -> bool: 105 w, h = self.size() 106 return 0 <= x < w and 0 <= y < h 107 108 def update(self) -> None: 109 scr.erase() 110 111 for y, row in enumerate(self.board): 112 for x, cell in enumerate(row): 113 color = 0 114 sel = self.pos[0] == x and self.pos[1] == y 115 if not cell.showing: 116 s = ' ' 117 elif cell.mined: 118 s = '*' 119 else: 120 n = cell.mines_around 121 s = str(n) if n > 0 else ' ' 122 color = n + 1 123 style = A_NORMAL if sel ^ cell.showing else A_REVERSE 124 self.screen.addstr(y, x, s, style | color_pair(color)) 125 126 scr.refresh() 127 128 129 def wrap(x: int, max: int) -> int: 130 return x + max if x < 0 else x % max 131 132 133 def new_game(ui: UI) -> None: 134 h, w = ui.screen.getmaxyx() 135 mines = max(int(w * h / 15), 2) 136 ui.board = make_board(w - 1, h - 1, mines) 137 138 139 def make_board(w: int, h: int, num_mines: int) -> List: 140 num_mines = min(num_mines, w * h) 141 board = [[Cell() for _ in range(w)] for _ in range(h)] 142 143 for _ in range(num_mines): 144 while True: 145 x = randint(0, w - 1) 146 y = randint(0, h - 1) 147 if not board[y][x].mined: 148 cell = Cell() 149 cell.mined = True 150 board[y][x] = cell 151 break 152 153 for y, row in enumerate(board): 154 for x, cell in enumerate(row): 155 board[y][x].mines_around = count_around(board, x, y) 156 return board 157 158 159 def count_around(board: List[List[Cell]], x: int, y: int) -> int: 160 def count(dx: int, dy: int) -> int: 161 try: 162 return int(board[y + dy][x + dx].mined) 163 except Exception: 164 return 0 165 166 if count(0, 0) == 1: 167 return -1 168 169 left = count(-1, -1) + count(-1, 0) + count(-1, +1) 170 center = count(0, -1) + count(0, +1) 171 right = count(+1, -1) + count(+1, 0) + count(+1, +1) 172 return left + center + right 173 174 175 def find_distance(xy0, xy1) -> float: 176 dx = xy1[0] - xy0[0] 177 dy = xy1[1] - xy0[1] 178 return sqrt(dx * dx + dy * dy) 179 # return abs(dx) + abs(dy) 180 181 182 def reveal(board: List[List[Cell]], x: int, y: int, xy0) -> None: 183 h = len(board) 184 w = len(board[0]) if h > 0 else 0 185 186 def rec(x, y): 187 if x < 0 or y < 0 or y >= h or x >= w: 188 return 189 190 if find_distance((x, y), xy0) > 20: 191 return 192 193 try: 194 if board[y][x].mined or board[y][x].showing: 195 return 196 except Exception: 197 return 198 199 board[y][x].showing = True 200 # for dy in (-1, 0, +1): 201 # for dx in (-1, 0, +1): 202 # rec(x + dx, y + dy) 203 rec(x - 1, y) 204 rec(x + 1, y) 205 rec(x, y - 1) 206 rec(x, y + 1) 207 208 rec(x, y) 209 210 211 def solved(board: List[List[Cell]]) -> bool: 212 for row in board: 213 for cell in row: 214 if not cell.showing and not cell.mined: 215 return False 216 return True 217 218 219 def handle_help_screen(help: str, ui: UI, h: int) -> None: 220 start = 0 221 lines = help.splitlines() 222 maxstart = max(len(lines) - (h - 1), 0) 223 224 while True: 225 ui.screen.erase() 226 msg = 'Help page: press Escape key to go back' 227 ui.screen.addstr(0, 0, msg, A_REVERSE) 228 for i, l in enumerate(islice(lines, start, start + (h - 1))): 229 ui.screen.addstr(i + 1, 0, l) 230 ui.screen.refresh() 231 232 inp = ui.screen.getkey() 233 if inp == '\x1b': 234 return 235 if inp == 'KEY_UP': 236 start = max(start - 1, 0) 237 if inp == 'KEY_DOWN': 238 start = min(start + 1, maxstart) 239 if inp == 'KEY_NPAGE': 240 start = min(start + (h - 1), maxstart) 241 if inp == 'KEY_PPAGE': 242 start = max(start - (h - 1), 0) 243 if inp == 'KEY_HOME': 244 start = 0 245 if inp == 'KEY_END': 246 start = maxstart 247 248 249 def handle_keys(ui: UI, inp: str, h: int) -> None: 250 if inp == '\x1b': 251 return 252 253 if inp == 'KEY_RESIZE': 254 new_game(ui) 255 return 256 257 if inp in (' ', '\n'): 258 lost = ui.board[ui.pos[1]][ui.pos[0]].mined 259 if not lost: 260 reveal(ui.board, ui.pos[0], ui.pos[1], ui.pos) 261 262 if lost or solved(ui.board): 263 for row in ui.board: 264 for cell in row: 265 cell.showing = True 266 ui.update() 267 ui.screen.getkey() 268 new_game(ui) 269 return 270 271 if inp == 'KEY_F(1)': 272 handle_help_screen(info.strip(), ui, h) 273 return 274 275 if inp == 'KEY_F(5)': 276 new_game(ui) 277 return 278 279 if inp == 'KEY_RIGHT': 280 ui.move(+1, 0) 281 return 282 283 if inp == 'KEY_LEFT': 284 ui.move(-1, 0) 285 return 286 287 if inp == 'KEY_DOWN': 288 ui.move(0, +1) 289 return 290 291 if inp == 'KEY_UP': 292 ui.move(0, -1) 293 return 294 295 if inp == 'KEY_HOME': 296 ui.pos = (0, ui.pos[1]) 297 return 298 299 if inp == 'KEY_END': 300 w, _ = ui.size() 301 ui.pos = (w - 1, ui.pos[1]) 302 return 303 304 if inp == 'KEY_NPAGE': 305 _, h = ui.size() 306 ui.pos = (ui.pos[0], h - 1) 307 return 308 309 if inp == 'KEY_PPAGE': 310 ui.pos = (ui.pos[0], 0) 311 return 312 313 314 def restore(scr) -> None: 315 if scr is None: 316 return 317 318 for i, rgb in enumerate(original): 319 init_color(i, rgb[0], rgb[1], rgb[2]) 320 321 curs_set(1) 322 scr.keypad(False) 323 echo() 324 nocbreak() 325 endwin() 326 327 328 scr = None 329 ui = UI() 330 331 # avoid using func curses.wrapper, so that func curses.start_color isn't 332 # called, which in turn would otherwise force a black background even on 333 # terminals configured to use any other background color 334 try: 335 scr = initscr() 336 noecho() 337 cbreak() 338 start_color() 339 scr.keypad(True) 340 341 curs_set(0) 342 set_escdelay(5) 343 344 use_default_colors() 345 for i in range(len(colors)): 346 original.append(color_content(i)) 347 for i, rgb in enumerate(colors): 348 r, g, b = (int(1000.0/255.0 * e) for e in rgb) 349 init_color(i, r, g, b) 350 for i in range(len(colors)): 351 init_pair(i, i, 0) 352 353 ui.screen = scr 354 new_game(ui) 355 356 while True: 357 ui.update() 358 359 try: 360 inp = ui.screen.getkey() 361 except KeyboardInterrupt: 362 break 363 364 if inp == '\x1b': 365 break 366 367 h, _ = ui.screen.getmaxyx() 368 handle_keys(ui, inp, h - 1) 369 except Exception as e: 370 restore(scr) 371 print(f'\x1b[31m{e}\x1b[0m', file=stderr) 372 exit(2) 373 374 restore(ui.screen)