~piotr-machura/sweep-ai

b8c621acf7a8656ac72d4bb56a7824df2373c9c3 — Piotr Machura 10 months ago 6c4d894
Add GUI
M .pylintrc => .pylintrc +3 -3
@@ 23,9 23,9 @@ ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
init-hook=
    try: import pylint_venv
    except ImportError: pass
    else: pylint_venv.inithook()
    # try: import pylint_venv
    # except ImportError: pass
    # else: pylint_venv.inithook()

# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.

A assets/Grid.png => assets/Grid.png +0 -0
A assets/empty.png => assets/empty.png +0 -0
A assets/flag.png => assets/flag.png +0 -0
A assets/grid1.png => assets/grid1.png +0 -0
A assets/grid2.png => assets/grid2.png +0 -0
A assets/grid3.png => assets/grid3.png +0 -0
A assets/grid4.png => assets/grid4.png +0 -0
A assets/grid5.png => assets/grid5.png +0 -0
A assets/grid6.png => assets/grid6.png +0 -0
A assets/grid7.png => assets/grid7.png +0 -0
A assets/grid8.png => assets/grid8.png +0 -0
A assets/hint.png => assets/hint.png +0 -0
A assets/mine.png => assets/mine.png +0 -0
M sweep_ai/__main__.py => sweep_ai/__main__.py +1 -8
@@ 1,12 1,5 @@
"""Game entry point."""
from .logic import State


def main():
    """Entrypoint function."""
    state = State(4, 0.25)
    state.click(0, 0)

from .window import main

if __name__ == '__main__':
    main()

M sweep_ai/logic.py => sweep_ai/logic.py +15 -5
@@ 4,20 4,18 @@ from typing import List, Optional, Tuple

import numpy as np

#pylint: disable=invalid-name
# pylint: disable=invalid-name


class State:
    """Represents game state at any point in time.

    Note that this does not track flags, as they have no impact on the gameplay
    logic and should be handled by the UI exclusively.

    Properties:
        size: size of the square board
        near[x, y]: how many bombs are near `(x, y)`
        bomb[x, y]: 1 if `(x, y)` is a bomb
        revealed[x, y]: 1 if `(x, y)` has been revealed
        flagged[x, y]: 1 if `(x, y)` has been flagged
        won: `True` if user has won, `None` if the game has not finished
        clicks: tracks the number of total clicks done by the user
    """


@@ 41,6 39,7 @@ class State:
        self.bomb = np.zeros((self.size, self.size), dtype=int)
        self.revealed = np.copy(self.bomb)
        self.near = np.copy(self.bomb)
        self.flagged = np.copy(self.bomb)
        self.won: Optional[bool] = None
        self.clicks = 0



@@ 92,7 91,7 @@ class State:

    @property
    def score(self):
        """Calcualte the score as a simple revealed tiles / safe tiles ratio."""
        """Calculate the score as a simple revealed / safe ratio."""
        if self.won is not False:
            # We have not lost yet
            return self.revealed_n / self.safe_n


@@ 122,13 121,24 @@ class State:
        if np.array_equal(self.revealed, self.safe):
            self.won = True

    def flag(self, x: int, y: int):
        """Place a flag at `(x, y)`.

        A no-op if `(x, y)` is already revealed.
        """
        if not self.revealed[x, y]:
            self.flagged[x, y] = 1

    def reveal(self, x: int, y: int):
        """Reveals `(x, y)`.

        If `(x, y)` is not near a bomb the reveal cascades recursively,
        revealing the neighbors too.

        If `(x, y)` was flagged the flag is removed.
        """
        self.revealed[x, y] = 1
        self.flagged[x, y] = 0
        # If there are no bombs nearby then cascade
        if self.near[x, y] == 0:
            for n_x, n_y in self.neighbors(x, y):

M sweep_ai/window.py => sweep_ai/window.py +238 -14
@@ 1,19 1,243 @@
"""Window handling module."""
import sys
from typing import Optional, Tuple

import neat
import pygame
from pygame.constants import QUIT


def open_window():
    """Open the main window."""
    pygame.init()
    pygame.display.set_mode((400, 300))
    pygame.display.set_caption(f'Hello World! - AI with {neat.__name__}')
    while True:    # main game loop
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()
import pygame_menu

from .logic import State


class Game:
    """Game class."""
    DIFFICULTY = {'easy': 0.05, 'normal': 0.12, 'hard': 0.15, 'torment': 0.2}
    SIZE = {'small': 10, 'regular': 12, 'large': 16}

    def __init__(self):
        """Constructor for the `Game` class."""
        pygame.init()
        self.difficulty = self.DIFFICULTY['easy']
        self.size = self.SIZE['small']
        self.hint: Optional[Tuple[int, int]] = None
        self.grid_s = 32
        self.border = 8
        self.menu_width = 260

        self.display_width = self.grid_s * self.size + self.border * 2 + self.menu_width
        self.display_height = self.grid_s * self.size + self.border * 2
        self.game_display = pygame.display.set_mode(
            (self.display_width, self.display_height),
        )

        self.sprites = {}
        self.sprites['flag'] = pygame.image.load('assets/flag.png')
        self.sprites['hidden'] = pygame.image.load('assets/Grid.png')
        self.sprites['mine'] = pygame.image.load('assets/mine.png')
        self.sprites[0] = pygame.image.load('assets/empty.png')
        for i in range(1, 9):
            self.sprites[i] = pygame.image.load(f'assets/grid{i}.png')

        self.menu_pos = self.grid_s * self.size + self.border * 2
        self.timer = pygame.time.Clock()
        self.configure_menu()
        self.reset()

    def configure_menu(self):
        theme = pygame_menu.Theme(
            background_color=pygame_menu.themes.TRANSPARENT_COLOR,
            title=False,
            widget_font=pygame_menu.font.FONT_FIRACODE_BOLD,
            widget_font_size=18,
            widget_font_antialias=True,
            widget_font_color=(255, 255, 255),
            widget_margin=(0, 10),
            widget_selection_effect=pygame_menu.widgets.NoneSelection(),
        )
        self._menu = pygame_menu.Menu(
            height=self.display_height,
            mouse_motion_selection=True,
            position=(self.menu_pos, 25, False),
            theme=theme,
            title='',
            width=240,
        )
        self._menu.add.label(
            'Sweep AI',
            margin=(0, 0),
            font_name=pygame_menu.font.FONT_8BIT,
            font_size=22,
        ).translate(0, -10)
        self._menu.add.label(
            '',
            label_id='winlose',
            margin=(0, 15),
        ).translate(-40, 18)
        self._menu.add.button(
            'Hint',
            self.get_hint,
            padding=5,
            margin=(0, 0),
            cursor=pygame_menu.locals.CURSOR_HAND,
            font_color=(163, 190, 140),
        ).translate(50, -30)
        self._menu.add.dropselect(
            '',
            list(self.DIFFICULTY.items()),
            selection_box_width=100,
            selection_box_inflate=(0, 12),
            font_size=16,
            selection_box_height=len(self.DIFFICULTY),
            placeholder_add_to_selection_box=False,
            selection_option_padding=2,
            default=list(self.DIFFICULTY.values()).index(self.difficulty),
            onchange=self.set_difficulty,
        )
        self._menu.add.dropselect(
            '',
            list(self.SIZE.items()),
            selection_box_width=100,
            selection_box_inflate=(0, 12),
            font_size=16,
            selection_box_height=len(self.SIZE),
            placeholder_add_to_selection_box=False,
            selection_option_padding=2,
            default=list(self.SIZE.values()).index(self.size),
            onchange=self.set_size,
        )
        self._menu.add.button(
            'Reset',
            self.reset,
            font_size=18,
            padding=5,
            shadow_width=100,
            cursor=pygame_menu.locals.CURSOR_HAND,
            font_color=(208, 135, 112),
        ).translate(-50, 0)
        self._menu.add.button(
            'Exit',
            pygame_menu.events.EXIT,
            font_size=18,
            padding=5,
            cursor=pygame_menu.locals.CURSOR_HAND,
            font_color=(191, 97, 106),
        ).translate(50, -43)
        self._menu.center_content()

    def reset(self):
        """Reset the game state."""
        self.state = State(self.size, self.difficulty)
        self.time = 0
        self.hint = None

    def set_difficulty(self, *args):
        """Set a new difficulty."""
        self.difficulty = args[1]
        self.reset()

    def set_size(self, *args):
        """Set a new board size."""
        self.size = args[1]
        self.display_width = self.grid_s * self.size + self.border * 2 + self.menu_width
        self.display_height = self.grid_s * self.size + self.border * 2
        self.game_display = pygame.display.set_mode(
            (self.display_width, self.display_height),
        )
        self.menu_pos = self.grid_s * self.size + self.border * 2
        self.configure_menu()
        self.reset()

    def get_hint(self):
        """Highlight the three safest."""
        self.hint = (1, 1)

    def within_board(self, pos_x: float, pos_y: float) -> bool:
        """Returns `true` if `pos_x`, `pos_y` is within the board."""
        return bool(
            self.border < pos_x <
            self.grid_s * self.size + self.border and self.border <
            pos_y < self.grid_s * self.size + self.border)

    def draw_square(self, x: int, y: int):
        """Draw a single square on the board."""
        x_pos = self.border + x * self.grid_s
        y_pos = self.border + y * self.grid_s
        if self.state.revealed[x, y]:
            if self.state.bomb[x, y]:
                self.game_display.blit(self.sprites['mine'], (x_pos, y_pos))
            else:
                self.game_display.blit(
                    self.sprites[self.state.near[x, y]],
                    (x_pos, y_pos),
                )
        else:
            if self.state.flagged[x, y]:
                self.game_display.blit(self.sprites['flag'], (x_pos, y_pos))
            else:
                self.game_display.blit(self.sprites['hidden'], (x_pos, y_pos))
            if self.hint is not None:
                if (x, y) == self.hint:
                    pygame.draw.rect(
                        self.game_display,
                        (163, 190, 140),
                        (x_pos, y_pos, self.grid_s, self.grid_s),
                        3,
                    )

    def draw_banner(self):
        """Draw the win/loss banner."""
        banner = self._menu.get_widget('winlose')
        if self.state.won is None:
            self.time += 1
            banner.update_font({'color': (255, 255, 255)})
            banner.set_title(f'Time: {self.time // 15}')
        elif self.state.won is True:
            banner.update_font({'color': (0, 255, 0)})
        elif self.state.won is False:
            banner.update_font({'color': (255, 0, 0)})

    def draw(self):
        """Draw the menu and board."""
        self.game_display.fill((0, 0, 0))
        for x in range(self.state.size):
            for y in range(self.state.size):
                self.draw_square(x, y)
        self.draw_banner()
        self._menu.draw(self.game_display)
        pygame.display.update()

    def handle_click(self, event: pygame.event.Event):
        """Handle the click event."""
        if event.type != pygame.MOUSEBUTTONUP or self.state.won is not None:
            return

        pos_x, pos_y = event.pos
        if not self.within_board(pos_x, pos_y):
            return

        x = int((pos_x - self.border) / self.grid_s)
        y = int((pos_y - self.border) / self.grid_s)
        if event.button == 1:
            self.hint = None
            self.state.click(x, y)
        elif event.button == 3:
            self.state.flag(x, y)

    def loop(self):
        """Main GUI loop."""
        while True:
            self.draw()
            events = pygame.event.get()
            self._menu.update(events)
            for event in events:
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit(0)
                else:
                    self.handle_click(event)
            self.timer.tick(15)


def main():
    """Main gameplay entry point."""
    game = Game()
    game.loop()

M tests/test_logic.py => tests/test_logic.py +1 -0
@@ 163,6 163,7 @@ def test_click():
    assert not state.won
    assert state.clicks == 3


def test_game():
    state = logic.State(4, bomb_positions=[(0, 0), (1, 1), (3, 3)])
    state.click(3, 0)