# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse # SPDX-License-Identifier: GPL-3.0-or-later from enum import Enum, auto from math import sqrt from random import choice, randint from typing import List, Optional, Union, Tuple, Any from .display.texturepack import TexturePack from .translations import gettext as _ class Logs: """ The logs object stores the messages to display. It is encapsulating a list of such messages, to allow multiple pointers to keep track of it even if the list was to be reassigned. """ def __init__(self) -> None: self.messages = [] def add_message(self, msg: str) -> None: self.messages.append(msg) def add_messages(self, msg: List[str]) -> None: self.messages += msg def clear(self) -> None: self.messages = [] class Slope(): X: int Y: int def __init__(self, y: int, x: int) -> None: self.Y = y self.X = x def compare(self, other: Union[Tuple[int, int], "Slope"]) -> int: if isinstance(other, Slope): y, x = other.Y, other.X else: y, x = other return self.Y * x - self.X * y def __lt__(self, other: Union[Tuple[int, int], "Slope"]) -> bool: return self.compare(other) < 0 def __eq__(self, other: Union[Tuple[int, int], "Slope"]) -> bool: return self.compare(other) == 0 class Map: """ Object that represents a Map with its width, height and tiles, that have their custom properties. """ width: int height: int start_y: int start_x: int tiles: List[List["Tile"]] visibility: List[List[bool]] entities: List["Entity"] logs: Logs # coordinates of the point that should be # on the topleft corner of the screen currentx: int currenty: int def __init__(self, width: int, height: int, tiles: list, start_y: int, start_x: int): self.width = width self.height = height self.start_y = start_y self.start_x = start_x self.tiles = tiles self.visibility = [[False for _ in range(len(tiles[0]))] for _ in range(len(tiles))] self.entities = [] self.logs = Logs() def add_entity(self, entity: "Entity") -> None: """ Register a new entity in the map. """ self.entities.append(entity) entity.map = self def remove_entity(self, entity: "Entity") -> None: """ Unregister an entity from the map. """ if entity in self.entities: self.entities.remove(entity) def find_entities(self, entity_class: type) -> list: return [entity for entity in self.entities if isinstance(entity, entity_class)] def is_free(self, y: int, x: int) -> bool: """ Indicates that the tile at the coordinates (y, x) is empty. """ return 0 <= y < self.height and 0 <= x < self.width and \ self.tiles[y][x].can_walk() and \ not any(entity.x == x and entity.y == y for entity in self.entities) def entity_is_present(self, y: int, x: int) -> bool: """ Indicates that the tile at the coordinates (y, x) contains a killable entity """ return 0 <= y < self.height and 0 <= x < self.width and \ any(entity.x == x and entity.y == y and entity.is_friendly() for entity in self.entities) @staticmethod def load(filename: str) -> "Map": """ Read a file that contains the content of a map, and build a Map object. """ with open(filename, "r") as f: file = f.read() return Map.load_from_string(file) @staticmethod def load_from_string(content: str) -> "Map": """ Load a map represented by its characters and build a Map object. """ lines = content.split("\n") first_line = lines[0] start_y, start_x = map(int, first_line.split(" ")) lines = [line for line in lines[1:] if line] height = len(lines) width = len(lines[0]) tiles = [[Tile.from_ascii_char(c) for x, c in enumerate(line)] for y, line in enumerate(lines)] return Map(width, height, tiles, start_y, start_x) @staticmethod def load_dungeon_from_string(content: str) -> List[List["Tile"]]: """ Transforms a string into the list of corresponding tiles """ lines = content.split("\n") tiles = [[Tile.from_ascii_char(c) for x, c in enumerate(line)] for y, line in enumerate(lines)] return tiles def draw_string(self, pack: TexturePack) -> str: """ Draw the current map as a string object that can be rendered in the window. """ return "\n".join("".join(tile.char(pack) for tile in line) for line in self.tiles) def spawn_random_entities(self, count: int) -> None: """ Put randomly {count} entities on the map, where it is available. """ for _ignored in range(count): y, x = 0, 0 while True: y, x = randint(0, self.height - 1), randint(0, self.width - 1) tile = self.tiles[y][x] if tile.can_walk(): break entity = choice(Entity.get_all_entity_classes())() entity.move(y, x) self.add_entity(entity) def compute_visibility(self, y: int, x: int, max_range: int) -> None: """ Sets the visible tiles to be the ones visible by an entity at point (y, x), using a twaked shadow casting algorithm """ for line in self.visibility: for i in range(len(line)): line[i] = False self.visibility[y][x] = True for octant in range(8): self.compute_visibility_octant(octant, (y, x), max_range, 1, Slope(1, 1), Slope(0, 1)) def crop_top_visibility(self, octant: int, origin: Tuple[int, int], x: int, top: Slope) -> int: if top.X == 1: top_y = x else: top_y = ((x * 2 - 1) * top.Y + top.X) / (top.X * 2) if self.is_wall(top_y, x, octant, origin): if top >= (top_y * 2 + 1, x * 2) and not\ self.is_wall(top_y + 1, x, octant, origin): top_y += 1 else: ax = x * 2 if self.is_wall(top_y + 1, x + 1, octant, origin): ax += 1 if top > (top_y * 2 + 1, ax): top_y += 1 return top_y def crop_bottom_visibility(self, octant: int, origin: Tuple[int, int], x: int, bottom: Slope) -> int: if bottom.Y == 0: bottom_y = 0 else: bottom_y = ((x * 2 + 1) * bottom.Y + bottom.X) /\ (bottom.X * 2) if bottom >= (bottom_y * 2 + 1, x * 2) and\ self.is_wall(bottom_y, x, octant, origin) and\ not self.is_wall(bottom_y + 1, x, octant, origin): bottom_y += 1 return bottom_y def compute_visibility_octant(self, octant: int, origin: Tuple[int, int], max_range: int, distance: int, top: Slope, bottom: Slope) -> None: for x in range(distance, max_range): top_y = self.crop_top_visibility(octant, origin, x, top) bottom_y = self.crop_bottom_visibility(octant, origin, x, bottom) was_opaque = -1 for y in range(top_y, bottom_y - 1, -1): if sqrt(x**2 + y**2) > max_range: continue is_opaque = self.is_wall(y, x, octant, origin) is_visible = is_opaque\ or ((y != top_y or top > (y * 4 - 1, x * 4 - 1)) and (y != bottom_y or bottom < (y * 4 + 1, x * 4 + 1))) if is_visible: self.set_visible(y, x, octant, origin) if x == max_range: continue if is_opaque and was_opaque == 0: nx, ny = x * 2, y * 2 + 1 if self.is_wall(y + 1, x, octant, origin): nx -= 1 if top > (ny, nx): if y == bottom_y: bottom = Slope(ny, nx) break else: self.compute_visibility_octant( octant, origin, max_range, x + 1, top, Slope(ny, nx)) else: if y == bottom_y: return elif not is_opaque and was_opaque == 1: nx, ny = x * 2, y * 2 + 1 if self.is_wall(y + 1, x + 1, octant, origin): nx += 1 if bottom >= (ny, nx): return was_opaque = is_opaque if was_opaque != 0: break @staticmethod def translate_coord(y: int, x: int, octant: int, origin: Tuple[int, int]) -> Tuple[int, int]: ny, nx = origin if octant == 0: return nx + x, ny - y elif octant == 1: return nx + y, ny - x elif octant == 2: return nx - y, ny - x elif octant == 3: return nx - x, ny - y elif octant == 4: return nx - x, ny + y elif octant == 5: return nx - y, ny + x elif octant == 6: return nx + y, ny + x elif octant == 7: return nx + x, ny + y def is_wall(self, y: int, x: int, octant: int, origin: Tuple[int, int]) -> bool: y, x = self.translate_coord(y, x, octant, origin) return self.tiles[y][x].is_wall() def set_visible(self, y: int, x: int, octant: int, origin: Tuple[int, int]) -> None: y, x = self.translate_coord(y, x, octant, origin) self.visibility[y][x] = True def tick(self) -> None: """ Trigger all entity events. """ for entity in self.entities: entity.act(self) def save_state(self) -> dict: """ Saves the map's attributes to a dictionary """ d = dict() d["width"] = self.width d["height"] = self.height d["start_y"] = self.start_y d["start_x"] = self.start_x d["currentx"] = self.currentx d["currenty"] = self.currenty d["entities"] = [] for enti in self.entities: d["entities"].append(enti.save_state()) d["map"] = self.draw_string(TexturePack.ASCII_PACK) return d def load_state(self, d: dict) -> None: """ Loads the map's attributes from a dictionary """ self.width = d["width"] self.height = d["height"] self.start_y = d["start_y"] self.start_x = d["start_x"] self.currentx = d["currentx"] self.currenty = d["currenty"] self.tiles = self.load_dungeon_from_string(d["map"]) self.entities = [] dictclasses = Entity.get_all_entity_classes_in_a_dict() for entisave in d["entities"]: self.add_entity(dictclasses[entisave["type"]](**entisave)) class Tile(Enum): """ The internal representation of the tiles of the map """ EMPTY = auto() WALL = auto() FLOOR = auto() @staticmethod def from_ascii_char(ch: str) -> "Tile": """ Maps an ascii character to its equivalent in the texture pack """ for tile in Tile: if tile.char(TexturePack.ASCII_PACK) == ch: return tile raise ValueError(ch) def char(self, pack: TexturePack) -> str: """ Translates a Tile to the corresponding character according to the texture pack """ return getattr(pack, self.name) def is_wall(self) -> bool: """ Is this Tile a wall? """ return self == Tile.WALL def can_walk(self) -> bool: """ Check if an entity (player or not) can move in this tile. """ return not self.is_wall() and self != Tile.EMPTY class Entity: """ An Entity object represents any entity present on the map """ y: int x: int name: str map: Map # noinspection PyShadowingBuiltins def __init__(self, y: int = 0, x: int = 0, name: Optional[str] = None, map: Optional[Map] = None, *ignored, **ignored2): self.y = y self.x = x self.name = name self.map = map def check_move(self, y: int, x: int, move_if_possible: bool = False)\ -> bool: """ Checks if moving to (y,x) is authorized """ free = self.map.is_free(y, x) if free and move_if_possible: self.move(y, x) return free def move(self, y: int, x: int) -> bool: """ Moves an entity to (y,x) coordinates """ self.y = y self.x = x return True def move_up(self, force: bool = False) -> bool: """ Moves the entity up one tile, if possible """ return self.move(self.y - 1, self.x) if force else \ self.check_move(self.y - 1, self.x, True) def move_down(self, force: bool = False) -> bool: """ Moves the entity down one tile, if possible """ return self.move(self.y + 1, self.x) if force else \ self.check_move(self.y + 1, self.x, True) def move_left(self, force: bool = False) -> bool: """ Moves the entity left one tile, if possible """ return self.move(self.y, self.x - 1) if force else \ self.check_move(self.y, self.x - 1, True) def move_right(self, force: bool = False) -> bool: """ Moves the entity right one tile, if possible """ return self.move(self.y, self.x + 1) if force else \ self.check_move(self.y, self.x + 1, True) def act(self, m: Map) -> None: """ Define the action of the entity that is ran each tick. By default, does nothing. """ pass def distance_squared(self, other: "Entity") -> int: """ Get the square of the distance to another entity. Useful to check distances since square root takes time. """ return (self.y - other.y) ** 2 + (self.x - other.x) ** 2 def distance(self, other: "Entity") -> float: """ Get the cartesian distance to another entity. """ return sqrt(self.distance_squared(other)) def is_fighting_entity(self) -> bool: """ Is this entity a fighting entity? """ return isinstance(self, FightingEntity) def is_item(self) -> bool: """ Is this entity an item? """ from squirrelbattle.entities.items import Item return isinstance(self, Item) def is_friendly(self) -> bool: """ Is this entity a friendly entity? """ return isinstance(self, FriendlyEntity) def is_merchant(self) -> bool: """ Is this entity a merchant? """ from squirrelbattle.entities.friendly import Merchant return isinstance(self, Merchant) @property def translated_name(self) -> str: return _(self.name.replace("_", " ")) @staticmethod def get_all_entity_classes() -> list: """ Returns all entities subclasses """ from squirrelbattle.entities.items import BodySnatchPotion, Bomb, Heart from squirrelbattle.entities.monsters import Tiger, Hedgehog, \ Rabbit, TeddyBear from squirrelbattle.entities.friendly import Merchant, Sunflower return [BodySnatchPotion, Bomb, Heart, Hedgehog, Rabbit, TeddyBear, Sunflower, Tiger, Merchant] @staticmethod def get_all_entity_classes_in_a_dict() -> dict: """ Returns all entities subclasses in a dictionary """ from squirrelbattle.entities.player import Player from squirrelbattle.entities.monsters import Tiger, Hedgehog, Rabbit, \ TeddyBear from squirrelbattle.entities.friendly import Merchant, Sunflower from squirrelbattle.entities.items import BodySnatchPotion, Bomb, \ Heart, Sword return { "Tiger": Tiger, "Bomb": Bomb, "Heart": Heart, "BodySnatchPotion": BodySnatchPotion, "Hedgehog": Hedgehog, "Rabbit": Rabbit, "TeddyBear": TeddyBear, "Player": Player, "Merchant": Merchant, "Sunflower": Sunflower, "Sword": Sword, } def save_state(self) -> dict: """ Saves the coordinates of the entity """ d = dict() d["x"] = self.x d["y"] = self.y d["type"] = self.__class__.__name__ return d class FightingEntity(Entity): """ A FightingEntity is an entity that can fight, and thus has a health, level and stats """ maxhealth: int health: int strength: int intelligence: int charisma: int dexterity: int constitution: int level: int def __init__(self, maxhealth: int = 0, health: Optional[int] = None, strength: int = 0, intelligence: int = 0, charisma: int = 0, dexterity: int = 0, constitution: int = 0, level: int = 0, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.maxhealth = maxhealth self.health = maxhealth if health is None else health self.strength = strength self.intelligence = intelligence self.charisma = charisma self.dexterity = dexterity self.constitution = constitution self.level = level @property def dead(self) -> bool: return self.health <= 0 def hit(self, opponent: "FightingEntity") -> str: """ Deals damage to the opponent, based on the stats """ return _("{name} hits {opponent}.")\ .format(name=_(self.translated_name.capitalize()), opponent=_(opponent.translated_name)) + " " + \ opponent.take_damage(self, self.strength) def take_damage(self, attacker: "Entity", amount: int) -> str: """ Take damage from the attacker, based on the stats """ self.health -= amount if self.health <= 0: self.die() return _("{name} takes {amount} damage.")\ .format(name=self.translated_name.capitalize(), amount=str(amount))\ + (" " + _("{name} dies.") .format(name=self.translated_name.capitalize()) if self.health <= 0 else "") def die(self) -> None: """ If a fighting entity has no more health, it dies and is removed """ self.map.remove_entity(self) def keys(self) -> list: """ Returns a fighting entity's specific attributes """ return ["name", "maxhealth", "health", "level", "strength", "intelligence", "charisma", "dexterity", "constitution"] def save_state(self) -> dict: """ Saves the state of the entity into a dictionary """ d = super().save_state() for name in self.keys(): d[name] = getattr(self, name) return d class FriendlyEntity(FightingEntity): """ Friendly entities are living entities which do not attack the player """ dialogue_option: list def talk_to(self, player: Any) -> str: return _("{entity} said: {message}").format( entity=self.translated_name.capitalize(), message=choice(self.dialogue_option)) def keys(self) -> list: """ Returns a friendly entity's specific attributes """ return ["maxhealth", "health"] class InventoryHolder(Entity): hazel: int # Currency of the game inventory: list def translate_inventory(self, inventory: list) -> list: """ Translate the JSON-state of the inventory into a list of the items in the inventory. """ for i in range(len(inventory)): if isinstance(inventory[i], dict): inventory[i] = self.dict_to_inventory(inventory[i]) return inventory def dict_to_inventory(self, item_dict: dict) -> Entity: """ Translate a dict object that contains the state of an item into an item object. """ entity_classes = self.get_all_entity_classes_in_a_dict() item_class = entity_classes[item_dict["type"]] return item_class(**item_dict) def save_state(self) -> dict: """ We save the inventory of the merchant formatted as JSON """ d = super().save_state() d["hazel"] = self.hazel d["inventory"] = [item.save_state() for item in self.inventory] return d def add_to_inventory(self, obj: Any) -> None: """ Adds an object to inventory """ self.inventory.append(obj) def remove_from_inventory(self, obj: Any) -> None: """ Removes an object from the inventory """ self.inventory.remove(obj) def change_hazel_balance(self, hz: int) -> None: """ Change the number of hazel the entity has by hz. hz is negative when the player loses money and positive when he gains money """ self.hazel += hz