Untitled

 avatar
unknown
plain_text
5 months ago
43 kB
2
Indexable
# DO NOT modify or add any import statements
import tkinter.filedialog

from a2_support import *
import tkinter as tk
from tkinter import messagebox, filedialog
from typing import Optional, Callable

# Name: Dzmitry Silchanka
# Student Number: s4885581
# -----------

# Write your classes and functions here


class Tile:
    def __init__(self) -> None:
        pass

    def __repr__(self) -> str:
        """
        Returns a machine-readable string that could be used to construct an identical instance of the tile.
        """
        return f"{self.__class__.__name__}()"

    def __str__(self) -> str:
        """
        Returns the character representing the type of the tile.
        """
        return TILE_SYMBOL

    def get_tile_name(self) -> str:
        """
        Returns the name of the type of the tile.
        """
        return TILE_NAME

    def is_blocking(self) -> bool:
        """
        Returns True only when the tile is blocking. By default, tiles are not blocking.
        """
        return False


class Mountain(Tile):

    def __init__(self) -> None:
        super().__init__()

    def is_blocking(self) -> bool:
        """
        Returns True if the tile is blocking. By default, Mountains are blocking.
        """
        return True

    def get_tile_name(self) -> str:
        """
        Returns the name of the mountain Tile.
        """
        return MOUNTAIN_NAME

    def __str__(self) -> str:
        """
        Returns the character representing the type of the tile.
        """
        return MOUNTAIN_SYMBOL


class Ground(Tile):
    def __init__(self) -> None:
        super().__init__()

    def get_tile_name(self) -> str:
        """
        Returns the name of the ground Tile.
        """
        return GROUND_NAME

    def __str__(self) -> str:
        """
        Returns the symbol representing a ground Tile
        """
        return GROUND_SYMBOL


class Building(Tile):
    def __init__(self, initial_health: int) -> None:
        self.health = initial_health
        super().__init__()

    def is_destroyed(self) -> bool:
        """
        Returns true if the building is destroyed and false otherwise.
        """
        if self.health != 0:
            return False
        else:
            return True

    def damage(self, damage: int):
        """
        Damages the building by the amount specified in "damage".
        """
        if not self.is_destroyed():
            self.health = min(9, max(0, self.health - damage))

    def is_blocking(self) -> bool:
        """
        Returns if the building is blocking (True) or not (False).
        """
        if self.health != 0:
            return True
        else:
            return False

    def __str__(self) -> str:
        """
        Returns the health of the building as a string.
        """
        return str(self.health)

    def get_tile_name(self) -> str:
        """
        Returns the name of the building tile.
        """
        return BUILDING_NAME

    def __repr__(self) -> str:
        """
        Returns a string that can be used to create another building instance.
        """
        return f"{self.__class__.__name__}("+str(self.health)+")"


class Board:
    def __init__(self, board) -> None:
        """
        Concrete class representing the board on which the game is played
        Each position in the board is a tuple, where (0,0) is the top left corner
        """
        self.board = self.create_board(board)

    def create_board(self, board: list[str]) -> list:
        """
        Converts a list of strings representing tiles to their respective Tile objects.
        """
        return [[self.char_to_tile(char) for char in row] for row in board]

    def char_to_tile(self, char) -> Tile:
        """
        :param char: String representing tile
        :return: Tile object representing char.
        """
        if char == " ":
            return Ground()
        elif char == "M":
            return Mountain()
        elif char.isdigit():
            return Building(int(char))

    def tile_to_char(self, tile) -> str:
        """
        :param tile: Tile Object
        :return: String Representation of tile.
        """
        if isinstance(tile, Ground):
            return " "
        elif isinstance(tile, Mountain):
            return "M"
        elif isinstance(tile, Building):
            return str(tile.health)

    def __repr__(self) -> str:
        """
        :return: String representing the board
        """
        return f"Board({[[self.tile_to_char(tile) for tile in row] for row in self.board]})"

    def __str__(self):
        return "\n".join("".join(str(tile) for tile in row)
                         for row in self.board)

    def get_dimensions(self) -> tuple[int, int]:
        """
        Returns the (#rows, #columns) dimensions of the board.
        """
        return len(self.board), len(self.board[0])

    def get_tile(self, position: tuple[int, int]) -> Tile:
        """
        Returns the tile at the specified position.
        """
        return self.board[position[0]][position[1]]

    def get_buildings(self) -> dict[tuple[int, int], Building]:
        """
        Returns a dictionary mapping the positions of
        buildings to the building instances at those positions.
        """
        buildings = {}
        for row_index, row in enumerate(self.board):
            for col_index, _ in enumerate(row):
                tile = self.get_tile((row_index, col_index))
                if isinstance(tile, Building):
                    buildings[(row_index, col_index)] = tile
        return buildings


class Entity:
    Entity_ID_Counter = 0

    def __init__(self, position, initial_health, speed, strength) -> None:
        """
        :param position: Position of entity.
        :param initial_health: Starting health of entity.
        :param speed: How many tiles the entity can move in a turn.
        :param strength: How strong the entity's damage or healing is.
        """
        self._id = Entity.Entity_ID_Counter
        Entity.Entity_ID_Counter += 1
        self.position = position
        self.health = initial_health
        self.speed = speed
        self.strength = strength
        self._name = self.__class__.__name__

    def __repr__(self) -> str:
        """
        :return: Comma separated string representing entity.
        """
        return f'{self._name}({self.position}, {self.health}, {self.speed}' \
               f', {self.strength})'

    def __str__(self) -> str:
        """
        :return: Comma seperated string from which
        an identical entity can be constructed
        """
        return f'{ENTITY_SYMBOL},{self.position[0]},{self.position[1]}' \
               f',{self.health},{self.speed},{self.strength}'

    def get_symbol(self) -> str:
        """

        :return: Symbol representing entity
        """
        return ENTITY_SYMBOL

    def get_name(self) -> str:
        """

        :return: Name representing entity
        """
        return self._name

    def get_position(self) -> tuple[int, int]:
        """
        :return: Entity Position - tuple
        """
        return self.position

    def set_position(self, position: tuple[int, int]) -> None:
        """
        Sets position of entity
        :param position: Tuple representing postion
        """
        self.position = position

    def get_health(self) -> int:
        """

        :return: health of entity
        """
        return self.health

    def get_speed(self) -> int:
        """

        :return: speed of entity
        """
        return self.speed

    def get_strength(self) -> int:
        """
        :return: strength of entity
        """
        return self.strength

    def damage(self, damage: int) -> None:
        """
        lowers the enemy's health by the damage dealt
        :param damage: int of how much damage entity takes
        """
        if self.is_alive():
            self.health = max(0, self.health - damage)

    def is_alive(self) -> bool:
        """

        :return: is the entity is alive (more than 0) health or not
        """
        return self.health > 0

    def is_friendly(self) -> bool:
        """

        :return: if the entity is friendly. default, NOT friendly
        """
        return False

    def get_targets(self) -> list[tuple[int, int]]:
        """

        :return: positions the entity is targeting.
        default, adjacent squares not inc. diagonals
        """
        return [(self.position[0] + dx, self.position[1] + dy)
                for dx, dy in [(-1, 0), (1, 0),
                               (0, -1), (0, 1)]]

    def attack(self, entity) -> None:
        """

        :param entity: target entity
        damages the target entity by the current entity's strength.
        """
        if self.is_alive():
            entity.damage(self.strength)

    def get_display_character(self) -> str:
        """

        :return: The character that will be displayed on the game board
        """
        if self.get_symbol() == SCORPION_SYMBOL:
            return "\U00010426"
        elif self.get_symbol() == FIREFLY_SYMBOL:
            return "\U00000D9E"
        elif self.get_symbol() == HEAL_SYMBOL:
            return "\U0001F6E1"
        elif self.get_symbol() == TANK_SYMBOL:
            return "\U000023F8"


class Mech(Entity):
    """
    Abstract class that inherits from Entity, representing a mech.
    """
    def __init__(self, position, initial_health, speed, strength) -> None:
        super().__init__(position, initial_health, speed, strength)
        self._previous_position = position
        self._is_active = True

    def is_friendly(self) -> bool:
        """

        :return: True, as all mechs are friendly
        """
        return True

    def get_symbol(self) -> str:
        """

        :return: Symbol representing a Mech
        """
        return MECH_SYMBOL

    def get_name(self) -> str:
        """

        :return: Name of mech
        """
        return MECH_NAME

    def enable(self) -> None:
        """
        Enables the mech to move this turn
        """
        self._is_active = True

    def disable(self) -> None:
        """
        Disables the mech, preventing it from moving
        """
        self._is_active = False

    def is_active(self) -> bool:
        """
        Returns bool of if the mech is active or not.
        """
        return self._is_active

    def set_position(self, position) -> None:
        """

        :param position: Position for the mech to move
        Moves the mech to the position specified
        """
        self._previous_position = self.get_position()
        super().set_position(position)

    def get_previous_position(self) -> tuple[int, int]:
        """

        :return: the previous position of the mech
        """
        return self._previous_position

    def __str__(self) -> str:
        """

        :return: string that can be used to construct an identical mech
        """
        return f'{MECH_SYMBOL},{self.position[0]},{self.position[1]}' \
               f',{self.health},{self.speed},{self.strength}'


class TankMech(Mech):
    """
    Concrete class representing a Tank Mech, which is an attacking unit
    """
    def __init__(self, position, initial_health, speed, strength) -> None:
        super().__init__(position, initial_health, speed, strength)
        self._previous_position = position

    def get_symbol(self) -> str:
        """

        :return: Tank symbol
        """
        return TANK_SYMBOL

    def get_name(self) -> str:
        """

        :return: Tank name
        """
        return TANK_NAME

    def __str__(self) -> str:
        """

        :return: String representing the tank entity
        """
        return f'{TANK_SYMBOL},{self.position[0]},{self.position[1]}' \
               f',{self.health},{self.speed},{self.strength}'

    def get_targets(self) -> list[tuple[int, int]]:
        """
        :return: List of tuples (positions) that the tank is attacking
        Tanks attack horizontally from both sides, up to their range
        """
        x, y = self.position
        targets = [(x, y+i) for i in range(1, (TANK_RANGE+1))]
        targets += [(x, y-i) for i in range(1, (TANK_RANGE+1))]
        return targets


class HealMech(Mech):
    """
    Concrete class representing a Heal Mech, which is a protective unit
    """
    def __init__(self, position, health, strength, speed) -> None:
        super().__init__(position, health, strength, speed)

    def __str__(self) -> str:
        """

        :return: String representing the Heal Mech entity
        """
        return f'{HEAL_SYMBOL},{self.position[0]},{self.position[1]}' \
               f',{self.health},{self.speed},{self.strength}'

    def get_symbol(self) -> str:
        return HEAL_SYMBOL

    def get_name(self) -> str:
        """

        :return: Name of heal mech
        """
        return HEAL_NAME

    def get_strength(self) -> int:
        """

        :return: Strength of HealMech. Negative value as these mechs heal units.
        """
        return -self.strength

    def attack(self, entity) -> None:
        """

        :param entity: target unit
        Heals the unit if they are friendly
        """
        if entity.is_friendly():
            entity.health += self.strength


class Enemy(Entity):
    """
    Abstract class representing an enemy unit
    """
    def __init__(self, position, health, speed, strength) -> None:
        super().__init__(position, health, speed, strength)
        self.objective = position

    def __str__(self) -> str:
        """

        :return: String representing the enemy
        """
        return f'{ENEMY_SYMBOL},{self.position[0]},{self.position[1]}' \
               f',{self.health},{self.speed},{self.strength}'

    def get_symbol(self) -> str:
        """

        :return: Character representing the enemy
        """
        return ENEMY_SYMBOL

    def get_name(self) -> str:
        """

        :return: enemy name
        """
        return ENEMY_NAME

    def get_objective(self) -> tuple[int, int]:
        """

        :return: enemy objective (where it wants to go)
        """
        return self.objective

    def update_objective(self, entities, buildings) -> None:
        """
        default behavior: set objective to current position
        :param entities: list of entities
        :param buildings: list of buildings
        """
        self.objective = self.position


class Scorpion(Enemy):
    """
    Concrete class representing a Scorpion, which is an enemy type.
    """
    def __init__(self, position, health, speed, strength) -> None:
        super().__init__(position, health, speed, strength)

    def get_symbol(self) -> str:
        """

        :return: Symbol representing a Scorpion
        """
        return SCORPION_SYMBOL

    def get_name(self) -> str:
        """

        :return: Name of Scorpion unit
        """
        return SCORPION_NAME

    def __str__(self) -> str:
        """

        :return: String representation of a scorpion.
        """
        return f'{SCORPION_SYMBOL},{self.position[0]},' \
               f'{self.position[1]},{self.health},{self.speed},{self.strength}'

    def get_targets(self) -> list[tuple[int, int]]:
        """
        :return: List of tuples (positions) the scorpion is attacking
        the scorpion attacks vertically and horizontally
        at a distance equal to their range
        """
        x, y = self.position
        targets = [(x, y+i) for i in range(-SCORPION_RANGE, 0)]\
                  + [(x, y+i) for i in range(1, SCORPION_RANGE+1)]
        targets += [(x+i, y) for i in range(-SCORPION_RANGE, 0)]\
                   + [(x+i, y) for i in range(1, SCORPION_RANGE+1)]
        return targets

    def update_objective(self, entities, buildings) -> None:
        """

        :param entities: list of entities
        :param buildings: list of buildings
        Assigns the position of the mech with the highest
        health as the scorpion's objective
        """
        mechs = []
        for e in entities:
            if isinstance(e, (TankMech, HealMech)):
                mechs.append(e)
        if not mechs:
            return
        max_health = 0
        max_health_mech = None
        for mech in mechs:
            health = mech.get_health()
            if health > max_health:
                max_health = health
                max_health_mech = mech
        self.objective = max_health_mech.get_position()


class Firefly(Enemy):
    """
    Concrete class representing a Firefly, which is a hostile unit
    """
    def __init__(self, position, health, speed, strength) -> None:
        super().__init__(position, health, speed, strength)

    def get_symbol(self) -> str:
        """

        :return: Symbol representing a firefly
        """
        return FIREFLY_SYMBOL

    def get_name(self) -> str:
        """

        :return: Name of a Firefly
        """
        return FIREFLY_NAME

    def __str__(self) -> str:
        """
        :return:  string representing a unique firefly entity
        """
        return f'{FIREFLY_SYMBOL},{self.position[0]},{self.position[1]}' \
               f',{self.health},{self.speed},{self.strength}'

    def get_targets(self) -> list[tuple[int, int]]:
        """

        :return: list of tuples representing positions the firefly is targeting
        The firefly can positions above and below itself equal to its range.
        """
        x, y = self.position
        targets = [(x+i, y) for i in range(-FIREFLY_RANGE, 0)]\
                  + [(x+i, y) for i in range(1, FIREFLY_RANGE+1)]
        return targets

    def update_objective(self, entities, buildings) -> None:
        """

        :param entities: list of entities
        :param buildings: list of buildings
        Sets its objective to the position of the building
        with the lowest health
        """
        if not buildings:
            return
        min_health_building = \
            min(buildings, key=lambda x: int(str(buildings[x])))
        self.objective = min_health_building


class BreachModel:
    """
    Concrete class representing the state of the game
    Includes the current position of the board, entities,
    if it is ready to save, moving enemies, making attacks,
    assigning objectives, and ending turns. Also checks
    if the player has won or lost.
    """
    def __init__(self, board: Board, entities: list[Entity]) -> None:
        self.board = board
        self.entities = entities
        self.is_ready_to_save = True
        self.game_end_callback = None

    def __str__(self) -> str:
        """

        :return: string representation of the board
        """
        return f"{self.board}\n\n" + "\n".join(str(entity)
                                               for entity in self.entities)

    def get_board(self) -> Board:
        """

        :return: the board (object)
        """
        return self.board

    def get_entities(self) -> list:
        """

        :return: list of entities
        """
        return self.entities

    def entity_positions(self) -> dict[tuple[int, int], Entity]:
        """

        :return: dictionary containing all entities and their position
        """
        return {entity.get_position(): entity for entity in self.entities}

    def has_lost(self) -> bool:
        """
        :return: True or False depending on if the current state of the game
        is lost or not
        """
        buildings = []
        mechs = [entity for entity in self.entities if
                 isinstance(entity, Mech)]
        for building in self.board.get_buildings().values():
            buildings.append(building)
        if all(building.is_destroyed() for building in buildings):
            return True
        elif all(entity.health == 0 for entity in mechs):
            return True
        else:
            return False

    def has_won(self) -> bool:
        """
        :return: True or False depending on if the current state of the game
        is won or not
        """
        enemies = [entity for entity in self.entities
                   if isinstance(entity, Enemy)]
        mechs = [entity for entity in self.entities
                 if isinstance(entity, Mech)]
        all_enemies_destroyed = all(not enemy.is_alive() for enemy in enemies)
        at_least_one_mech_alive = any(mech.is_alive() for mech in mechs)
        buildings = []
        for building in self.board.get_buildings().values():
            buildings.append(building)
        at_least_one_building_alive = any(not x.is_destroyed() for x in buildings)
        return all_enemies_destroyed and at_least_one_mech_alive\
               and at_least_one_building_alive

    def get_valid_movement_positions(self, entity: Entity)\
            -> list[tuple[int, int]]:
        """
        :param entity: current entity
        :return: list of positions that the entity can move to in its turn
        """
        board = self.get_board()
        entity_position = entity.get_position()
        movement_positions = []

        for row in range(board.get_dimensions()[0]):
            for col in range(board.get_dimensions()[1]):
                position = (row, col)
                if position != entity_position and not\
                        board.get_tile(position).is_blocking():
                    distance = get_distance(self, entity_position, position)
                    if distance != -1 and distance <= entity.get_speed():
                        movement_positions.append((distance, position))
        movement_positions.sort()
        return [position for _, position in movement_positions]

    def attempt_move(self, entity, position) -> None:
        """

        :param entity: current entity
        :param position: where the entity is trying to move
        moves the entity to the desired position and disables it
        """
        if entity.is_friendly() and entity.is_active() and position\
                in self.get_valid_movement_positions(entity):
            entity.set_position(position)
            entity.disable()
        self.is_ready_to_save = False

    def assign_objectives(self) -> None:
        """
        assigns all entities their objective based on the state of the game
        """
        for entity in self.get_entities():
            if isinstance(entity, Enemy):
                entity.update_objective(self.get_entities(),
                                        self.get_board().get_buildings())

    def move_enemies(self) -> None:
        """
        moves all enemies to the correct place on the board considering
        the state of the game, their speed, and what their objective is
        """
        self.assign_objectives()
        for entity in self.get_entities():
            if isinstance(entity, Enemy):
                min_distance = float('inf')
                best_positions = []
                for position in self.get_valid_movement_positions(entity):
                    distance = get_distance(self, entity.get_objective()
                                            , position)
                    if distance != -1 and (distance < min_distance
                                           or distance == min_distance):
                        min_distance = distance
                        if distance == 1 and min_distance == 1:
                            best_positions.append(position)
                        else:
                            best_positions = [position]
                    elif distance == -1:
                        best_positions.append(position)
                if isinstance(entity, Firefly):
                    print(best_positions, min_distance)
                if best_positions:
                    if min_distance == 1:
                        best_positions.sort(key=lambda x: (-x[0], x[1]))
                        for position in best_positions:
                            entity.set_position(position)
                            if entity.get_objective() in entity.get_targets():
                                break
                    else:
                        best_positions.sort(key=lambda x: (x[0], -x[1]))
                        if min_distance != float("inf"):
                            entity.set_position(best_positions[0])

    def make_attack(self, entity) -> None:
        """

        :param entity: current entity
        makes the current entity attack all positions in its range.
        """
        for target in self.entities:
            if target.position in entity.get_targets():
                target.damage(entity.get_strength())
        buildings = self.board.get_buildings()
        for position, building in buildings.items():
            if position in entity.get_targets():
                building.damage(entity.get_strength())

    def end_turn(self) -> None:
        """

        functionality for when the player has ended their turn
        according to the game rules
        """
        for entity in self.get_entities():
            if entity.is_alive():
                self.make_attack(entity)
            if not entity.is_alive():
                self.entities.remove(entity)
        for entity in self.entities:
            if not entity.is_alive():
                self.entities.remove(entity)
        self.move_enemies()
        self.is_ready_to_save = True
        for entity in self.entities:
            if isinstance(entity, Mech):
                entity.enable()

    def ready_to_save(self) -> bool:
        """
        :return: True if the game is in a saveable state, otherwise False
        """
        return self.is_ready_to_save

# GUI


class GameGrid(AbstractGrid):
    """
    Concrete class representing the grid-like board the player
    will see when they play.
    Uses AbstractGrid, a class that provides methods to help
    use a Tkinter canvas as a grid
    """
    def __init__(self, master, dimensions, size, **kwargs) -> None:
        super().__init__(master, dimensions, size, **kwargs)

    def get_cell_size(self) -> tuple[int, int]:
        """Returns the size of the cells (width, height) in pixels."""
        return self._get_cell_size()

    def redraw(self, board, entities, highlighted=None, movement=False):
        """Clears the game grid, then redraws it according to the provided information."""
        self.clear()
        for row in range(board.get_dimensions()[0]):
            for col in range(board.get_dimensions()[1]):
                tile = board.get_tile((row, col))
                if highlighted and (row, col) in highlighted:
                    if movement:
                        colour = MOVE_COLOR
                    else:
                        colour = ATTACK_COLOR
                elif type(tile) is Building and not tile.is_destroyed():
                    colour = BUILDING_COLOR
                elif type(tile) is Mountain:
                    colour = MOUNTAIN_COLOR
                elif type(tile) is Ground:
                    colour = GROUND_COLOR
                else:
                    colour = DESTROYED_COLOR
                self.color_cell((row, col), colour)
                if colour == BUILDING_COLOR\
                        or (colour == ATTACK_COLOR
                            and isinstance(tile, Building)):
                    self.annotate_position((row, col), str(tile.health))
        for entity in entities:
            self.annotate_position((entity.position[0], entity.position[1])
                                   , entity.get_display_character())

    def bind_click_callback(self, click_callback: Callable[[tuple[int, int]]
    , None]) -> None:
        """
        :param click_callback: callable function taking a position
        binds the left and right mouse clicks
        to a function in the controller class
        returns a tuple (position) where the player has clicked
        """
        self.bind('<Button-1>', lambda event:
        click_callback(self.pixel_to_cell(event.x, event.y)))
        self.bind('<Button-2>', lambda event:
        click_callback(self.pixel_to_cell(event.x, event.y)))


class SideBar(AbstractGrid):
    """
    Concrete class representing the sidebar, which shows the player a list
    of all entities, their positions, and strength.
    """
    def __init__(self, master, dimensions, size, **kwargs) -> None:
        super().__init__(master, dimensions, size, **kwargs)
        self._header_font = SIDEBAR_FONT

    def display(self, entities) -> None:
        """
        :param entities: list of entities
        draws the header, and list of entities and their attributes
        """
        self.clear()
        self._draw_header()
        self.set_dimensions((len(entities) + 1, 4))
        for i, entity in enumerate(entities):
            self._draw_entity_row(i, entity)

    def _draw_header(self) -> None:
        """
        annotates each position the header approptiately
        """
        for i, heading in enumerate(SIDEBAR_HEADINGS):
            self.annotate_position((0, i), heading, font=self._header_font)

    def _draw_entity_row(self, row, entity) -> None:
        """

        :param row: row in sidebar
        :param entity: current entity
        Draws the entities position, health, and strength onto the sidebar.
        """
        symbol = entity.get_display_character()
        self.annotate_position((row + 1, 0), symbol)
        self.annotate_position((row + 1, 1), f"({entity.position[0]}"
                                             f", {entity.position[1]})")
        self.annotate_position((row + 1, 2), str(entity.health))
        self.annotate_position((row + 1, 3), str(entity.get_strength()))


class ControlBar(tk.Frame):
    """
    Concrete class representing the bar housing the three buttons at the
    bottom of the display, allowing the user to save, load, and end their turn
    """
    def __init__(self, master: tk.Widget
                 , save_callback: Optional[Callable[[], None]] = None
                 , load_callback: Optional[Callable[[], None]] = None
                 , turn_callback: Optional[Callable[[], None]] = None
                 , **kwargs) -> None:
        super().__init__(master, **kwargs)

        self.save_callback = save_callback
        self.load_callback = load_callback
        self.turn_callback = turn_callback

        # create widgets and layout here
        self.save_button = tk.Button(self, text="Save Game"
                                     , command=self.on_save)
        self.save_button.pack(side='left', expand=True)

        self.load_button = tk.Button(self, text="Load Game"
                                     , command=self.on_load)
        self.load_button.pack(side='left', expand=True)

        self.turn_button = tk.Button(self, text="End Turn"
                                     , command=self.on_turn)
        self.turn_button.pack(side='left', expand=True)

    def on_save(self) -> None:
        """
        when save button clicked, callback to controller
        """
        if self.save_callback:
            self.save_callback()

    def on_load(self) -> None:
        """
        when load button clicked, callback to controller
        """
        if self.load_callback:
            self.load_callback()

    def on_turn(self) -> None:
        """
        when end turn button clicked, callback to controller
        """
        if self.turn_callback:
            self.turn_callback()


class BreachView:
    """
    Concrete class that combines GameGrid, SideBar, and ControlBar
    to display to the player the current game state.
    """
    def __init__(self, root: tk.Tk, board_dims: tuple[int, int]
                 , save_callback: Optional[Callable[[], None]] = None
                 , load_callback: Optional[Callable[[], None]] = None
                 , turn_callback: Optional[Callable[[], None]] = None
                 , ) -> None:

        self.root = root
        self.root.title("Into The Breach")
        self.board_dims = board_dims
        self.banner_frame = tk.Frame(self.root, height=BANNER_HEIGHT)
        self.banner_frame.pack()
        self.banner = tk.Label(self.banner_frame, text=BANNER_TEXT,
                               font=BANNER_FONT)
        self.banner.pack(fill='x')
        self.game_frame = tk.Frame(self.root)
        self.game_grid = GameGrid(self.game_frame, self.board_dims
                                  , (GRID_SIZE, GRID_SIZE))
        self.game_grid.pack(side='left')
        self.side_bar = SideBar(self.game_frame, (1, 4)
                                , (SIDEBAR_WIDTH, GRID_SIZE))
        self.side_bar.pack(side='left')
        self.control_frame = tk.Frame(self.root, height=CONTROL_BAR_HEIGHT
                                      , width=SIDEBAR_WIDTH+GRID_SIZE)
        self.control_bar = ControlBar(self.control_frame)
        self.control_bar.pack(fill='x', expand=True)
        self.game_frame.pack()
        self.control_frame.pack(fill='x', expand=True)

    def bind_click_callback(self, click_callback: Callable[[tuple[int, int]]
    , None]) -> None:
        """

        :param click_callback: callable function
        binds a function in the controller to the callback in the game grid
        """
        self.game_grid.bind_click_callback(click_callback)

    def redraw(self, board: Board, entities: list[Entity]
               , highlighted: list[tuple[int, int]]
               = None, movement: bool = False) -> None:
        """

        :param board: current board
        :param entities: list of entities
        :param highlighted: which positions are highlighted
        :param movement: if the highlight should be shown as
        movement or attack

        redraws the view model to the current state of the game.
        """
        self.game_grid.redraw(board, entities, highlighted, movement)
        self.side_bar.display(entities)


class IntoTheBreach:
    """
    Concrete class that acts as the controller to the game
    Controller gameplay, saving, and loading.
    """
    def __init__(self, root: tk.Tk, game_file: str) -> None:

        self.board, self.entities = self.initialise_from_file(game_file)
        self.game_board = Board(self.board)
        self.root = root
        self.file_path = game_file
        self.highlighted = []
        self.focussed_entity = None
        self.model = BreachModel(self.game_board
                                 , self.entities)  # Initialize BreachModel
        self.view = BreachView(self.root
                               , self.game_board.get_dimensions()
                               , self.entities)  # Initialize BreachView instance
        self.view.bind_click_callback(self._handle_click)
        self.view.control_bar.turn_callback = self._end_turn
        self.view.control_bar.load_callback = self._load_game
        self.view.control_bar.save_callback = self._save_game
        self.view.side_bar.display(self.entities)
        self.redraw()  # Redraw the view based on the initial game state

    def initialise_from_file(self, file_path):
        """
        :param file_path: file path to computer directory
        :return: tuple containing the board as a list of strings,
        or none in case of error loading the game.
        """
        try:
            with open(file_path, 'r') as f:
                content = f.read()
            paragraphs = content.split('\n\n')
            board = [line for line in paragraphs[0].split('\n')]
            entities = []
            for entity_str in paragraphs[1].split('\n'):
                if entity_str:
                    entity_type, x, y, health, speed, strength\
                        = entity_str.split(',')
                    x, y, health, speed, strength = int(x), int(y)\
                        , int(health), int(speed), int(strength)
                    if entity_type == 'T':
                        entities.append(TankMech((x, y), health
                                                 , speed, strength))
                    elif entity_type == 'H':
                        entities.append(HealMech((x, y), health
                                                 , speed, strength))
                    elif entity_type == 'S':
                        entities.append(Scorpion((x, y), health
                                                 , speed, strength))
                    elif entity_type == 'F':
                        entities.append(Firefly((x, y), health
                                                , speed, strength))
        except ValueError:
            messagebox.showerror(IO_ERROR_TITLE, IO_ERROR_MESSAGE)
            return None
        return board, entities

    def redraw(self) -> None:
        """
        Redraws the display according to the current game state
        """
        self.view.game_grid.redraw(self.game_board, self.entities)
        self.view.side_bar.display(self.entities)

    def set_focussed_entity(self, entity: Optional[Entity]) -> None:
        """
        :param entity: entitiy to focus
        redraws a grid with the focused entity. focused entities
        highlight the tiles they are able to move to if they
        are friendly and haven't moved, or the tiles they will attack
        if they are unfriendly or have moved.
        """
        if entity:
            if isinstance(entity, Mech):
                if entity.is_active():
                    self.highlighted\
                        = self.model.get_valid_movement_positions(entity)
                    self.view.redraw(self.game_board, self.entities
                                     , self.highlighted, True)
                else:
                    self.draw_targets(entity)
            elif not entity.is_friendly():
                self.draw_targets(entity)
        else:
            self.view.redraw(self.game_board, self.entities)
            self.highlighted = []

    def draw_targets(self, entity) -> None:
        """
        :param entity: current entity
        :return: highlights the positions that the entity is attacking
        """
        self.highlighted = entity.get_targets()
        self.view.redraw(self.game_board, self.entities, self.highlighted)
        self.highlighted = []

    def make_move(self, position: tuple[int, int]) -> None:
        """

        :param position: position to move entity to
        :return: attempts to move the focussed entity to a position
        """
        self.model.attempt_move(self.focussed_entity, position)
        self.view.redraw(self.game_board, self.entities)

    def load_model(self, file_path: str) -> None:
        """

        :param file_path: file path to file in directory
        loads a new game state based on the model in the file
        """
        try:
            board, entities = self.initialise_from_file(file_path)
            self.file_path = file_path
            self.entities = entities
            self.board = self.game_board
            self.game_board.board = self.game_board.create_board(board)
            self.model.entities = self.entities
            self.model.board = self.game_board
            self.view.board_dims = self.game_board.get_dimensions()
            self.view.game_grid.set_dimensions\
                (self.game_board.get_dimensions())
            self.view.redraw(self.game_board, self.entities)
            print(self.game_board)
        except IndexError:
            tk.messagebox.showerror(IO_ERROR_TITLE
                                    , f'{IO_ERROR_MESSAGE}{file_path}')

    def _save_game(self) -> None:
        """
        Opens a file dialogue where the user is able to save their game
        to a new file. Raises error if the game state does not allow
        for a save at the moment
        """
        if self.model.ready_to_save():
            file_path = tkinter.filedialog.asksaveasfilename\
                (defaultextension='.txt', filetypes=[("Text Files", "*.txt")])
            if file_path:
                with open(file_path, 'w') as file:
                    file.write(str(self.model))
        else:
            tk.messagebox.showerror(INVALID_SAVE_TITLE, INVALID_SAVE_MESSAGE)

    def _load_game(self) -> None:
        """
        Opens a filedialogue that asks player for a file they wish to open
        a new level with.
        """
        try:
            file_path = tkinter.filedialog.askopenfilename\
                (defaultextension='.txt')
            if file_path:
                self.load_model(file_path)
        except IOError:
            messagebox.showerror(IO_ERROR_TITLE, IO_ERROR_MESSAGE)

    def _end_turn(self) -> None:
        """
        Runs functionality for ending the player's turn and redraws the board.
        """
        self.model.end_turn()
        self.view.redraw(self.game_board, self.entities)
        check_play_again = None
        if self.model.has_won():
            check_play_again = messagebox.askquestion\
                ("You Win!", "You Win! Would you like to play again?")
        if self.model.has_lost():
            check_play_again = messagebox.askquestion\
                ("You Lost!", "You Lost! Would you like to play again?")
        if check_play_again == "yes":
            self.load_model(self.file_path)
        elif check_play_again == "no":
            self.root.destroy()

    def _handle_click(self, position: tuple[int, int]) -> None:
        """

        :param position: where the player clicked
        Handles what occurs when a player clicks the game grid.
        """
        print(f"Clicked on tile at position {position}")
        if position in self.model.entity_positions():
            for entity in self.entities:
                if entity.position == position:
                    self.focussed_entity = entity
                    self.set_focussed_entity(entity)
        elif position in self.highlighted:
            self.make_move(position)
        else:
            self.focussed_entity = None
            self.set_focussed_entity(None)


def play_game(root: tk.Tk, file_path: str) -> None:
    """

    :param root: tk root instance
    :param file_path: path to file in directory
    initiates a new controller using the given model
    """
    controller = IntoTheBreach(root, file_path)
    controller.root.mainloop()


def main() -> None:
    """
    Is called when the program is run
    """
    root_instance = tk.Tk()
    play_game(root_instance, "level1.txt")


if __name__ == "__main__":
    main()

Editor is loading...
Leave a Comment