diff --git a/generals/agents/expander_agent.py b/generals/agents/expander_agent.py index a794892..2f56678 100644 --- a/generals/agents/expander_agent.py +++ b/generals/agents/expander_agent.py @@ -1,7 +1,7 @@ import numpy as np from .agent import Agent -from generals.core.config import DIRECTIONS +from generals.core.config import Direction class ExpanderAgent(Agent): @@ -27,8 +27,10 @@ def play(self, observation): # Find actions that capture opponent or neutral cells actions_capture_opponent = np.zeros(len(valid_actions)) actions_capture_neutral = np.zeros(len(valid_actions)) + + directions = [Direction.UP, Direction.DOWN, Direction.LEFT, Direction.RIGHT] for i, action in enumerate(valid_actions): - di, dj = action[:-1] + DIRECTIONS[action[-1]] # Destination cell indices + di, dj = action[:-1] + directions[action[-1]].value # Destination cell indices if army[action[0], action[1]] <= army[di, dj] + 1: # Can't capture continue elif opponent[di, dj]: diff --git a/generals/core/config.py b/generals/core/config.py index b7e0b65..72e9277 100644 --- a/generals/core/config.py +++ b/generals/core/config.py @@ -1,71 +1,33 @@ from typing import Literal from importlib.resources import files +from enum import Enum, IntEnum, StrEnum + ################# # Game Literals # ################# -PASSABLE: Literal['.'] = '.' -MOUNTAIN: Literal['#'] = '#' -CITY: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 0 # CITY can be any digit 0-9 +PASSABLE: Literal["."] = "." +MOUNTAIN: Literal["#"] = "#" +CITY: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 0 # CITY can be any digit 0-9 -######### -# Moves # -######### -UP: tuple[int, int] = (-1, 0) -DOWN: tuple[int, int] = (1, 0) -LEFT: tuple[int, int] = (0, -1) -RIGHT: tuple[int, int] = (0, 1) -DIRECTIONS: list[tuple[int, int]] = [UP, DOWN, LEFT, RIGHT] -################## -# Game constants # -################## -GAME_SPEED: float = 8 # by default, every 8 ticks, actions are processed +class Dimension(IntEnum): + SQUARE_SIZE = 50 + GUI_CELL_HEIGHT = 30 + GUI_CELL_WIDTH = 70 + MINIMUM_WINDOW_SIZE = 700 -######################## -# Grid visual settings # -######################## -SQUARE_SIZE: int = 50 -LINE_WIDTH: int = 1 -GUI_ROW_HEIGHT: int = 30 -GUI_CELL_WIDTH: int = 70 -MINIMUM_WINDOW_SIZE: int = 700 -########## -# Colors # -########## -FOG_OF_WAR: tuple[int, int, int] = (70, 73, 76) -NEUTRAL_CASTLE: tuple[int, int, int] = (128, 128, 128) -VISIBLE_PATH: tuple[int, int, int] = (200, 200, 200) -VISIBLE_MOUNTAIN: tuple[int, int, int] = (187, 187, 187) -BLACK: tuple[int, int, int] = (0, 0, 0) -WHITE: tuple[int, int, int] = (230, 230, 230) -PLAYER_1_COLOR: tuple[int, int, int] = (255, 0, 0) -PLAYER_2_COLOR: tuple[int, int, int] = (67, 99, 216) -PLAYER_COLORS: list[tuple[int, int, int]] = [PLAYER_1_COLOR, PLAYER_2_COLOR] +class Direction(Enum): + UP = (-1, 0) + DOWN = (1, 0) + LEFT = (0, -1) + RIGHT = (0, 1) -######### -# Fonts # -######### -FONT_TYPE = "Quicksand-Medium.ttf" # Font options are Quicksand-SemiBold.ttf, Quicksand-Medium.ttf, Quicksand-Light.ttf -FONT_SIZE = 18 -try: - file_ref = files("generals.assets.fonts") / FONT_TYPE - FONT_PATH = str(file_ref) -except FileNotFoundError: - raise FileNotFoundError(f"Font file {FONT_TYPE} not found in the fonts directory") -######### -# Icons # -######### -try: +class Path(StrEnum): GENERAL_PATH = str(files("generals.assets.images") / "crownie.png") -except FileNotFoundError: - raise FileNotFoundError("Image not found") -try: CITY_PATH = str(files("generals.assets.images") / "citie.png") -except FileNotFoundError: - raise FileNotFoundError("Image not found") -try: MOUNTAIN_PATH = str(files("generals.assets.images") / "mountainie.png") -except FileNotFoundError: - raise FileNotFoundError("Image not found") + + # Font options are Quicksand-SemiBold.ttf, Quicksand-Medium.ttf, Quicksand-Light.ttf + FONT_PATH = str(files("generals.assets.fonts") / "Quicksand-Medium.ttf") diff --git a/generals/core/game.py b/generals/core/game.py index 3c4f130..5152532 100644 --- a/generals/core/game.py +++ b/generals/core/game.py @@ -7,7 +7,7 @@ from .channels import Channels from .grid import Grid -from .config import DIRECTIONS +from .config import Direction from scipy.ndimage import maximum_filter @@ -16,6 +16,7 @@ Info: TypeAlias = dict[str, Any] increment_rate = 50 +DIRECTIONS = [Direction.UP, Direction.DOWN, Direction.LEFT, Direction.RIGHT] class Game: @@ -99,7 +100,7 @@ def action_mask(self, agent: str) -> np.ndarray: return valid_action_mask for channel_index, direction in enumerate(DIRECTIONS): - destinations = owned_cells_indices + direction + destinations = owned_cells_indices + direction.value # check if destination is in grid bounds in_first_boundary = np.all(destinations >= 0, axis=1) @@ -116,7 +117,7 @@ def action_mask(self, agent: str) -> np.ndarray: action_destinations = destinations[passable_cell_indices] # get valid action mask for a given direction - valid_source_indices = action_destinations - direction + valid_source_indices = action_destinations - direction.value valid_action_mask[ valid_source_indices[:, 0], valid_source_indices[:, 1], channel_index ] = 1.0 @@ -187,8 +188,8 @@ def step( continue di, dj = ( - si + DIRECTIONS[direction][0], - sj + DIRECTIONS[direction][1], + si + DIRECTIONS[direction].value[0], + sj + DIRECTIONS[direction].value[1], ) # destination indices # Figure out the target square owner and army size diff --git a/generals/gui/gui.py b/generals/gui/gui.py index 40e3c92..4bee36c 100644 --- a/generals/gui/gui.py +++ b/generals/gui/gui.py @@ -3,7 +3,12 @@ from generals.core.game import Game from .properties import Properties -from .event_handler import TrainEventHandler, GameEventHandler, ReplayEventHandler, ReplayCommand +from .event_handler import ( + TrainEventHandler, + GameEventHandler, + ReplayEventHandler, + Command, +) from .rendering import Renderer @@ -14,16 +19,16 @@ def __init__( agent_data: dict[str, dict[str, Any]], mode: Literal["train", "game", "replay"] = "train", ): - self.properties = Properties(game, agent_data, mode) - self.__renderer = Renderer(self.properties) - self.__event_handler = self.__initialize_event_handler() - pygame.init() pygame.display.set_caption("Generals") # Handle key repeats pygame.key.set_repeat(500, 64) + self.properties = Properties(game, agent_data, mode) + self.__renderer = Renderer(self.properties) + self.__event_handler = self.__initialize_event_handler() + def __initialize_event_handler(self): if self.properties.mode == "train": return TrainEventHandler @@ -32,7 +37,7 @@ def __initialize_event_handler(self): elif self.properties.mode == "replay": return ReplayEventHandler - def tick(self, fps=None): + def tick(self, fps=None) -> Command: handler = self.__event_handler(self.properties) command = handler.handle_events() if command.quit: diff --git a/generals/gui/properties.py b/generals/gui/properties.py index 992da09..b2bdf97 100644 --- a/generals/gui/properties.py +++ b/generals/gui/properties.py @@ -3,8 +3,8 @@ from pygame.time import Clock -from generals.core import config as c from generals.core.game import Game +from generals.core.config import Dimension @dataclass @@ -14,13 +14,16 @@ class Properties: __mode: Literal["train", "game", "replay"] __game_speed: int = 1 __clock: Clock = Clock() + __font_size = 18 def __post_init__(self): self.__grid_height: int = self.__game.grid_dims[0] self.__grid_width: int = self.__game.grid_dims[1] - self.__display_grid_width: int = c.SQUARE_SIZE * self.grid_width - self.__display_grid_height: int = c.SQUARE_SIZE * self.grid_height - self.__right_panel_width: int = 4 * c.GUI_CELL_WIDTH + self.__display_grid_width: int = Dimension.SQUARE_SIZE.value * self.grid_width + self.__display_grid_height: int = ( + Dimension.SQUARE_SIZE.value * self.grid_height + ) + self.__right_panel_width: int = 4 * Dimension.GUI_CELL_WIDTH.value self.__paused: bool = False @@ -85,6 +88,10 @@ def display_grid_height(self): def right_panel_width(self): return self.__right_panel_width + @property + def font_size(self): + return self.__font_size + def update_speed(self, multiplier: float) -> None: """multiplier: usually 2.0 or 0.5""" new_speed = self.game_speed * multiplier diff --git a/generals/gui/rendering.py b/generals/gui/rendering.py index 4cc41a6..6e81c46 100644 --- a/generals/gui/rendering.py +++ b/generals/gui/rendering.py @@ -1,8 +1,18 @@ import pygame import numpy as np -import generals.core.config as c -from .properties import Properties +from generals.gui.properties import Properties +from generals.core.config import Dimension, Path + +from typing import TypeAlias + +Color: TypeAlias = tuple[int, int, int] +FOG_OF_WAR: Color = (70, 73, 76) +NEUTRAL_CASTLE: Color = (128, 128, 128) +VISIBLE_PATH: Color = (200, 200, 200) +VISIBLE_MOUNTAIN: Color = (187, 187, 187) +BLACK: Color = (0, 0, 0) +WHITE: Color = (230, 230, 230) class Renderer: @@ -34,6 +44,9 @@ def __init__(self, properties: Properties): window_width = self.display_grid_width + self.right_panel_width window_height = self.display_grid_height + 1 + width = Dimension.GUI_CELL_WIDTH.value + height = Dimension.GUI_CELL_HEIGHT.value + # Main window self.screen = pygame.display.set_mode( (window_width, window_height), pygame.HWSURFACE | pygame.DOUBLEBUF @@ -42,14 +55,14 @@ def __init__(self, properties: Properties): self.right_panel = pygame.Surface((self.right_panel_width, window_height)) self.score_cols = {} for col in ["Agent", "Army", "Land"]: - size = (c.GUI_CELL_WIDTH, c.GUI_ROW_HEIGHT) + size = (width, height) if col == "Agent": - size = (2 * c.GUI_CELL_WIDTH, c.GUI_ROW_HEIGHT) + size = (2 * width, height) self.score_cols[col] = [pygame.Surface(size) for _ in range(3)] self.info_panel = { - "time": pygame.Surface((self.right_panel_width / 2, c.GUI_ROW_HEIGHT)), - "speed": pygame.Surface((self.right_panel_width / 2, c.GUI_ROW_HEIGHT)), + "time": pygame.Surface((self.right_panel_width / 2, height)), + "speed": pygame.Surface((self.right_panel_width / 2, height)), } # Game area and tiles self.game_area = pygame.Surface( @@ -57,21 +70,23 @@ def __init__(self, properties: Properties): ) self.tiles = [ [ - pygame.Surface((c.SQUARE_SIZE, c.SQUARE_SIZE)) + pygame.Surface( + (Dimension.SQUARE_SIZE.value, Dimension.SQUARE_SIZE.value) + ) for _ in range(self.grid_width) ] for _ in range(self.grid_height) ] self._mountain_img = pygame.image.load( - str(c.MOUNTAIN_PATH), "png" + str(Path.MOUNTAIN_PATH), "png" ).convert_alpha() self._general_img = pygame.image.load( - str(c.GENERAL_PATH), "png" + str(Path.GENERAL_PATH), "png" ).convert_alpha() - self._city_img = pygame.image.load(str(c.CITY_PATH), "png").convert_alpha() + self._city_img = pygame.image.load(Path.CITY_PATH, "png").convert_alpha() - self._font = pygame.font.Font(c.FONT_PATH, c.FONT_SIZE) + self._font = pygame.font.Font(Path.FONT_PATH, self.properties.font_size) def render(self, fps=None): self.render_grid() @@ -80,7 +95,13 @@ def render(self, fps=None): if fps: self.properties.clock.tick(fps) - def render_cell_text(self, cell, text, fg_color=c.BLACK, bg_color=c.WHITE): + def render_cell_text( + self, + cell, + text: str, + fg_color: Color = BLACK, + bg_color: Color = WHITE, + ): """ Draw a text in the middle of the cell with given foreground and background colors @@ -103,13 +124,13 @@ def render_stats(self): """ names = self.game.agents player_stats = self.game.get_infos() + gui_cell_height = Dimension.GUI_CELL_HEIGHT.value + gui_cell_width = Dimension.GUI_CELL_WIDTH.value # Write names for i, name in enumerate(["Agent"] + names): - color = ( - self.agent_data[name]["color"] if name in self.agent_data else c.WHITE - ) - # add opacity to the color, where color is a tuple (r,g,b) + color = self.agent_data[name]["color"] if name in self.agent_data else WHITE + # add opacity to the color, where color is a Color(r,g,b) if name in self.agent_fov and not self.agent_fov[name]: color = tuple([int(0.5 * rgb) for rgb in color]) self.render_cell_text(self.score_cols["Agent"][i], name, bg_color=color) @@ -118,25 +139,23 @@ def render_stats(self): for i, col in enumerate(["Army", "Land"]): self.render_cell_text(self.score_cols[col][0], col) for j, name in enumerate(names): - # Give darkish color if agents FoV is off - color = c.WHITE if name in self.agent_fov and not self.agent_fov[name]: color = (128, 128, 128) self.render_cell_text( self.score_cols[col][j + 1], str(player_stats[name][col.lower()]), - bg_color=color, + bg_color=WHITE, ) # Blit each right_panel cell to the right_panel surface for i, col in enumerate(["Agent", "Army", "Land"]): for j, cell in enumerate(self.score_cols[col]): rect_dim = (0, 0, cell.get_width(), cell.get_height()) - pygame.draw.rect(cell, c.BLACK, rect_dim, 1) + pygame.draw.rect(cell, BLACK, rect_dim, 1) - position = ((i + 1) * c.GUI_CELL_WIDTH, j * c.GUI_ROW_HEIGHT) + position = ((i + 1) * gui_cell_width, j * gui_cell_height) if col == "Agent": - position = (0, j * c.GUI_ROW_HEIGHT) + position = (0, j * gui_cell_height) self.right_panel.blit(cell, position) info_text = { @@ -156,10 +175,10 @@ def render_stats(self): self.info_panel[key].get_width(), self.info_panel[key].get_height(), ) - pygame.draw.rect(self.info_panel[key], c.BLACK, rect_dim, 1) + pygame.draw.rect(self.info_panel[key], BLACK, rect_dim, 1) self.right_panel.blit( - self.info_panel[key], (i * 2 * c.GUI_CELL_WIDTH, 3 * c.GUI_ROW_HEIGHT) + self.info_panel[key], (i * 2 * gui_cell_width, 3 * gui_cell_height) ) # Render right_panel on the screen self.screen.blit(self.right_panel, (self.display_grid_width, 0)) @@ -195,14 +214,14 @@ def render_grid(self): # Draw background of visible but not owned squares visible_not_owned = np.logical_and(visible_map, not_owned_map) - self.draw_channel(visible_not_owned, c.WHITE) + self.draw_channel(visible_not_owned, WHITE) # Draw background of squares in fog of war - self.draw_channel(invisible_map, c.FOG_OF_WAR) + self.draw_channel(invisible_map, FOG_OF_WAR) # Draw background of visible mountains visible_mountain = np.logical_and(self.game.channels.mountain, visible_map) - self.draw_channel(visible_mountain, c.VISIBLE_MOUNTAIN) + self.draw_channel(visible_mountain, VISIBLE_MOUNTAIN) # Draw mountains (even if they are not visible) self.draw_images(self.game.channels.mountain, self._mountain_img) @@ -212,7 +231,7 @@ def render_grid(self): visible_cities_neutral = np.logical_and( visible_cities, self.game.channels.ownership_neutral ) - self.draw_channel(visible_cities_neutral, c.NEUTRAL_CASTLE) + self.draw_channel(visible_cities_neutral, NEUTRAL_CASTLE) # Draw invisible cities as mountains invisible_cities = np.logical_and(self.game.channels.city, invisible_map) @@ -228,25 +247,25 @@ def render_grid(self): self.render_cell_text( self.tiles[i][j], str(int(visible_army[i, j])), - fg_color=c.WHITE, + fg_color=WHITE, bg_color=None, # Transparent background ) # Blit tiles to the self.game_area + square_size = Dimension.SQUARE_SIZE.value for i, j in np.ndindex(self.grid_height, self.grid_width): - self.game_area.blit( - self.tiles[i][j], (j * c.SQUARE_SIZE, i * c.SQUARE_SIZE) - ) + self.game_area.blit(self.tiles[i][j], (j * square_size, i * square_size)) self.screen.blit(self.game_area, (0, 0)) - def draw_channel(self, channel, color: tuple[int, int, int]): + def draw_channel(self, channel, color: Color): """ Draw background and borders (left and top) for grid tiles of a given channel """ + square_size = Dimension.SQUARE_SIZE.value for i, j in self.game.channel_to_indices(channel): self.tiles[i][j].fill(color) - pygame.draw.line(self.tiles[i][j], c.BLACK, (0, 0), (0, c.SQUARE_SIZE), 1) - pygame.draw.line(self.tiles[i][j], c.BLACK, (0, 0), (c.SQUARE_SIZE, 0), 1) + pygame.draw.line(self.tiles[i][j], BLACK, (0, 0), (0, square_size), 1) + pygame.draw.line(self.tiles[i][j], BLACK, (0, 0), (square_size, 0), 1) def draw_images(self, channel, image): """