reznum-minesweeper 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minesweeper_cli/__init__.py +5 -0
- minesweeper_cli/__main__.py +7 -0
- minesweeper_cli/board.py +225 -0
- minesweeper_cli/cell.py +30 -0
- minesweeper_cli/cli.py +84 -0
- minesweeper_cli/config.py +62 -0
- minesweeper_cli/controls.py +97 -0
- minesweeper_cli/game.py +225 -0
- minesweeper_cli/menu.py +523 -0
- minesweeper_cli/persistence.py +83 -0
- minesweeper_cli/platform_compat.py +71 -0
- minesweeper_cli/py.typed +1 -0
- minesweeper_cli/records.py +137 -0
- minesweeper_cli/renderer.py +281 -0
- minesweeper_cli/settings.py +89 -0
- minesweeper_cli/themes.py +246 -0
- minesweeper_cli/timer.py +57 -0
- reznum_minesweeper-0.2.2.dist-info/METADATA +288 -0
- reznum_minesweeper-0.2.2.dist-info/RECORD +23 -0
- reznum_minesweeper-0.2.2.dist-info/WHEEL +5 -0
- reznum_minesweeper-0.2.2.dist-info/entry_points.txt +2 -0
- reznum_minesweeper-0.2.2.dist-info/licenses/LICENSE +21 -0
- reznum_minesweeper-0.2.2.dist-info/top_level.txt +1 -0
minesweeper_cli/board.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Minesweeper board engine and gameplay mechanics."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
from typing import List, Tuple, Set, Optional
|
|
6
|
+
from minesweeper_cli.cell import Cell
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GameStatus(Enum):
|
|
10
|
+
"""Lifecycle states of a Minesweeper game."""
|
|
11
|
+
READY = auto() # Board created, first move not yet taken
|
|
12
|
+
PLAYING = auto() # Game active, timer ticking
|
|
13
|
+
WON = auto() # All non-mine cells revealed
|
|
14
|
+
LOST = auto() # Mine triggered
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Board:
|
|
18
|
+
"""Core Minesweeper game board."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, width: int, height: int, num_mines: int) -> None:
|
|
21
|
+
if width < 1 or height < 1:
|
|
22
|
+
raise ValueError("Board dimensions must be at least 1x1")
|
|
23
|
+
total_cells = width * height
|
|
24
|
+
if num_mines < 0 or num_mines >= total_cells:
|
|
25
|
+
raise ValueError(f"Mines ({num_mines}) must be between 0 and {total_cells - 1}")
|
|
26
|
+
|
|
27
|
+
self.width = width
|
|
28
|
+
self.height = height
|
|
29
|
+
self.num_mines = num_mines
|
|
30
|
+
self.grid: List[List[Cell]] = [[Cell() for _ in range(width)] for _ in range(height)]
|
|
31
|
+
self.status = GameStatus.READY
|
|
32
|
+
self.first_move = True
|
|
33
|
+
self.revealed_count = 0
|
|
34
|
+
self.flagged_count = 0
|
|
35
|
+
self.exploded_pos: Optional[Tuple[int, int]] = None
|
|
36
|
+
|
|
37
|
+
def in_bounds(self, x: int, y: int) -> bool:
|
|
38
|
+
"""Check if grid coordinates (x=column, y=row) are within board bounds."""
|
|
39
|
+
return 0 <= x < self.width and 0 <= y < self.height
|
|
40
|
+
|
|
41
|
+
def get_cell(self, x: int, y: int) -> Cell:
|
|
42
|
+
"""Get cell at coordinates (x, y)."""
|
|
43
|
+
if not self.in_bounds(x, y):
|
|
44
|
+
raise IndexError(f"Coordinates ({x}, {y}) out of bounds")
|
|
45
|
+
return self.grid[y][x]
|
|
46
|
+
|
|
47
|
+
def get_neighbors(self, x: int, y: int) -> List[Tuple[int, int]]:
|
|
48
|
+
"""Return list of (nx, ny) coordinates for all valid adjacent neighbors."""
|
|
49
|
+
neighbors = []
|
|
50
|
+
for dy in (-1, 0, 1):
|
|
51
|
+
for dx in (-1, 0, 1):
|
|
52
|
+
if dx == 0 and dy == 0:
|
|
53
|
+
continue
|
|
54
|
+
nx, ny = x + dx, y + dy
|
|
55
|
+
if self.in_bounds(nx, ny):
|
|
56
|
+
neighbors.append((nx, ny))
|
|
57
|
+
return neighbors
|
|
58
|
+
|
|
59
|
+
def place_mines(self, safe_x: int, safe_y: int) -> None:
|
|
60
|
+
"""Generate mines with first-move protection ensuring safe_x, safe_y is safe."""
|
|
61
|
+
# Prefer guaranteeing safe_x, safe_y and its 8 neighbors have no mines if space permits
|
|
62
|
+
forbidden: Set[Tuple[int, int]] = {(safe_x, safe_y)}
|
|
63
|
+
neighbors = self.get_neighbors(safe_x, safe_y)
|
|
64
|
+
total_cells = self.width * self.height
|
|
65
|
+
|
|
66
|
+
if total_cells - (len(neighbors) + 1) >= self.num_mines:
|
|
67
|
+
forbidden.update(neighbors)
|
|
68
|
+
|
|
69
|
+
candidates = [
|
|
70
|
+
(x, y)
|
|
71
|
+
for y in range(self.height)
|
|
72
|
+
for x in range(self.width)
|
|
73
|
+
if (x, y) not in forbidden
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
# If candidates are fewer than required mines (dense custom board), fall back to only safe cell
|
|
77
|
+
if len(candidates) < self.num_mines:
|
|
78
|
+
candidates = [
|
|
79
|
+
(x, y)
|
|
80
|
+
for y in range(self.height)
|
|
81
|
+
for x in range(self.width)
|
|
82
|
+
if (x, y) != (safe_x, safe_y)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
mine_coords = set(random.sample(candidates, self.num_mines))
|
|
86
|
+
|
|
87
|
+
for x, y in mine_coords:
|
|
88
|
+
self.grid[y][x].is_mine = True
|
|
89
|
+
|
|
90
|
+
# Calculate neighbor mine counts for each cell
|
|
91
|
+
for y in range(self.height):
|
|
92
|
+
for x in range(self.width):
|
|
93
|
+
if self.grid[y][x].is_mine:
|
|
94
|
+
continue
|
|
95
|
+
count = sum(1 for nx, ny in self.get_neighbors(x, y) if self.grid[ny][nx].is_mine)
|
|
96
|
+
self.grid[y][x].adjacent_mines = count
|
|
97
|
+
|
|
98
|
+
def reveal(self, x: int, y: int) -> bool:
|
|
99
|
+
"""Reveal cell at (x, y). Returns True if action succeeded."""
|
|
100
|
+
if not self.in_bounds(x, y) or self.status in (GameStatus.WON, GameStatus.LOST):
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
cell = self.grid[y][x]
|
|
104
|
+
if not cell.can_reveal:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# First move protection: generate mine layout ensuring first reveal is safe
|
|
108
|
+
if self.first_move:
|
|
109
|
+
self.place_mines(x, y)
|
|
110
|
+
self.first_move = False
|
|
111
|
+
self.status = GameStatus.PLAYING
|
|
112
|
+
|
|
113
|
+
if cell.is_mine:
|
|
114
|
+
cell.is_exploded = True
|
|
115
|
+
self.exploded_pos = (x, y)
|
|
116
|
+
self.status = GameStatus.LOST
|
|
117
|
+
self._reveal_all_mines()
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Flood fill reveal
|
|
121
|
+
self._flood_reveal(x, y)
|
|
122
|
+
self._check_win_condition()
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
def _flood_reveal(self, start_x: int, start_y: int) -> None:
|
|
126
|
+
"""Breadth-first search expanding zero-neighbor cells and boundary numbers."""
|
|
127
|
+
queue: List[Tuple[int, int]] = [(start_x, start_y)]
|
|
128
|
+
visited: Set[Tuple[int, int]] = set()
|
|
129
|
+
|
|
130
|
+
while queue:
|
|
131
|
+
cx, cy = queue.pop(0)
|
|
132
|
+
if (cx, cy) in visited:
|
|
133
|
+
continue
|
|
134
|
+
visited.add((cx, cy))
|
|
135
|
+
|
|
136
|
+
cell = self.grid[cy][cx]
|
|
137
|
+
if cell.is_mine:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if not cell.is_revealed and not cell.is_flagged:
|
|
141
|
+
cell.is_revealed = True
|
|
142
|
+
self.revealed_count += 1
|
|
143
|
+
|
|
144
|
+
if cell.adjacent_mines == 0:
|
|
145
|
+
for nx, ny in self.get_neighbors(cx, cy):
|
|
146
|
+
if (nx, ny) not in visited:
|
|
147
|
+
neighbor_cell = self.grid[ny][nx]
|
|
148
|
+
if not neighbor_cell.is_revealed and not neighbor_cell.is_flagged and not neighbor_cell.is_mine:
|
|
149
|
+
queue.append((nx, ny))
|
|
150
|
+
|
|
151
|
+
def chord_reveal(self, x: int, y: int) -> bool:
|
|
152
|
+
"""Reveal surrounding unflagged cells if flagged neighbor count matches cell number."""
|
|
153
|
+
if not self.in_bounds(x, y) or self.status != GameStatus.PLAYING:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
cell = self.grid[y][x]
|
|
157
|
+
if not cell.is_revealed or cell.adjacent_mines == 0:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
neighbors = self.get_neighbors(x, y)
|
|
161
|
+
flagged_neighbors = sum(1 for nx, ny in neighbors if self.grid[ny][nx].is_flagged)
|
|
162
|
+
|
|
163
|
+
if flagged_neighbors != cell.adjacent_mines:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
any_revealed = False
|
|
167
|
+
for nx, ny in neighbors:
|
|
168
|
+
n_cell = self.grid[ny][nx]
|
|
169
|
+
if not n_cell.is_revealed and not n_cell.is_flagged:
|
|
170
|
+
if n_cell.is_mine:
|
|
171
|
+
n_cell.is_exploded = True
|
|
172
|
+
self.exploded_pos = (nx, ny)
|
|
173
|
+
self.status = GameStatus.LOST
|
|
174
|
+
self._reveal_all_mines()
|
|
175
|
+
return True
|
|
176
|
+
self._flood_reveal(nx, ny)
|
|
177
|
+
any_revealed = True
|
|
178
|
+
|
|
179
|
+
self._check_win_condition()
|
|
180
|
+
return any_revealed
|
|
181
|
+
|
|
182
|
+
def toggle_flag(self, x: int, y: int) -> bool:
|
|
183
|
+
"""Toggle flag marker on hidden cell (x, y)."""
|
|
184
|
+
if not self.in_bounds(x, y) or self.status in (GameStatus.WON, GameStatus.LOST):
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
cell = self.grid[y][x]
|
|
188
|
+
if cell.is_revealed:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
if cell.is_flagged:
|
|
192
|
+
cell.is_flagged = False
|
|
193
|
+
self.flagged_count -= 1
|
|
194
|
+
else:
|
|
195
|
+
cell.is_flagged = True
|
|
196
|
+
self.flagged_count += 1
|
|
197
|
+
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
def _check_win_condition(self) -> None:
|
|
201
|
+
"""Check if all non-mine cells have been opened."""
|
|
202
|
+
total_cells = self.width * self.height
|
|
203
|
+
non_mine_cells = total_cells - self.num_mines
|
|
204
|
+
if self.revealed_count == non_mine_cells and self.status != GameStatus.LOST:
|
|
205
|
+
self.status = GameStatus.WON
|
|
206
|
+
# Auto-flag remaining hidden mines for polished display
|
|
207
|
+
for y in range(self.height):
|
|
208
|
+
for x in range(self.width):
|
|
209
|
+
c = self.grid[y][x]
|
|
210
|
+
if c.is_mine and not c.is_flagged:
|
|
211
|
+
c.is_flagged = True
|
|
212
|
+
self.flagged_count += 1
|
|
213
|
+
|
|
214
|
+
def _reveal_all_mines(self) -> None:
|
|
215
|
+
"""Expose mine positions on game loss."""
|
|
216
|
+
for y in range(self.height):
|
|
217
|
+
for x in range(self.width):
|
|
218
|
+
c = self.grid[y][x]
|
|
219
|
+
if c.is_mine:
|
|
220
|
+
c.is_revealed = True
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def remaining_flags(self) -> int:
|
|
224
|
+
"""Count of flags remaining relative to total mines."""
|
|
225
|
+
return self.num_mines - self.flagged_count
|
minesweeper_cli/cell.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Minesweeper individual cell representation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Cell:
|
|
8
|
+
"""Represents a single tile on the Minesweeper board."""
|
|
9
|
+
is_mine: bool = False
|
|
10
|
+
is_revealed: bool = False
|
|
11
|
+
is_flagged: bool = False
|
|
12
|
+
is_exploded: bool = False
|
|
13
|
+
adjacent_mines: int = 0
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def is_hidden(self) -> bool:
|
|
17
|
+
"""Return True if the cell has not yet been opened."""
|
|
18
|
+
return not self.is_revealed
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def can_reveal(self) -> bool:
|
|
22
|
+
"""A cell can only be revealed if it is hidden and not flagged."""
|
|
23
|
+
return not self.is_revealed and not self.is_flagged
|
|
24
|
+
|
|
25
|
+
def toggle_flag(self) -> bool:
|
|
26
|
+
"""Toggle flag status if hidden. Returns True if now flagged, False otherwise."""
|
|
27
|
+
if self.is_revealed:
|
|
28
|
+
return False
|
|
29
|
+
self.is_flagged = not self.is_flagged
|
|
30
|
+
return self.is_flagged
|
minesweeper_cli/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Command-line interface entrypoint and argument parser."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from minesweeper_cli import __version__
|
|
8
|
+
from minesweeper_cli.config import APP_NAME, DIFFICULTIES
|
|
9
|
+
from minesweeper_cli.menu import MenuController
|
|
10
|
+
from minesweeper_cli.records import RecordsManager
|
|
11
|
+
from minesweeper_cli.settings import SettingsManager
|
|
12
|
+
from minesweeper_cli.themes import THEMES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
16
|
+
"""Build command-line arguments parser."""
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="minesweeper",
|
|
19
|
+
description=f"{APP_NAME} - A modern, colorful terminal Minesweeper game written in Python.",
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-v", "--version",
|
|
25
|
+
action="version",
|
|
26
|
+
version=f"{APP_NAME} {__version__}",
|
|
27
|
+
help="Show version information and exit.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-d", "--difficulty",
|
|
32
|
+
choices=["easy", "medium", "hard", "expert"],
|
|
33
|
+
type=str.lower,
|
|
34
|
+
help="Directly launch game with specified difficulty preset.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"-c", "--custom",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Directly open custom board setup dialog.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"-t", "--theme",
|
|
45
|
+
choices=list(THEMES.keys()),
|
|
46
|
+
type=str.lower,
|
|
47
|
+
help="Select visual color theme (classic, matrix, ocean, dracula, cyberpunk, nord, monochrome).",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
54
|
+
"""CLI execution entrypoint."""
|
|
55
|
+
parser = create_parser()
|
|
56
|
+
args = parser.parse_args(argv)
|
|
57
|
+
|
|
58
|
+
settings_mgr = SettingsManager()
|
|
59
|
+
records_mgr = RecordsManager()
|
|
60
|
+
|
|
61
|
+
if args.theme:
|
|
62
|
+
settings_mgr.settings.theme = args.theme
|
|
63
|
+
settings_mgr.save()
|
|
64
|
+
|
|
65
|
+
menu = MenuController(settings_manager=settings_mgr, records_manager=records_mgr)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
if args.difficulty:
|
|
69
|
+
chosen = DIFFICULTIES[args.difficulty]
|
|
70
|
+
menu.launch_game(chosen.width, chosen.height, chosen.mines, chosen.name)
|
|
71
|
+
return 0
|
|
72
|
+
elif args.custom:
|
|
73
|
+
menu.menu_custom_game()
|
|
74
|
+
return 0
|
|
75
|
+
else:
|
|
76
|
+
menu.run()
|
|
77
|
+
return 0
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
sys.stdout.write("\nThanks for playing Minesweeper CLI!\n")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Global configuration, difficulty presets, and branding metadata."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
APP_NAME = "Minesweeper CLI"
|
|
7
|
+
APP_SLUG = "minesweeper-cli"
|
|
8
|
+
AUTHOR = "Made with ❤️ by ItsReZNuM"
|
|
9
|
+
GITHUB_PROFILE = "https://github.com/ItsReZNuM"
|
|
10
|
+
GITHUB_REPO = "https://github.com/ItsReZNuM/Minesweeper-CLI"
|
|
11
|
+
TELEGRAM = "https://t.me/ItsReZNuM"
|
|
12
|
+
INSTAGRAM = "https://instagram.com/rez.num"
|
|
13
|
+
TERMINAL_DEFAULT = "Classic CMD"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DifficultyConfig:
|
|
18
|
+
"""Predefined difficulty configuration."""
|
|
19
|
+
name: str
|
|
20
|
+
width: int
|
|
21
|
+
height: int
|
|
22
|
+
mines: int
|
|
23
|
+
description: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DIFFICULTIES: Dict[str, DifficultyConfig] = {
|
|
27
|
+
"easy": DifficultyConfig(
|
|
28
|
+
name="Easy",
|
|
29
|
+
width=9,
|
|
30
|
+
height=9,
|
|
31
|
+
mines=10,
|
|
32
|
+
description="9x9 board with 10 mines (Beginner friendly)",
|
|
33
|
+
),
|
|
34
|
+
"medium": DifficultyConfig(
|
|
35
|
+
name="Medium",
|
|
36
|
+
width=16,
|
|
37
|
+
height=16,
|
|
38
|
+
mines=40,
|
|
39
|
+
description="16x16 board with 40 mines (Standard challenge)",
|
|
40
|
+
),
|
|
41
|
+
"hard": DifficultyConfig(
|
|
42
|
+
name="Hard",
|
|
43
|
+
width=30,
|
|
44
|
+
height=16,
|
|
45
|
+
mines=99,
|
|
46
|
+
description="30x16 board with 99 mines (Classic advanced)",
|
|
47
|
+
),
|
|
48
|
+
"expert": DifficultyConfig(
|
|
49
|
+
name="Expert",
|
|
50
|
+
width=30,
|
|
51
|
+
height=24,
|
|
52
|
+
mines=150,
|
|
53
|
+
description="30x24 board with 150 mines (Master challenge)",
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Custom mode boundary constraints
|
|
58
|
+
MIN_BOARD_WIDTH = 4
|
|
59
|
+
MAX_BOARD_WIDTH = 60
|
|
60
|
+
MIN_BOARD_HEIGHT = 4
|
|
61
|
+
MAX_BOARD_HEIGHT = 40
|
|
62
|
+
MIN_MINES_COUNT = 1
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Game controls, key mapping definitions, and conflict validation."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
ACTION_UP = "up"
|
|
6
|
+
ACTION_DOWN = "down"
|
|
7
|
+
ACTION_LEFT = "left"
|
|
8
|
+
ACTION_RIGHT = "right"
|
|
9
|
+
ACTION_REVEAL = "reveal"
|
|
10
|
+
ACTION_FLAG = "flag"
|
|
11
|
+
ACTION_CHORD = "chord"
|
|
12
|
+
ACTION_RESTART = "restart"
|
|
13
|
+
ACTION_QUIT = "quit"
|
|
14
|
+
|
|
15
|
+
ALL_ACTIONS = [
|
|
16
|
+
ACTION_UP,
|
|
17
|
+
ACTION_DOWN,
|
|
18
|
+
ACTION_LEFT,
|
|
19
|
+
ACTION_RIGHT,
|
|
20
|
+
ACTION_REVEAL,
|
|
21
|
+
ACTION_FLAG,
|
|
22
|
+
ACTION_CHORD,
|
|
23
|
+
ACTION_RESTART,
|
|
24
|
+
ACTION_QUIT,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
ACTION_LABELS = {
|
|
28
|
+
ACTION_UP: "Move Up",
|
|
29
|
+
ACTION_DOWN: "Move Down",
|
|
30
|
+
ACTION_LEFT: "Move Left",
|
|
31
|
+
ACTION_RIGHT: "Move Right",
|
|
32
|
+
ACTION_REVEAL: "Reveal Cell",
|
|
33
|
+
ACTION_FLAG: "Place / Remove Flag",
|
|
34
|
+
ACTION_CHORD: "Chord Reveal (Quick Open)",
|
|
35
|
+
ACTION_RESTART: "Restart Game",
|
|
36
|
+
ACTION_QUIT: "Quit to Menu",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
DEFAULT_KEY_BINDINGS: Dict[str, List[str]] = {
|
|
40
|
+
ACTION_UP: ["w", "up"],
|
|
41
|
+
ACTION_DOWN: ["s", "down"],
|
|
42
|
+
ACTION_LEFT: ["a", "left"],
|
|
43
|
+
ACTION_RIGHT: ["d", "right"],
|
|
44
|
+
ACTION_REVEAL: ["enter", "space"],
|
|
45
|
+
ACTION_FLAG: ["f"],
|
|
46
|
+
ACTION_CHORD: ["c"],
|
|
47
|
+
ACTION_RESTART: ["r"],
|
|
48
|
+
ACTION_QUIT: ["q", "escape"],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_key(key: str) -> str:
|
|
53
|
+
"""Normalize key string representation for comparison."""
|
|
54
|
+
if not key:
|
|
55
|
+
return ""
|
|
56
|
+
mapping = {
|
|
57
|
+
"\r": "enter",
|
|
58
|
+
"\n": "enter",
|
|
59
|
+
" ": "space",
|
|
60
|
+
"\x1b": "escape",
|
|
61
|
+
"esc": "escape",
|
|
62
|
+
}
|
|
63
|
+
if key in mapping:
|
|
64
|
+
return mapping[key]
|
|
65
|
+
cleaned = key.strip().lower()
|
|
66
|
+
return mapping.get(cleaned, cleaned)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_key_bindings(bindings: Dict[str, List[str]]) -> Tuple[bool, Optional[str]]:
|
|
70
|
+
"""Verify that every action has at least one key and no key is assigned to multiple actions."""
|
|
71
|
+
for action in ALL_ACTIONS:
|
|
72
|
+
if action not in bindings or not bindings[action]:
|
|
73
|
+
return False, f"Action '{ACTION_LABELS.get(action, action)}' must have at least one key assigned."
|
|
74
|
+
|
|
75
|
+
seen_keys: Dict[str, str] = {}
|
|
76
|
+
for action, keys in bindings.items():
|
|
77
|
+
for k in keys:
|
|
78
|
+
norm = normalize_key(k)
|
|
79
|
+
if not norm:
|
|
80
|
+
continue
|
|
81
|
+
if norm in seen_keys and seen_keys[norm] != action:
|
|
82
|
+
existing_action = ACTION_LABELS.get(seen_keys[norm], seen_keys[norm])
|
|
83
|
+
new_action = ACTION_LABELS.get(action, action)
|
|
84
|
+
return False, f"Key '{norm}' conflicts between '{existing_action}' and '{new_action}'."
|
|
85
|
+
seen_keys[norm] = action
|
|
86
|
+
|
|
87
|
+
return True, None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_action_for_key(key: str, bindings: Dict[str, List[str]]) -> Optional[str]:
|
|
91
|
+
"""Resolve normalized key string into an action name."""
|
|
92
|
+
normalized = normalize_key(key)
|
|
93
|
+
for action, keys in bindings.items():
|
|
94
|
+
for k in keys:
|
|
95
|
+
if normalize_key(k) == normalized:
|
|
96
|
+
return action
|
|
97
|
+
return None
|