diff --git a/main.py b/main.py index 27908e2..7170159 100644 --- a/main.py +++ b/main.py @@ -4,41 +4,33 @@ from minesweeper.ai.solver import MinesweeperSolver -def parse_arguments(): - """Parse command line arguments for game mode selection.""" - parser = argparse.ArgumentParser(description="Minesweeper Game with AI") - parser.add_argument('--mode', type=str, choices=['human', 'ai', 'random'], - default='human', help='Game mode: human, ai, or random') - return parser.parse_args() - - def main(): - """Main entry point for the game.""" - args = parse_arguments() + parser = argparse.ArgumentParser(description='Run Minesweeper game') + parser.add_argument('--mode', choices=['human', 'ai'], + default='human', help='Game mode') + parser.add_argument('--mobile', action='store_true', + help='Run in mobile mode') + + args = parser.parse_args() - # Initialize the game + # Add mobile flag to sys.argv for the game to detect + if args.mobile: + sys.argv.append('--mobile') + + # Create and run the game game = MinesweeperGame() - # Choose mode based on argument if args.mode == 'human': print("Starting game in human mode. Good luck!") game.run() - else: + elif args.mode == 'ai': + print("Starting game with AI solver...") # Start the game without running the full game loop game.setup() # Create solver with game API solver = MinesweeperSolver(game.api) - - if args.mode == 'ai': - print("Starting game with AI solver...") - # Use the intelligent solver - solver.start() - elif args.mode == 'random': - print("Starting game with random solver...") - # Use the random clicking solver - solver.click_random_tiles_to_vicotry() - + solver.start() if __name__ == "__main__": main() \ No newline at end of file diff --git a/minesweeper/game.py b/minesweeper/game.py index 2abdd93..d768eb1 100644 --- a/minesweeper/game.py +++ b/minesweeper/game.py @@ -16,31 +16,128 @@ # Game configuration constants #---------------------------------------------------------------------- -ROWS, COLS = 32, 32 -N_BOMBS = 99 -BUFFER = 4 # margin/gap between tiles -STRIP_HEIGHT = 50 # Height of the top strip -SCREEN_WIDTH = 600 # Fixed screen width -SCREEN_HEIGHT = 600 + STRIP_HEIGHT # Screen height with strip +import platform + +# Platform detection - updated to handle command line arguments properly +IS_MOBILE = (platform.system() in ['Android', 'iOS'] or + '--mobile' in sys.argv or + any('--mobile' in arg for arg in sys.argv)) + +# Desktop PC proportions - smaller window size (unchanged) +DESKTOP_ROWS, DESKTOP_COLS = 16, 25 +DESKTOP_N_BOMBS = 80 +DESKTOP_BUFFER = 3 +DESKTOP_STRIP_HEIGHT = 80 +DESKTOP_SCREEN_WIDTH = 800 +DESKTOP_SCREEN_HEIGHT = 600 + +# Mobile phone proportions - optimized for portrait mode +MOBILE_ROWS, MOBILE_COLS = 15, 10 # Portrait ratio, more rows than columns +MOBILE_N_BOMBS = 30 # Adjusted for smaller grid +MOBILE_BUFFER = 2 # Tighter spacing for mobile +MOBILE_STRIP_HEIGHT = 100 # Fixed: Increased from 10 to 100 for proper spacing +MOBILE_SCREEN_WIDTH = 400 # Portrait width +MOBILE_SCREEN_HEIGHT = 650 # Changed to 650 as requested + +# Set active configuration based on platform +if IS_MOBILE: + ROWS, COLS = MOBILE_ROWS, MOBILE_COLS + N_BOMBS = MOBILE_N_BOMBS + BUFFER = MOBILE_BUFFER + STRIP_HEIGHT = MOBILE_STRIP_HEIGHT + SCREEN_WIDTH = MOBILE_SCREEN_WIDTH + SCREEN_HEIGHT = MOBILE_SCREEN_HEIGHT +else: + ROWS, COLS = DESKTOP_ROWS, DESKTOP_COLS + N_BOMBS = DESKTOP_N_BOMBS + BUFFER = DESKTOP_BUFFER + STRIP_HEIGHT = DESKTOP_STRIP_HEIGHT + SCREEN_WIDTH = DESKTOP_SCREEN_WIDTH + SCREEN_HEIGHT = DESKTOP_SCREEN_HEIGHT + + +# Modern color scheme - Black tiles with cyan/purple accents +COLORS = { + 'background': (5, 18, 21), # Keep your dark background + 'tile_hidden': (25, 25, 30), # Dark gray/black tiles + 'tile_revealed': (15, 15, 20), # Even darker for revealed tiles + 'tile_border': (45, 45, 55), # Subtle dark border + 'accent_blue': (47, 181, 208), # Keep your cyan accent + 'light_blue': (89, 196, 217), # Keep your light cyan + 'text_primary': (234, 248, 250), # Keep your light text + 'text_secondary': (171, 227, 237), # Keep your secondary text + 'danger': (255, 65, 55), # Keep red for bombs + 'success': (45, 210, 85), # Keep green for success + 'warning': (255, 210, 10), # Keep yellow for warning + 'secondary_accent': (144, 47, 208), # Keep your purple + 'secondary_light': (166, 89, 217), # Keep your light purple + 'accent_purple': (208, 47, 208), # Keep your magenta + 'light_purple': (217, 89, 217), # Keep your light magenta +} + +# Enhanced number colors using your cyan/teal and purple theme palette +NUMBER_COLORS = { + 1: (47, 181, 208), # --primary-500 (main cyan) + 2: (45, 210, 85), # Green (keep for contrast) + 3: (255, 65, 55), # Red (keep for contrast) + 4: (144, 47, 208), # --secondary-500 (purple) + 5: (255, 160, 10), # Orange (keep for contrast) + 6: (208, 47, 208), # --accent-500 (magenta) + 7: (130, 211, 227), # --primary-700 (light cyan) + 8: (234, 248, 250) # --text-950 (white) +} + +# Modern design constants - adaptive for platform +if IS_MOBILE: + TILE_BORDER_RADIUS = 6 # Larger for touch targets + TILE_PADDING = 2 # More padding for mobile + MIN_TILE_SIZE = 30 # Reduced from 35 to fit more tiles + BUTTON_SIZE = 50 # Reduced from 60 + COUNTER_FONT_SIZE = 36 # Reduced from 50 + LABEL_FONT_SIZE = 18 # Reduced from 24 +else: + TILE_BORDER_RADIUS = 4 # Smaller for crisp pixels + TILE_PADDING = 1 # Minimal padding + MIN_TILE_SIZE = 20 # Smaller minimum for desktop + BUTTON_SIZE = 50 # Standard button size + COUNTER_FONT_SIZE = 40 # Standard text size + LABEL_FONT_SIZE = 20 # Standard labels # Asset directory ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets') -# Grid layout calculations -AVAILABLE_WIDTH = SCREEN_WIDTH - (BUFFER * (COLS + 1)) -AVAILABLE_HEIGHT = SCREEN_HEIGHT - STRIP_HEIGHT - (BUFFER * (ROWS + 1)) -TILE_SIZE = min(AVAILABLE_WIDTH // COLS, AVAILABLE_HEIGHT // ROWS) -ACTUAL_GRID_WIDTH = (TILE_SIZE * COLS) + (BUFFER * (COLS + 1)) -ACTUAL_GRID_HEIGHT = (TILE_SIZE * ROWS) + (BUFFER * (ROWS + 1)) -GRID_X_OFFSET = (SCREEN_WIDTH - ACTUAL_GRID_WIDTH) // 2 +# Grid layout calculations - platform adaptive with bounds checking +AVAILABLE_WIDTH = SCREEN_WIDTH - (BUFFER * 4) # More margin for mobile +AVAILABLE_HEIGHT = SCREEN_HEIGHT - STRIP_HEIGHT - (BUFFER * 4) # More margin + +# Calculate tile size with better constraints +max_tile_width = AVAILABLE_WIDTH // COLS +max_tile_height = AVAILABLE_HEIGHT // ROWS +TILE_SIZE = min(max_tile_width, max_tile_height) - BUFFER + +# Ensure tile size is appropriate for platform with stricter limits +if IS_MOBILE: + TILE_SIZE = max(min(TILE_SIZE, 35), MIN_TILE_SIZE) # Cap at 35px for mobile +else: + TILE_SIZE = max(TILE_SIZE, MIN_TILE_SIZE) + +ACTUAL_GRID_WIDTH = (TILE_SIZE * COLS) + (BUFFER * (COLS - 1)) +ACTUAL_GRID_HEIGHT = (TILE_SIZE * ROWS) + (BUFFER * (ROWS - 1)) -# Font size calculations -BASE_FONT_SIZE = 32 # The font size for a standard 16x16 grid -BASE_GRID_SIZE = 16 # Standard Minesweeper grid dimension -FONT_SCALE_FACTOR = max(ROWS, COLS) / BASE_GRID_SIZE +# Ensure grid fits within screen bounds +if ACTUAL_GRID_WIDTH > SCREEN_WIDTH - 20: + TILE_SIZE = (SCREEN_WIDTH - 20 - (BUFFER * (COLS - 1))) // COLS + ACTUAL_GRID_WIDTH = (TILE_SIZE * COLS) + (BUFFER * (COLS - 1)) + +if ACTUAL_GRID_HEIGHT > SCREEN_HEIGHT - STRIP_HEIGHT - 20: + TILE_SIZE = (SCREEN_HEIGHT - STRIP_HEIGHT - 20 - (BUFFER * (ROWS - 1))) // ROWS + ACTUAL_GRID_HEIGHT = (TILE_SIZE * ROWS) + (BUFFER * (ROWS - 1)) + +GRID_X_OFFSET = (SCREEN_WIDTH - ACTUAL_GRID_WIDTH) // 2 +GRID_Y_OFFSET = STRIP_HEIGHT + (SCREEN_HEIGHT - STRIP_HEIGHT - ACTUAL_GRID_HEIGHT) // 2 -# Calculate tile font size based on grid dimensions and tile size -TILE_FONT_SIZE = max(int(BASE_FONT_SIZE / FONT_SCALE_FACTOR), 12) # Minimum 12pt +# Font size calculations - platform adaptive +TILE_FONT_SIZE = max(TILE_SIZE // 2, 16 if IS_MOBILE else 14) class MinesweeperGame: """ @@ -53,18 +150,18 @@ class MinesweeperGame: def __init__(self): """ - Initialize the game, setting up the window, assets, and initial game state. - - This creates the game window, loads all required assets, and prepares - the game for the first run. + Initialize the game with modern dark theme. """ pygame.init() self.game_window = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) - pygame.display.set_caption("MinesweeperAI") + pygame.display.set_caption("MinesweeperAI - Modern") + + # Set window background to dark + self.game_window.fill(COLORS['background']) # Game state variables - self.game_status = "playing" # Can be "playing", "won", or "lost" + self.game_status = "playing" self.bomb_count = N_BOMBS self.flagged_count = 0 self.grid = None @@ -73,42 +170,38 @@ def __init__(self): self.revealed_count = 0 self.bombs_placed = False - # Load tile assets + # Load modern assets self._load_assets() # Create API instance self.api = None - - #---------------------------------------------------------------------- - # Initialization methods - #---------------------------------------------------------------------- - + def _load_assets(self): """ - Load all game assets including tile images, fonts and emoji faces. - - This is a private helper method called during initialization. + Load assets with platform-adaptive sizing. """ - # Load images and font for all tiles + # Create modern tile font Tile.load_assets( bomb_path=os.path.join(ASSETS_DIR, 'bomb.png'), hidden_path=os.path.join(ASSETS_DIR, 'hidden2.png'), revealed_path=os.path.join(ASSETS_DIR, 'revealed.png'), flagged_path=os.path.join(ASSETS_DIR, 'flag1.png'), - font=pygame.font.Font('freesansbold.ttf', TILE_FONT_SIZE) + font=pygame.font.Font(None, max(TILE_FONT_SIZE, 16)) ) - # Load emoji faces for restart button - self.face_happy = pygame.transform.scale(pygame.image.load(os.path.join(ASSETS_DIR, 'face_happy.png')), (40, 40)) - self.face_sad = pygame.transform.scale(pygame.image.load(os.path.join(ASSETS_DIR, 'face_sad.png')), (40, 40)) - self.face_cool = pygame.transform.scale(pygame.image.load(os.path.join(ASSETS_DIR, 'face_cool.png')), (40, 40)) - - # Restart button rect - self.restart_rect = pygame.Rect(SCREEN_WIDTH//2 - 20, STRIP_HEIGHT//2 - 20, 40, 40) + # Platform-adaptive restart button + button_size = BUTTON_SIZE + self.restart_rect = pygame.Rect( + SCREEN_WIDTH//2 - button_size//2, + STRIP_HEIGHT//2 - button_size//2, + button_size, + button_size + ) - # Create fonts for the counters - self.counter_font = pygame.font.Font('freesansbold.ttf', 24) - + # Platform-adaptive fonts + self.counter_font = pygame.font.Font(None, COUNTER_FONT_SIZE) + self.title_font = pygame.font.Font(None, LABEL_FONT_SIZE) + def setup_new_game(self) -> None: """ Set up a new game with fresh state. @@ -135,15 +228,12 @@ def setup_new_game(self) -> None: def setup(self) -> None: """ - Set up the game without running the game loop. - - This method initializes the game, creating the grid and UI, - but doesn't start the event loop - allowing programmatic control. + Set up the game with properly sized grid. """ - # Fill the entire window with gray background first - self.game_window.fill((192, 192, 192)) + # Fill with dark background + self.game_window.fill(COLORS['background']) - # Create the grid and cache all_tiles + # Create the grid with proper positioning self.grid, self.all_tiles = Grid.make_grid( self.game_window, ROWS, @@ -151,7 +241,7 @@ def setup(self) -> None: TILE_SIZE, N_BOMBS, BUFFER, - y_offset=STRIP_HEIGHT, + y_offset=GRID_Y_OFFSET, # Use calculated Y offset x_offset=GRID_X_OFFSET ) self.non_bomb_tiles_count = ROWS * COLS - N_BOMBS @@ -161,7 +251,6 @@ def setup(self) -> None: self.api = MinesweeperAPI(self) self.setup_new_game() - # Draw initial state pygame.display.flip() def run(self) -> None: @@ -199,12 +288,13 @@ def game_loop(self) -> None: self.api.restart_game() continue - # Adjust mouse position to account for the top strip - adjusted_y = mouse_y - STRIP_HEIGHT + # Adjust mouse position to account for the grid offset + adjusted_x = mouse_x - GRID_X_OFFSET + adjusted_y = mouse_y - GRID_Y_OFFSET - # Only process mouse events on the grid if adjusted_y is positive - if adjusted_y >= 0: - row, col = self.get_tile_at_pos(mouse_x, adjusted_y, TILE_SIZE, BUFFER, GRID_X_OFFSET) + # Only process mouse events on the grid if coordinates are positive + if adjusted_x >= 0 and adjusted_y >= 0: + row, col = self.get_tile_at_pos(adjusted_x, adjusted_y, TILE_SIZE, BUFFER) if 0 <= row < ROWS and 0 <= col < COLS: # Handle clicks through the API @@ -299,72 +389,67 @@ def place_bombs_after_first_click(self, first_tile: Tile) -> list[Tile]: def won(self) -> None: """ - Handle the game-won state. - - Updates the game status, flags all remaining bombs, - and displays a win message overlay. + Handle the game-won state with modern overlay. """ print("Congratulations! You won!") - # Set game status self.game_status = "won" - # Mark all hidden tiles (which must be bombs) as flags + # Mark all hidden tiles as flags for tile in self.all_tiles: if tile.is_hidden and not tile.is_flagged: self.api.flag_tile(tile.row, tile.col) - # Update the counter display with cool face self.draw_counters() - # Show a message on the screen - font = pygame.font.Font('freesansbold.ttf', 64) - win_text = font.render("YOU WIN!", True, (0, 255, 0)) - text_rect = win_text.get_rect(center=(SCREEN_WIDTH//2, STRIP_HEIGHT + SCREEN_HEIGHT//2)) - - # Apply a semi-transparent overlay + # Modern win overlay overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) - overlay.fill((0, 0, 0, 128)) # Semi-transparent black + overlay.fill((0, 0, 0, 180)) # Semi-transparent black + + # Modern win text + win_font = pygame.font.Font(None, 72) + win_text = win_font.render("VICTORY", True, COLORS['success']) + win_rect = win_text.get_rect(center=(SCREEN_WIDTH//2, STRIP_HEIGHT + SCREEN_HEIGHT//2 - 20)) + + sub_text = self.counter_font.render("Click restart to play again", True, COLORS['text_secondary']) + sub_rect = sub_text.get_rect(center=(SCREEN_WIDTH//2, STRIP_HEIGHT + SCREEN_HEIGHT//2 + 30)) - # Draw overlay and text self.game_window.blit(overlay, (0, 0)) - self.game_window.blit(win_text, text_rect) + self.game_window.blit(win_text, win_rect) + self.game_window.blit(sub_text, sub_rect) pygame.display.flip() def lost(self) -> None: """ - Handle the game-lost state. - - Updates the game status, reveals all bombs, - and displays a game over message overlay. + Handle the game-lost state with modern overlay. """ print("Game over! You hit a bomb.") - # Set game status self.game_status = "lost" # Reveal all bombs for tile in self.all_tiles: if tile.is_bomb and tile.is_hidden: - # Force reveal bombs without triggering cascade tile.is_hidden = False tile.draw(self.game_window) - # Update the counter display with sad face self.draw_counters() - # Show a message on the screen - font = pygame.font.Font('freesansbold.ttf', 64) - lose_text = font.render("GAME OVER", True, (255, 0, 0)) - text_rect = lose_text.get_rect(center=(SCREEN_WIDTH//2, STRIP_HEIGHT + SCREEN_HEIGHT//2)) + # Modern loss overlay + overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) - # Apply a semi-transparent overlay - overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) - overlay.fill((0, 0, 0, 128)) # Semi-transparent black + # Modern loss text + lose_font = pygame.font.Font(None, 72) + lose_text = lose_font.render("GAME OVER", True, COLORS['danger']) + lose_rect = lose_text.get_rect(center=(SCREEN_WIDTH//2, STRIP_HEIGHT + SCREEN_HEIGHT//2 - 20)) + + sub_text = self.counter_font.render("Click restart to try again", True, COLORS['text_secondary']) + sub_rect = sub_text.get_rect(center=(SCREEN_WIDTH//2, STRIP_HEIGHT + SCREEN_HEIGHT//2 + 30)) - # Draw overlay and text self.game_window.blit(overlay, (0, 0)) - self.game_window.blit(lose_text, text_rect) + self.game_window.blit(lose_text, lose_rect) + self.game_window.blit(sub_text, sub_rect) pygame.display.flip() #---------------------------------------------------------------------- @@ -373,90 +458,124 @@ def lost(self) -> None: def draw_counters(self) -> None: """ - Draw the top strip with bomb counter, restart button, and flag counter. - - This updates the UI elements in the top strip of the game window. + Draw counters with platform-adaptive sizing. """ - # Create a gray background for the strip + # Modern dark strip strip_bg = pygame.Surface((SCREEN_WIDTH, STRIP_HEIGHT)) - strip_bg.fill((192, 192, 192)) # Light gray like classic Minesweeper + strip_bg.fill(COLORS['background']) self.game_window.blit(strip_bg, (0, 0)) - # Draw border for the strip - pygame.draw.rect(self.game_window, (128, 128, 128), (0, 0, SCREEN_WIDTH, STRIP_HEIGHT), 2) + # Calculate remaining mines + remaining_mines = self.bomb_count - self.flagged_count + + # Platform-adaptive fonts + counter_font = pygame.font.Font(None, COUNTER_FONT_SIZE) + label_font = pygame.font.Font(None, LABEL_FONT_SIZE) - # Calculate remaining flags (bomb count - flagged count), it could go negative - remaining_flags_count = self.bomb_count - self.flagged_count + # Modern mine counter (left side) + mine_text = f"{remaining_mines:03d}" + mine_surface = counter_font.render(mine_text, True, COLORS['text_primary']) + mine_label = label_font.render("MINES", True, COLORS['text_secondary']) - # Create bomb counter (left side) - bomb_counter = self.counter_font.render(f"{remaining_flags_count:03d}", True, (255, 0, 0)) - bomb_rect = bomb_counter.get_rect(center=(80, STRIP_HEIGHT//2)) + # Platform-adaptive positioning + x_margin = 100 if IS_MOBILE else 80 + y_offset = 8 if IS_MOBILE else 5 + label_offset = -25 if IS_MOBILE else -15 - # Create flag counter (right side) - flag_counter = self.counter_font.render(f"{self.flagged_count:03d}", True, (0, 0, 255)) - flag_rect = flag_counter.get_rect(center=(SCREEN_WIDTH - 80, STRIP_HEIGHT//2)) + mine_rect = mine_surface.get_rect(center=(x_margin, STRIP_HEIGHT//2 + y_offset)) + mine_label_rect = mine_label.get_rect(center=(x_margin, STRIP_HEIGHT//2 + label_offset)) + + # Modern flag counter (right side) + flag_text = f"{self.flagged_count:03d}" + flag_surface = counter_font.render(flag_text, True, COLORS['text_primary']) + flag_label = label_font.render("FLAGS", True, COLORS['text_secondary']) + + flag_rect = flag_surface.get_rect(center=(SCREEN_WIDTH - x_margin, STRIP_HEIGHT//2 + y_offset)) + flag_label_rect = flag_label.get_rect(center=(SCREEN_WIDTH - x_margin, STRIP_HEIGHT//2 + label_offset)) # Draw the counters - self.game_window.blit(bomb_counter, bomb_rect) - self.game_window.blit(flag_counter, flag_rect) + self.game_window.blit(mine_label, mine_label_rect) + self.game_window.blit(mine_surface, mine_rect) + self.game_window.blit(flag_label, flag_label_rect) + self.game_window.blit(flag_surface, flag_rect) - # Draw restart button with appropriate face - if self.game_status == "playing": - self.game_window.blit(self.face_happy, self.restart_rect) - elif self.game_status == "won": - self.game_window.blit(self.face_cool, self.restart_rect) - else: # lost - self.game_window.blit(self.face_sad, self.restart_rect) - - # Add a border around the button - pygame.draw.rect(self.game_window, (128, 128, 128), self.restart_rect, 2) + # Modern restart button + self.draw_modern_restart_button() - # Update only the strip region + # Update strip pygame.display.update([(0, 0, SCREEN_WIDTH, STRIP_HEIGHT)]) - def draw_restart_button(self) -> None: + def draw_modern_restart_button(self) -> None: """ - Draw the restart button with the appropriate face. - - The face changes based on game status (happy, cool, or sad). + Draw restart button with platform-adaptive sizing. """ + # Platform-adaptive button radius + button_radius = 30 if IS_MOBILE else 20 + center = (self.restart_rect.centerx, self.restart_rect.centery) + if self.game_status == "playing": - # Draw the happy face - self.game_window.blit(self.face_happy, self.restart_rect.topleft) + pygame.draw.circle(self.game_window, COLORS['tile_hidden'], center, button_radius) + pygame.draw.circle(self.game_window, COLORS['accent_blue'], center, button_radius, 2) + + # Restart symbol (larger for mobile) + arc_size = 16 if IS_MOBILE else 10 + line_width = 4 if IS_MOBILE else 3 + + pygame.draw.arc(self.game_window, COLORS['text_primary'], + (center[0]-arc_size, center[1]-arc_size, arc_size*2, arc_size*2), 0.5, 5.5, line_width) + + # Arrow tip (larger for mobile) + arrow_size = 8 if IS_MOBILE else 5 + pygame.draw.polygon(self.game_window, COLORS['text_primary'], [ + (center[0]+arc_size-2, center[1]-arc_size+2), + (center[0]+arc_size+arrow_size-2, center[1]-arrow_size), + (center[0]+arrow_size, center[1]-arrow_size) + ]) elif self.game_status == "won": - # Draw the cool face - self.game_window.blit(self.face_cool, self.restart_rect.topleft) - elif self.game_status == "lost": - # Draw the sad face - self.game_window.blit(self.face_sad, self.restart_rect.topleft) - - # Update the restart button area - pygame.display.update(self.restart_rect) - + pygame.draw.circle(self.game_window, COLORS['success'], center, button_radius) + pygame.draw.circle(self.game_window, COLORS['light_blue'], center, button_radius, 2) + + # Checkmark (larger for mobile) + check_size = 8 if IS_MOBILE else 6 + line_width = 4 if IS_MOBILE else 3 + check_points = [ + (center[0]-check_size, center[1]), + (center[0]-2, center[1]+check_size//2), + (center[0]+check_size, center[1]-check_size//2) + ] + pygame.draw.lines(self.game_window, COLORS['text_primary'], False, check_points, line_width) + else: # lost + pygame.draw.circle(self.game_window, COLORS['danger'], center, button_radius) + pygame.draw.circle(self.game_window, COLORS['secondary_light'], center, button_radius, 2) + + # X symbol (larger for mobile) + x_size = 8 if IS_MOBILE else 6 + line_width = 4 if IS_MOBILE else 3 + pygame.draw.line(self.game_window, COLORS['text_primary'], + (center[0]-x_size, center[1]-x_size), (center[0]+x_size, center[1]+x_size), line_width) + pygame.draw.line(self.game_window, COLORS['text_primary'], + (center[0]+x_size, center[1]-x_size), (center[0]-x_size, center[1]+x_size), line_width) + #---------------------------------------------------------------------- # Utility methods #---------------------------------------------------------------------- @staticmethod - def get_tile_at_pos(mouse_x: int, mouse_y: int, tile_size: int, buffer: int, x_offset: int) -> tuple[int, int]: + def get_tile_at_pos(mouse_x: int, mouse_y: int, tile_size: int, buffer: int) -> tuple[int, int]: """ Convert mouse coordinates to grid coordinates. Args: - mouse_x (int): Mouse x-coordinate - mouse_y (int): Mouse y-coordinate (already adjusted for strip height) + mouse_x (int): Mouse x-coordinate (already adjusted for grid offset) + mouse_y (int): Mouse y-coordinate (already adjusted for grid offset) tile_size (int): Size of each tile in pixels buffer (int): Space between tiles in pixels - x_offset (int): Horizontal offset of the grid Returns: tuple[int, int]: (row, col) coordinates in the grid """ - # Adjust mouse position by the offset - adjusted_x = mouse_x - x_offset - buffer - - # Calculate tile coordinates - col = adjusted_x // (tile_size + buffer) + # Calculate tile coordinates with buffer consideration + col = mouse_x // (tile_size + buffer) row = mouse_y // (tile_size + buffer) return row, col diff --git a/minesweeper/grid.py b/minesweeper/grid.py index 69750aa..c779d6b 100644 --- a/minesweeper/grid.py +++ b/minesweeper/grid.py @@ -180,31 +180,24 @@ def set_numbers(self, all_tiles: list, bomb_tiles: list) -> None: def draw_grid(self) -> None: """ - Draw the entire grid to the game window. - - This method: - 1. Fills the entire game area with a background color - 2. Draws all individual tiles - 3. Updates the display to show changes - - The update region covers the entire game area below the top strip - to prevent black strips from appearing on edges. + Draw the grid with modern flat design and minimal spacing. """ - # Fill the entire game area with background color + from minesweeper.game import COLORS + + # Fill background with modern dark color pygame.draw.rect( self.game_window, - (192, 192, 192), # Light gray background + COLORS['background'], (0, self.y_offset, self.game_window.get_width(), self.game_window.get_height() - self.y_offset) ) - # Draw all tiles + # Draw all tiles with minimal spacing for row in self.tiles: for tile in row: tile.draw(self.game_window) - # Update the ENTIRE screen below the strip, not just the grid area - # This ensures no black strips remain + # Update the display pygame.display.update([(0, self.y_offset, self.game_window.get_width(), self.game_window.get_height() - self.y_offset)]) diff --git a/minesweeper/tile.py b/minesweeper/tile.py index 2be44d6..62a3864 100644 --- a/minesweeper/tile.py +++ b/minesweeper/tile.py @@ -1,4 +1,5 @@ import pygame +import math from typing import Callable @@ -152,60 +153,95 @@ def n_flagged_neighbours(self) -> int: def get_color(self) -> tuple[int, int, int]: """ Get the RGB color tuple for the tile's number based on its value. - - Colors follow the standard Minesweeper scheme: - 1: Blue, 2: Green, 3: Red, 4: Dark Blue, etc. + Modern minimalist color scheme. Returns: tuple: RGB color values (0-255) for the number's color """ - tile_colors = { - 0: (192, 192, 192), # Empty opened tile - 1: (0, 0, 255), # Blue - 2: (0, 128, 0), # Green - 3: (255, 0, 0), # Red - 4: (0, 0, 128), # Dark Blue - 5: (128, 0, 0), # Dark Red - 6: (0, 128, 128), # Cyan - 7: (0, 0, 0), # Black - 8: (128, 128, 128) # Gray - } - return tile_colors[self.value] + from minesweeper.game import NUMBER_COLORS + return NUMBER_COLORS.get(self.value, (255, 255, 255)) def draw(self, game_window: pygame.Surface) -> None: """ - Render the tile on the game window with appropriate visuals based on its state. + Render the tile with platform-adaptive design. + """ + from minesweeper.game import COLORS, TILE_BORDER_RADIUS, IS_MOBILE - This method draws the tile with different appearances depending on whether it's: - - Hidden: Shows the hidden tile image - - Flagged: Shows the flag image - - Revealed bomb: Shows the bomb image - - Revealed number: Shows the revealed tile with number - - Revealed empty: Shows the revealed tile without number + # Create tile rectangle with platform-appropriate padding + padding = 2 if IS_MOBILE else 1 + tile_rect = pygame.Rect( + self.pos_x + padding, + self.pos_y + padding, + self.tile_size - (padding * 2), + self.tile_size - (padding * 2) + ) - Args: - game_window (pygame.Surface): The pygame surface on which to draw the tile - """ - # Determine which image to use if self.is_hidden: - img = self.flagged_img if self.is_flagged else self.hidden_img - elif self.is_bomb: - img = self.bomb_img + if self.is_flagged: + # Flagged tile - using your cyan accent + pygame.draw.rect(game_window, COLORS['accent_blue'], tile_rect, border_radius=TILE_BORDER_RADIUS) + pygame.draw.rect(game_window, COLORS['light_blue'], tile_rect, 2, border_radius=TILE_BORDER_RADIUS) + + # Draw flag symbol (larger for mobile) + center_x = self.pos_x + self.tile_size // 2 + center_y = self.pos_y + self.tile_size // 2 + flag_size = (self.tile_size // 2) if IS_MOBILE else (self.tile_size // 3) + + # Flag pole (thicker for mobile) + pole_width = 4 if IS_MOBILE else 3 + pygame.draw.line(game_window, COLORS['text_primary'], + (center_x - flag_size//3, center_y - flag_size//2), + (center_x - flag_size//3, center_y + flag_size//2), pole_width) + + # Flag triangle + flag_points = [ + (center_x - flag_size//3, center_y - flag_size//2), + (center_x + flag_size//3, center_y - flag_size//4), + (center_x - flag_size//3, center_y) + ] + pygame.draw.polygon(game_window, COLORS['secondary_accent'], flag_points) + else: + # Hidden tile - black with subtle border + pygame.draw.rect(game_window, COLORS['tile_hidden'], tile_rect, border_radius=TILE_BORDER_RADIUS) + pygame.draw.rect(game_window, COLORS['tile_border'], tile_rect, 1, border_radius=TILE_BORDER_RADIUS) else: - img = self.revealed_img - - # Scale and draw the base tile image - scaled_img = pygame.transform.scale(img, (self.tile_size, self.tile_size)) - game_window.blit(scaled_img, (self.pos_x, self.pos_y)) - - # Add number text if it's already revealed and has a number - if not self.is_hidden and self.is_numbered: - text_surface = self.font.render(str(self.value), True, self.get_color()) - text_rect = text_surface.get_rect(center=( - self.pos_x + self.tile_size // 2, - self.pos_y + self.tile_size // 2 - )) - game_window.blit(text_surface, text_rect) + if self.is_bomb: + # Bomb tile + pygame.draw.rect(game_window, COLORS['danger'], tile_rect, border_radius=TILE_BORDER_RADIUS) + + # Enhanced bomb icon (larger for mobile) + center_x = self.pos_x + self.tile_size // 2 + center_y = self.pos_y + self.tile_size // 2 + bomb_radius = max(self.tile_size // 4 if IS_MOBILE else self.tile_size // 5, 6) + + # Main bomb body + pygame.draw.circle(game_window, COLORS['background'], (center_x, center_y), bomb_radius) + pygame.draw.circle(game_window, COLORS['text_primary'], (center_x, center_y), bomb_radius, 2) + + # Bomb spikes + spike_length = bomb_radius // 2 + spike_width = 2 if IS_MOBILE else 1 + for angle in [0, 45, 90, 135, 180, 225, 270, 315]: + end_x = center_x + spike_length * math.cos(math.radians(angle)) + end_y = center_y + spike_length * math.sin(math.radians(angle)) + pygame.draw.line(game_window, COLORS['text_primary'], + (center_x, center_y), (int(end_x), int(end_y)), spike_width) + else: + # Revealed tile + pygame.draw.rect(game_window, COLORS['tile_revealed'], tile_rect, border_radius=TILE_BORDER_RADIUS) + pygame.draw.rect(game_window, COLORS['tile_border'], tile_rect, 1, border_radius=TILE_BORDER_RADIUS) + + # Add number rendering (larger font for mobile) + if self.is_numbered: + font_size = max(self.tile_size // 2, 20 if IS_MOBILE else 16) + number_font = pygame.font.Font(None, font_size) + + text_surface = number_font.render(str(self.value), True, self.get_color()) + text_rect = text_surface.get_rect(center=( + self.pos_x + self.tile_size // 2, + self.pos_y + self.tile_size // 2 + )) + game_window.blit(text_surface, text_rect) #---------------------------------------------------------------------- # Game mechanics - primary interaction methods