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)