diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..504eb4a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + # Black formatter + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + language_version: python3 + + # isort import sorter + - repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort + name: isort + + # MyPy type checker + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: [] # Add type stubs here if needed (e.g., types-*) + # Run on all files like the GitHub workflow + pass_filenames: false + args: ["--ignore-missing-imports", "--show-column-numbers", "."] + +# Optional settings +fail_fast: false +default_stages: [pre-commit] diff --git a/components/base_component.py b/components/base_component.py deleted file mode 100644 index 2c5fbf9..0000000 --- a/components/base_component.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from engine import Engine - from entity import Entity - from game_map import GameMap - - -class BaseComponent: - parent: Entity # Owning entity instance. - - @property - def gamemap(self) -> GameMap: - return self.parent.gamemap - - @property - def engine(self) -> Engine: - return self.gamemap.engine diff --git a/components/equippable.py b/components/equippable.py deleted file mode 100644 index d624731..0000000 --- a/components/equippable.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from components.base_component import BaseComponent -from equipment_types import EquipmentType - -if TYPE_CHECKING: - from entity import Item - - -class Equippable(BaseComponent): - parent: Item - - def __init__( - self, - equipment_type: EquipmentType, - power_bonus: int = 0, - defense_bonus: int = 0, - ): - self.equipment_type = equipment_type - - self.power_bonus = power_bonus - self.defense_bonus = defense_bonus - - -class Dagger(Equippable): - def __init__(self) -> None: - super().__init__(equipment_type=EquipmentType.WEAPON, power_bonus=2) - - -class Sword(Equippable): - def __init__(self) -> None: - super().__init__(equipment_type=EquipmentType.WEAPON, power_bonus=4) - - -class LeatherArmor(Equippable): - def __init__(self) -> None: - super().__init__(equipment_type=EquipmentType.ARMOR, defense_bonus=1) - - -class ChainMail(Equippable): - def __init__(self) -> None: - super().__init__(equipment_type=EquipmentType.ARMOR, defense_bonus=3) diff --git a/components/__init__.py b/game/__init__.py similarity index 100% rename from components/__init__.py rename to game/__init__.py diff --git a/actions.py b/game/actions.py similarity index 61% rename from actions.py rename to game/actions.py index 9350cf6..9e9132f 100644 --- a/actions.py +++ b/game/actions.py @@ -2,22 +2,25 @@ from typing import TYPE_CHECKING, Optional, Tuple -import color -import exceptions +from game.color import descend, enemy_atk, player_atk +from game.entity import Actor +from game.exceptions import Impossible if TYPE_CHECKING: - from engine import Engine - from entity import Actor, Entity, Item + import game.components.inventory + import game.engine + import game.entity class Action: - def __init__(self, entity: Actor) -> None: + def __init__(self, entity: game.entity.Entity) -> None: super().__init__() self.entity = entity @property - def engine(self) -> Engine: + def engine(self) -> game.engine.Engine: """Return the engine this action belongs to.""" + assert self.entity.gamemap is not None, "Entity must be placed on a gamemap" return self.entity.gamemap.engine def perform(self) -> None: @@ -32,67 +35,9 @@ def perform(self) -> None: raise NotImplementedError() -class PickupAction(Action): - """Pickup an item and add it to the inventory, if there is room for it.""" - - def __init__(self, entity: Actor): - super().__init__(entity) - +class EscapeAction(Action): def perform(self) -> None: - actor_location_x = self.entity.x - actor_location_y = self.entity.y - inventory = self.entity.inventory - - for item in self.engine.game_map.items: - if actor_location_x == item.x and actor_location_y == item.y: - if len(inventory.items) >= inventory.capacity: - raise exceptions.Impossible("Your inventory is full.") - - self.engine.game_map.entities.remove(item) - item.parent = self.entity.inventory - inventory.items.append(item) - - self.engine.message_log.add_message(f"You picked up the {item.name}!") - return - - raise exceptions.Impossible("There is nothing here to pick up.") - - -class ItemAction(Action): - def __init__(self, entity: Actor, item: Item, target_xy: Optional[Tuple[int, int]] = None): - super().__init__(entity) - self.item = item - if not target_xy: - target_xy = entity.x, entity.y - self.target_xy = target_xy - - @property - def target_actor(self) -> Optional[Actor]: - """Return the actor at this actions destination.""" - return self.engine.game_map.get_actor_at_location(*self.target_xy) - - def perform(self) -> None: - """Invoke the items ability, this action will be given to provide context.""" - if self.item.consumable: - self.item.consumable.activate(self) - - -class DropItem(ItemAction): - def perform(self) -> None: - if self.entity.equipment.item_is_equipped(self.item): - self.entity.equipment.toggle_equip(self.item) - - self.entity.inventory.drop(self.item) - - -class EquipAction(Action): - def __init__(self, entity: Actor, item: Item): - super().__init__(entity) - - self.item = item - - def perform(self) -> None: - self.entity.equipment.toggle_equip(self.item) + raise SystemExit() class WaitAction(Action): @@ -100,39 +45,27 @@ def perform(self) -> None: pass -class TakeStairsAction(Action): - def perform(self) -> None: - """ - Take the stairs, if any exist at the entity's location. - """ - if (self.entity.x, self.entity.y) == self.engine.game_map.downstairs_location: - self.engine.game_world.generate_floor() - self.engine.message_log.add_message("You descend the staircase.", color.descend) - else: - raise exceptions.Impossible("There are no stairs here.") - - class ActionWithDirection(Action): - def __init__(self, entity: Actor, dx: int, dy: int): + def __init__(self, entity: game.entity.Entity, dx: int, dy: int): super().__init__(entity) self.dx = dx self.dy = dy @property - def dest_xy(self) -> Tuple[int, int]: + def dest_xy(self) -> tuple[int, int]: """Returns this actions destination.""" return self.entity.x + self.dx, self.entity.y + self.dy @property - def blocking_entity(self) -> Optional[Entity]: - """Return the blocking entity at this actions destination..""" - return self.engine.game_map.get_blocking_entity_at_location(*self.dest_xy) + def blocking_entity(self) -> Optional[game.entity.Entity]: + """Return the blocking entity at this actions destination.""" + return self.engine.game_map.get_blocking_entity_at(*self.dest_xy) @property - def target_actor(self) -> Optional[Actor]: + def target_actor(self) -> Optional[game.entity.Entity]: """Return the actor at this actions destination.""" - return self.engine.game_map.get_actor_at_location(*self.dest_xy) + return self.engine.game_map.get_blocking_entity_at(*self.dest_xy) def perform(self) -> None: raise NotImplementedError() @@ -142,15 +75,19 @@ class MeleeAction(ActionWithDirection): def perform(self) -> None: target = self.target_actor if not target: - raise exceptions.Impossible("Nothing to attack.") + return # No entity to attack. + + # Type checking to ensure both entities are Actors with fighter components + assert isinstance(self.entity, Actor), "Attacker must be an Actor" + assert isinstance(target, Actor), "Target must be an Actor" damage = self.entity.fighter.power - target.fighter.defense attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}" if self.entity is self.engine.player: - attack_color = color.player_atk + attack_color = player_atk else: - attack_color = color.enemy_atk + attack_color = enemy_atk if damage > 0: self.engine.message_log.add_message(f"{attack_desc} for {damage} hit points.", attack_color) @@ -164,14 +101,11 @@ def perform(self) -> None: dest_x, dest_y = self.dest_xy if not self.engine.game_map.in_bounds(dest_x, dest_y): - # Destination is out of bounds. - raise exceptions.Impossible("That way is blocked.") + return # Destination is out of bounds. if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]: - # Destination is blocked by a tile. - raise exceptions.Impossible("That way is blocked.") - if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y): - # Destination is blocked by an entity. - raise exceptions.Impossible("That way is blocked.") + return # Destination is blocked by a tile. + if self.engine.game_map.get_blocking_entity_at(dest_x, dest_y): + return # Destination is blocked by an entity. self.entity.move(self.dx, self.dy) @@ -180,6 +114,86 @@ class BumpAction(ActionWithDirection): def perform(self) -> None: if self.target_actor: return MeleeAction(self.entity, self.dx, self.dy).perform() - else: return MovementAction(self.entity, self.dx, self.dy).perform() + + +class PickupAction(Action): + """Pickup an item and add it to the inventory, if there is room for it.""" + + def __init__(self, entity: game.entity.Actor): + super().__init__(entity) + + def perform(self) -> None: + # Type check to ensure entity is an Actor with inventory + assert isinstance(self.entity, Actor), "Entity must be an Actor for inventory access" + + actor_location_x = self.entity.x + actor_location_y = self.entity.y + inventory = self.entity.inventory + + for item in self.engine.game_map.items: + if actor_location_x == item.x and actor_location_y == item.y: + if len(inventory.items) >= inventory.capacity: + raise Impossible("Your inventory is full.") + + self.engine.game_map.entities.remove(item) + item.parent = inventory + inventory.items.append(item) + + self.engine.message_log.add_message(f"You picked up the {item.name}!") + return + + raise Impossible("There is nothing here to pick up.") + + +class ItemAction(Action): + def __init__(self, entity: game.entity.Actor, item: game.entity.Item, target_xy: Optional[Tuple[int, int]] = None): + super().__init__(entity) + self.item = item + if not target_xy: + target_xy = entity.x, entity.y + self.target_xy = target_xy + + @property + def target_actor(self) -> Optional[game.entity.Actor]: + """Return the actor at this actions destination.""" + return self.engine.game_map.get_actor_at_location(*self.target_xy) + + def perform(self) -> None: + """Invoke the items ability, this action will be given to provide context.""" + if self.item.consumable is not None: + self.item.consumable.activate(self) + else: + raise Impossible("This item cannot be used.") + + +class DropItem(ItemAction): + def perform(self) -> None: + # Type check to ensure entity is an Actor with inventory + assert isinstance(self.entity, Actor), "Entity must be an Actor for inventory access" + self.entity.inventory.drop(self.item) + + +class TakeStairsAction(Action): + def perform(self) -> None: + """ + Take the stairs, if any exist at the entity's location. + """ + if (self.entity.x, self.entity.y) == self.engine.game_map.downstairs_location: + self.engine.game_world.generate_floor() + self.engine.message_log.add_message("You descend the staircase.", descend) + else: + raise game.exceptions.Impossible("There are no stairs here.") + + +class EquipAction(Action): + def __init__(self, entity: game.entity.Actor, item: game.entity.Item): + super().__init__(entity) + + self.item = item + + def perform(self) -> None: + # Type check to ensure entity is an Actor with equipment + assert isinstance(self.entity, game.entity.Actor), "Entity must be an Actor for equipment access" + self.entity.equipment.toggle_equip(self.item) diff --git a/color.py b/game/color.py similarity index 95% rename from color.py rename to game/color.py index 1bda85b..08fcb28 100644 --- a/color.py +++ b/game/color.py @@ -1,26 +1,28 @@ white = (0xFF, 0xFF, 0xFF) black = (0x0, 0x0, 0x0) -red = (0xFF, 0x0, 0x0) player_atk = (0xE0, 0xE0, 0xE0) enemy_atk = (0xFF, 0xC0, 0xC0) -needs_target = (0x3F, 0xFF, 0xFF) -status_effect_applied = (0x3F, 0xFF, 0x3F) -descend = (0x9F, 0x3F, 0xFF) - player_die = (0xFF, 0x30, 0x30) enemy_die = (0xFF, 0xA0, 0x30) -invalid = (0xFF, 0xFF, 0x00) impossible = (0x80, 0x80, 0x80) -error = (0xFF, 0x40, 0x40) +invalid = (0xFF, 0xFF, 0x00) welcome_text = (0x20, 0xA0, 0xFF) health_recovered = (0x0, 0xFF, 0x0) +needs_target = (0x3F, 0xFF, 0xFF) +status_effect_applied = (0x3F, 0xFF, 0x3F) + +descend = (0x9F, 0x3F, 0xFF) + bar_text = white bar_filled = (0x0, 0x60, 0x0) bar_empty = (0x40, 0x10, 0x10) menu_title = (255, 255, 63) menu_text = white + +red = (0xFF, 0x00, 0x00) +error = (0xFF, 0x40, 0x40) diff --git a/game/components/__init__.py b/game/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/ai.py b/game/components/ai.py similarity index 85% rename from components/ai.py rename to game/components/ai.py index 9a25d62..e039612 100644 --- a/components/ai.py +++ b/game/components/ai.py @@ -6,13 +6,15 @@ import numpy as np import tcod -from actions import Action, BumpAction, MeleeAction, MovementAction, WaitAction +from game.actions import Action, BumpAction, MeleeAction, MovementAction if TYPE_CHECKING: - from entity import Actor + import game.entity class BaseAI(Action): + entity: game.entity.Actor + def perform(self) -> None: raise NotImplementedError() @@ -22,10 +24,12 @@ def get_path_to(self, dest_x: int, dest_y: int) -> List[Tuple[int, int]]: If there is no valid path then returns an empty list. """ # Copy the walkable array. - cost = np.array(self.entity.gamemap.tiles["walkable"], dtype=np.int8) + gamemap = self.entity.gamemap + assert gamemap is not None + cost = np.array(gamemap.tiles["walkable"], dtype=np.int8) - for entity in self.entity.gamemap.entities: - # Check that an enitiy blocks movement and the cost isn't zero (blocking.) + for entity in gamemap.entities: + # Check that an entity blocks movement and the cost isn't zero (blocking.) if entity.blocks_movement and cost[entity.x, entity.y]: # Add to the cost of a blocked position. # A lower number means more enemies will crowd behind each other in @@ -47,7 +51,7 @@ def get_path_to(self, dest_x: int, dest_y: int) -> List[Tuple[int, int]]: class HostileEnemy(BaseAI): - def __init__(self, entity: Actor): + def __init__(self, entity: game.entity.Actor): super().__init__(entity) self.path: List[Tuple[int, int]] = [] @@ -71,8 +75,6 @@ def perform(self) -> None: dest_y - self.entity.y, ).perform() - return WaitAction(self.entity).perform() - class ConfusedEnemy(BaseAI): """ @@ -80,7 +82,7 @@ class ConfusedEnemy(BaseAI): If an actor occupies a tile it is randomly moving into, it will attack. """ - def __init__(self, entity: Actor, previous_ai: Optional[BaseAI], turns_remaining: int): + def __init__(self, entity: game.entity.Actor, previous_ai: Optional[BaseAI], turns_remaining: int): super().__init__(entity) self.previous_ai = previous_ai @@ -109,7 +111,7 @@ def perform(self) -> None: self.turns_remaining -= 1 # The actor will either try to move or attack in the chosen random direction. - # Its possible the actor will just bump into the wall, wasting a turn. + # It's possible the actor will just bump into the wall, wasting a turn. return BumpAction( self.entity, direction_x, diff --git a/game/components/base_component.py b/game/components/base_component.py new file mode 100644 index 0000000..3e535ed --- /dev/null +++ b/game/components/base_component.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import game.engine + import game.entity + import game.game_map + + +class BaseComponent: + parent: game.entity.Entity # Owning entity instance. + + @property + def gamemap(self) -> game.game_map.GameMap: + gamemap = self.parent.gamemap + assert gamemap is not None + return gamemap + + @property + def engine(self) -> game.engine.Engine: + return self.gamemap.engine diff --git a/components/consumable.py b/game/components/consumable.py similarity index 58% rename from components/consumable.py rename to game/components/consumable.py index 689947d..ef9777f 100644 --- a/components/consumable.py +++ b/game/components/consumable.py @@ -1,27 +1,28 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union -from components.base_component import BaseComponent -from exceptions import Impossible -from input_handlers import ActionOrHandler, AreaRangedAttackHandler, SingleRangedAttackHandler -import actions -import color -import components.ai -import components.inventory +import game.actions +import game.color +import game.components.ai +import game.components.base_component +import game.exceptions +import game.input_handlers if TYPE_CHECKING: - from entity import Actor, Item + import game.entity -class Consumable(BaseComponent): - parent: Item +class Consumable(game.components.base_component.BaseComponent): + parent: game.entity.Item - def get_action(self, consumer: Actor) -> Optional[ActionOrHandler]: + def get_action( + self, consumer: game.entity.Actor + ) -> Optional[Union[game.actions.Action, game.input_handlers.BaseEventHandler]]: """Try to return the action for this item.""" - return actions.ItemAction(consumer, self.parent) + return game.actions.ItemAction(consumer, self.parent) - def activate(self, action: actions.ItemAction) -> None: + def activate(self, action: game.actions.ItemAction) -> None: """Invoke this items ability. `action` is the context for this activation. @@ -32,37 +33,85 @@ def consume(self) -> None: """Remove the consumed item from its containing inventory.""" entity = self.parent inventory = entity.parent - if isinstance(inventory, components.inventory.Inventory): + if isinstance(inventory, game.components.inventory.Inventory): inventory.items.remove(entity) +class HealingConsumable(Consumable): + def __init__(self, amount: int): + self.amount = amount + + def activate(self, action: game.actions.ItemAction) -> None: + # Type check to ensure consumer is an Actor + assert isinstance(action.entity, game.entity.Actor), "Consumer must be an Actor" + consumer = action.entity + amount_recovered = consumer.fighter.heal(self.amount) + + if amount_recovered > 0: + self.engine.message_log.add_message( + f"You consume the {self.parent.name}, and recover {amount_recovered} HP!", + game.color.health_recovered, + ) + self.consume() + else: + raise game.exceptions.Impossible("Your health is already full.") + + +class LightningDamageConsumable(Consumable): + def __init__(self, damage: int, maximum_range: int): + self.damage = damage + self.maximum_range = maximum_range + + def activate(self, action: game.actions.ItemAction) -> None: + consumer = action.entity + target = None + closest_distance = self.maximum_range + 1.0 + + for actor in self.engine.game_map.actors: + if actor is not consumer and self.parent.gamemap.visible[actor.x, actor.y]: + distance = consumer.distance(actor.x, actor.y) + + if distance < closest_distance: + target = actor + closest_distance = distance + + if target: + self.engine.message_log.add_message( + f"A lighting bolt strikes the {target.name} with a loud thunder, for {self.damage} damage!" + ) + target.fighter.take_damage(self.damage) + self.consume() + else: + raise game.exceptions.Impossible("No enemy is close enough to strike.") + + class ConfusionConsumable(Consumable): def __init__(self, number_of_turns: int): self.number_of_turns = number_of_turns - def get_action(self, consumer: Actor) -> SingleRangedAttackHandler: - self.engine.message_log.add_message("Select a target location.", color.needs_target) - return SingleRangedAttackHandler( + def get_action(self, consumer: game.entity.Actor) -> Optional[game.input_handlers.ActionOrHandler]: + self.engine.message_log.add_message("Select a target location.", game.color.needs_target) + return game.input_handlers.SingleRangedAttackHandler( self.engine, - callback=lambda xy: actions.ItemAction(consumer, self.parent, xy), + callback=lambda xy: game.actions.ItemAction(consumer, self.parent, xy), ) - def activate(self, action: actions.ItemAction) -> None: + def activate(self, action: game.actions.ItemAction) -> None: consumer = action.entity target = action.target_actor if not self.engine.game_map.visible[action.target_xy]: - raise Impossible("You cannot target an area that you cannot see.") + raise game.exceptions.Impossible("You cannot target an area that you cannot see.") if not target: - raise Impossible("You must select an enemy to target.") + raise game.exceptions.Impossible("You must select an enemy to target.") if target is consumer: - raise Impossible("You cannot confuse yourself!") + raise game.exceptions.Impossible("You cannot confuse yourself!") self.engine.message_log.add_message( f"The eyes of the {target.name} look vacant, as it starts to stumble around!", - color.status_effect_applied, + game.color.status_effect_applied, ) - target.ai = components.ai.ConfusedEnemy( + target.ai = game.components.ai.ConfusedEnemy( entity=target, previous_ai=target.ai, turns_remaining=self.number_of_turns, @@ -75,19 +124,19 @@ def __init__(self, damage: int, radius: int): self.damage = damage self.radius = radius - def get_action(self, consumer: Actor) -> AreaRangedAttackHandler: - self.engine.message_log.add_message("Select a target location.", color.needs_target) - return AreaRangedAttackHandler( + def get_action(self, consumer: game.entity.Actor) -> Optional[game.input_handlers.ActionOrHandler]: + self.engine.message_log.add_message("Select a target location.", game.color.needs_target) + return game.input_handlers.AreaRangedAttackHandler( self.engine, radius=self.radius, - callback=lambda xy: actions.ItemAction(consumer, self.parent, xy), + callback=lambda xy: game.actions.ItemAction(consumer, self.parent, xy), ) - def activate(self, action: actions.ItemAction) -> None: + def activate(self, action: game.actions.ItemAction) -> None: target_xy = action.target_xy if not self.engine.game_map.visible[target_xy]: - raise Impossible("You cannot target an area that you cannot see.") + raise game.exceptions.Impossible("You cannot target an area that you cannot see.") targets_hit = False for actor in self.engine.game_map.actors: @@ -99,51 +148,5 @@ def activate(self, action: actions.ItemAction) -> None: targets_hit = True if not targets_hit: - raise Impossible("There are no targets in the radius.") + raise game.exceptions.Impossible("There are no targets in the radius.") self.consume() - - -class HealingConsumable(Consumable): - def __init__(self, amount: int): - self.amount = amount - - def activate(self, action: actions.ItemAction) -> None: - consumer = action.entity - amount_recovered = consumer.fighter.heal(self.amount) - - if amount_recovered > 0: - self.engine.message_log.add_message( - f"You consume the {self.parent.name}, and recover {amount_recovered} HP!", - color.health_recovered, - ) - self.consume() - else: - raise Impossible("Your health is already full.") - - -class LightningDamageConsumable(Consumable): - def __init__(self, damage: int, maximum_range: int): - self.damage = damage - self.maximum_range = maximum_range - - def activate(self, action: actions.ItemAction) -> None: - consumer = action.entity - target = None - closest_distance = self.maximum_range + 1.0 - - for actor in self.engine.game_map.actors: - if actor is not consumer and self.parent.gamemap.visible[actor.x, actor.y]: - distance = consumer.distance(actor.x, actor.y) - - if distance < closest_distance: - target = actor - closest_distance = distance - - if target: - self.engine.message_log.add_message( - f"A lighting bolt strikes the {target.name} with a loud thunder, for {self.damage} damage!" - ) - target.fighter.take_damage(self.damage) - self.consume() - else: - raise Impossible("No enemy is close enough to strike.") diff --git a/components/equipment.py b/game/components/equipment.py similarity index 66% rename from components/equipment.py rename to game/components/equipment.py index 465e17f..12ffc00 100644 --- a/components/equipment.py +++ b/game/components/equipment.py @@ -2,17 +2,17 @@ from typing import TYPE_CHECKING, Optional -from components.base_component import BaseComponent -from equipment_types import EquipmentType +import game.components.base_component +import game.equipment_types if TYPE_CHECKING: - from entity import Actor, Item + import game.entity -class Equipment(BaseComponent): - parent: Actor +class Equipment(game.components.base_component.BaseComponent): + parent: game.entity.Actor - def __init__(self, weapon: Optional[Item] = None, armor: Optional[Item] = None): + def __init__(self, weapon: Optional[game.entity.Item] = None, armor: Optional[game.entity.Item] = None): self.weapon = weapon self.armor = armor @@ -40,16 +40,16 @@ def power_bonus(self) -> int: return bonus - def item_is_equipped(self, item: Item) -> bool: + def item_is_equipped(self, item: game.entity.Item) -> bool: return self.weapon == item or self.armor == item def unequip_message(self, item_name: str) -> None: - self.parent.gamemap.engine.message_log.add_message(f"You remove the {item_name}.") + self.parent.parent.engine.message_log.add_message(f"You remove the {item_name}.") def equip_message(self, item_name: str) -> None: - self.parent.gamemap.engine.message_log.add_message(f"You equip the {item_name}.") + self.parent.parent.engine.message_log.add_message(f"You equip the {item_name}.") - def equip_to_slot(self, slot: str, item: Item, add_message: bool) -> None: + def equip_to_slot(self, slot: str, item: game.entity.Item, add_message: bool) -> None: current_item = getattr(self, slot) if current_item is not None: @@ -68,8 +68,11 @@ def unequip_from_slot(self, slot: str, add_message: bool) -> None: setattr(self, slot, None) - def toggle_equip(self, equippable_item: Item, add_message: bool = True) -> None: - if equippable_item.equippable and equippable_item.equippable.equipment_type == EquipmentType.WEAPON: + def toggle_equip(self, equippable_item: game.entity.Item, add_message: bool = True) -> None: + if ( + equippable_item.equippable + and equippable_item.equippable.equipment_type == game.equipment_types.EquipmentType.WEAPON + ): slot = "weapon" else: slot = "armor" diff --git a/game/components/equippable.py b/game/components/equippable.py new file mode 100644 index 0000000..75b4680 --- /dev/null +++ b/game/components/equippable.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import game.components.base_component +import game.equipment_types + +if TYPE_CHECKING: + import game.entity + + +class Equippable(game.components.base_component.BaseComponent): + parent: game.entity.Item + + def __init__( + self, + equipment_type: game.equipment_types.EquipmentType, + power_bonus: int = 0, + defense_bonus: int = 0, + ): + self.equipment_type = equipment_type + + self.power_bonus = power_bonus + self.defense_bonus = defense_bonus + + +class Dagger(Equippable): + def __init__(self) -> None: + super().__init__(equipment_type=game.equipment_types.EquipmentType.WEAPON, power_bonus=2) + + +class Sword(Equippable): + def __init__(self) -> None: + super().__init__(equipment_type=game.equipment_types.EquipmentType.WEAPON, power_bonus=4) + + +class LeatherArmor(Equippable): + def __init__(self) -> None: + super().__init__(equipment_type=game.equipment_types.EquipmentType.ARMOR, defense_bonus=1) + + +class ChainMail(Equippable): + def __init__(self) -> None: + super().__init__(equipment_type=game.equipment_types.EquipmentType.ARMOR, defense_bonus=3) diff --git a/components/fighter.py b/game/components/fighter.py similarity index 82% rename from components/fighter.py rename to game/components/fighter.py index f271c24..2380847 100644 --- a/components/fighter.py +++ b/game/components/fighter.py @@ -2,16 +2,17 @@ from typing import TYPE_CHECKING -from components.base_component import BaseComponent -from render_order import RenderOrder -import color +from game.color import enemy_die, player_die +from game.components.base_component import BaseComponent +from game.render_order import RenderOrder +import game.input_handlers if TYPE_CHECKING: - from entity import Actor + import game.entity class Fighter(BaseComponent): - parent: Actor + parent: game.entity.Actor def __init__(self, hp: int, base_defense: int, base_power: int): self.max_hp = hp @@ -54,10 +55,12 @@ def power_bonus(self) -> int: def die(self) -> None: if self.engine.player is self.parent: death_message = "You died!" - death_message_color = color.player_die + death_message_color = player_die + # Part 10 refactoring: Don't set event_handler here + # GameOverEventHandler will be returned in handle_action else: death_message = f"{self.parent.name} is dead!" - death_message_color = color.enemy_die + death_message_color = enemy_die self.parent.char = "%" self.parent.color = (191, 0, 0) diff --git a/components/inventory.py b/game/components/inventory.py similarity index 60% rename from components/inventory.py rename to game/components/inventory.py index 7e48762..a8e95b2 100644 --- a/components/inventory.py +++ b/game/components/inventory.py @@ -2,20 +2,25 @@ from typing import TYPE_CHECKING, List -from components.base_component import BaseComponent +from game.components.base_component import BaseComponent if TYPE_CHECKING: - from entity import Actor, Item + import game.entity + import game.game_map class Inventory(BaseComponent): - parent: Actor + parent: game.entity.Actor def __init__(self, capacity: int): self.capacity = capacity - self.items: List[Item] = [] + self.items: List[game.entity.Item] = [] - def drop(self, item: Item) -> None: + @property + def gamemap(self) -> game.game_map.GameMap: + return self.parent.gamemap + + def drop(self, item: game.entity.Item) -> None: """ Removes an item from the inventory and restores it to the game map, at the player's current location. """ diff --git a/components/level.py b/game/components/level.py similarity index 92% rename from components/level.py rename to game/components/level.py index 1b6ab38..8151a5e 100644 --- a/components/level.py +++ b/game/components/level.py @@ -1,15 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from components.base_component import BaseComponent - -if TYPE_CHECKING: - from entity import Actor +from game.components.base_component import BaseComponent +import game.entity class Level(BaseComponent): - parent: Actor + parent: game.entity.Actor def __init__( self, diff --git a/engine.py b/game/engine.py similarity index 58% rename from engine.py rename to game/engine.py index aad3e56..1e89016 100644 --- a/engine.py +++ b/game/engine.py @@ -4,38 +4,29 @@ import lzma import pickle -from tcod.console import Console -from tcod.map import compute_fov +import tcod -from message_log import MessageLog -import exceptions -import render_functions +import game.color +import game.entity +import game.message_log +import game.render_functions if TYPE_CHECKING: - from entity import Actor - from game_map import GameMap, GameWorld + import game.game_map class Engine: - game_map: GameMap - game_world: GameWorld + game_map: game.game_map.GameMap + game_world: game.game_map.GameWorld - def __init__(self, player: Actor): - self.message_log = MessageLog() - self.mouse_location = (0, 0) + def __init__(self, player: game.entity.Actor): self.player = player - - def handle_enemy_turns(self) -> None: - for entity in set(self.game_map.actors) - {self.player}: - if entity.ai: - try: - entity.ai.perform() - except exceptions.Impossible: - pass # Ignore impossible action exceptions from AI. + self.mouse_location = (0, 0) + self.message_log = game.message_log.MessageLog() def update_fov(self) -> None: """Recompute the visible area based on the players point of view.""" - self.game_map.visible[:] = compute_fov( + self.game_map.visible[:] = tcod.map.compute_fov( self.game_map.tiles["transparent"], (self.player.x, self.player.y), radius=8, @@ -43,25 +34,38 @@ def update_fov(self) -> None: # If a tile is "visible" it should be added to "explored". self.game_map.explored |= self.game_map.visible - def render(self, console: Console) -> None: + def handle_enemy_turns(self) -> None: + for entity in self.game_map.actors: + if entity is self.player: + continue + if entity.ai: + entity.ai.perform() + + def render(self, console: tcod.console.Console) -> None: self.game_map.render(console) self.message_log.render(console=console, x=21, y=45, width=40, height=5) - render_functions.render_bar( + game.render_functions.render_bar( console=console, current_value=self.player.fighter.hp, maximum_value=self.player.fighter.max_hp, total_width=20, ) - render_functions.render_dungeon_level( + game.render_functions.render_dungeon_level( console=console, dungeon_level=self.game_world.current_floor, location=(0, 47), ) - render_functions.render_names_at_mouse_location(console=console, x=21, y=44, engine=self) + console.print( + x=0, + y=46, + string=f"LVL: {self.player.level.current_level}", + ) + + game.render_functions.render_names_at_mouse_location(console=console, x=21, y=44, engine=self) def save_as(self, filename: str) -> None: """Save this Engine instance as a compressed file.""" diff --git a/entity.py b/game/entity.py similarity index 60% rename from entity.py rename to game/entity.py index b5af9cc..5530cf0 100644 --- a/entity.py +++ b/game/entity.py @@ -1,22 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Tuple, Type, TypeVar, Union -import copy -import math +from typing import TYPE_CHECKING, Optional, Tuple, Type, Union -from render_order import RenderOrder +from game.game_map import GameMap +from game.render_order import RenderOrder if TYPE_CHECKING: - from components.ai import BaseAI - from components.consumable import Consumable - from components.equipment import Equipment - from components.equippable import Equippable - from components.fighter import Fighter - from components.inventory import Inventory - from components.level import Level - from game_map import GameMap - -T = TypeVar("T", bound="Entity") + import game.components.ai + import game.components.consumable + import game.components.equipment + import game.components.equippable + import game.components.fighter + import game.components.inventory + import game.components.level + import game.game_map class Entity: @@ -24,18 +21,17 @@ class Entity: A generic object to represent players, enemies, items, etc. """ - parent: Union[GameMap, Inventory] + parent: Union[GameMap, game.components.inventory.Inventory] def __init__( self, - parent: Optional[GameMap] = None, + parent: Optional[Union[GameMap, game.components.inventory.Inventory]] = None, x: int = 0, y: int = 0, char: str = "?", color: Tuple[int, int, int] = (255, 255, 255), name: str = "", blocks_movement: bool = False, - render_order: RenderOrder = RenderOrder.CORPSE, ): self.x = x self.y = y @@ -43,47 +39,42 @@ def __init__( self.color = color self.name = name self.blocks_movement = blocks_movement - self.render_order = render_order + self.render_order = RenderOrder.CORPSE if parent: # If parent isn't provided now then it will be set later. self.parent = parent - parent.entities.add(self) + if hasattr(parent, "entities"): + parent.entities.add(self) @property def gamemap(self) -> GameMap: - return self.parent.gamemap - - def spawn(self: T, gamemap: GameMap, x: int, y: int) -> T: - """Spawn a copy of this instance at the given location.""" - clone = copy.deepcopy(self) - clone.x = x - clone.y = y - clone.parent = gamemap - gamemap.entities.add(clone) - return clone + if isinstance(self.parent, GameMap): + return self.parent + else: + return self.parent.gamemap def place(self, x: int, y: int, gamemap: Optional[GameMap] = None) -> None: - """Place this entitiy at a new location. Handles moving across GameMaps.""" + """Place this entity at a new location. Handles moving across GameMaps.""" self.x = x self.y = y if gamemap: if hasattr(self, "parent"): # Possibly uninitialized. - if self.parent is self.gamemap: - self.gamemap.entities.remove(self) + if hasattr(self.parent, "entities"): + self.parent.entities.remove(self) self.parent = gamemap gamemap.entities.add(self) - def distance(self, x: int, y: int) -> float: - """ - Return the distance between the current entity and the given (x, y) coordinate. - """ - return math.sqrt((x - self.x) ** 2 + (y - self.y) ** 2) - def move(self, dx: int, dy: int) -> None: # Move the entity by a given amount self.x += dx self.y += dy + def distance(self, x: int, y: int) -> float: + """ + Return the distance between the current entity and the given (x, y) coordinate. + """ + return float(((x - self.x) ** 2 + (y - self.y) ** 2) ** 0.5) + class Actor(Entity): def __init__( @@ -94,25 +85,25 @@ def __init__( char: str = "?", color: Tuple[int, int, int] = (255, 255, 255), name: str = "", - ai_cls: Type[BaseAI], - equipment: Equipment, - fighter: Fighter, - inventory: Inventory, - level: Level, + ai_cls: Type[game.components.ai.BaseAI], + equipment: game.components.equipment.Equipment, + fighter: game.components.fighter.Fighter, + inventory: game.components.inventory.Inventory, + level: game.components.level.Level, ): super().__init__( + parent=None, x=x, y=y, char=char, color=color, name=name, blocks_movement=True, - render_order=RenderOrder.ACTOR, ) - self.ai: Optional[BaseAI] = ai_cls(self) + self.ai: Optional[game.components.ai.BaseAI] = ai_cls(self) if ai_cls else None - self.equipment: Equipment = equipment + self.equipment = equipment self.equipment.parent = self self.fighter = fighter @@ -124,6 +115,8 @@ def __init__( self.level = level self.level.parent = self + self.render_order = RenderOrder.ACTOR + @property def is_alive(self) -> bool: """Returns True as long as this actor can perform actions.""" @@ -139,25 +132,25 @@ def __init__( char: str = "?", color: Tuple[int, int, int] = (255, 255, 255), name: str = "", - consumable: Optional[Consumable] = None, - equippable: Optional[Equippable] = None, + consumable: Optional[game.components.consumable.Consumable] = None, + equippable: Optional[game.components.equippable.Equippable] = None, ): super().__init__( + parent=None, x=x, y=y, char=char, color=color, name=name, blocks_movement=False, - render_order=RenderOrder.ITEM, ) self.consumable = consumable - if self.consumable: self.consumable.parent = self self.equippable = equippable - if self.equippable: self.equippable.parent = self + + self.render_order = RenderOrder.ITEM diff --git a/entity_factories.py b/game/entity_factories.py similarity index 50% rename from entity_factories.py rename to game/entity_factories.py index c380fc8..41fd98c 100644 --- a/entity_factories.py +++ b/game/entity_factories.py @@ -1,10 +1,16 @@ -from components import consumable, equippable -from components.ai import HostileEnemy -from components.equipment import Equipment -from components.fighter import Fighter -from components.inventory import Inventory -from components.level import Level -from entity import Actor, Item +from game.components.ai import HostileEnemy +from game.components.consumable import ( + ConfusionConsumable, + FireballDamageConsumable, + HealingConsumable, + LightningDamageConsumable, +) +from game.components.equipment import Equipment +from game.components.equippable import ChainMail, Dagger, LeatherArmor, Sword +from game.components.fighter import Fighter +from game.components.inventory import Inventory +from game.components.level import Level +from game.entity import Actor, Item player = Actor( char="@", @@ -12,7 +18,7 @@ name="Player", ai_cls=HostileEnemy, equipment=Equipment(), - fighter=Fighter(hp=30, base_defense=1, base_power=2), + fighter=Fighter(hp=30, base_defense=2, base_power=5), inventory=Inventory(capacity=26), level=Level(level_up_base=200), ) @@ -27,6 +33,7 @@ inventory=Inventory(capacity=0), level=Level(xp_given=35), ) + troll = Actor( char="T", color=(0, 127, 0), @@ -38,40 +45,58 @@ level=Level(xp_given=100), ) -confusion_scroll = Item( - char="~", - color=(207, 63, 255), - name="Confusion Scroll", - consumable=consumable.ConfusionConsumable(number_of_turns=10), -) -fireball_scroll = Item( - char="~", - color=(255, 0, 0), - name="Fireball Scroll", - consumable=consumable.FireballDamageConsumable(damage=12, radius=3), -) health_potion = Item( char="!", color=(127, 0, 255), name="Health Potion", - consumable=consumable.HealingConsumable(amount=4), + consumable=HealingConsumable(amount=4), ) + lightning_scroll = Item( char="~", color=(255, 255, 0), name="Lightning Scroll", - consumable=consumable.LightningDamageConsumable(damage=20, maximum_range=5), + consumable=LightningDamageConsumable(damage=20, maximum_range=5), +) + +confusion_scroll = Item( + char="~", + color=(207, 63, 255), + name="Confusion Scroll", + consumable=ConfusionConsumable(number_of_turns=10), ) -dagger = Item(char="/", color=(0, 191, 255), name="Dagger", equippable=equippable.Dagger()) +fireball_scroll = Item( + char="~", + color=(255, 0, 0), + name="Fireball Scroll", + consumable=FireballDamageConsumable(damage=12, radius=3), +) + +dagger = Item( + char="/", + color=(0, 191, 255), + name="Dagger", + equippable=Dagger(), +) -sword = Item(char="/", color=(0, 191, 255), name="Sword", equippable=equippable.Sword()) +sword = Item( + char="/", + color=(0, 191, 255), + name="Sword", + equippable=Sword(), +) leather_armor = Item( char="[", color=(139, 69, 19), name="Leather Armor", - equippable=equippable.LeatherArmor(), + equippable=LeatherArmor(), ) -chain_mail = Item(char="[", color=(139, 69, 19), name="Chain Mail", equippable=equippable.ChainMail()) +chain_mail = Item( + char="[", + color=(139, 69, 19), + name="Chain Mail", + equippable=ChainMail(), +) diff --git a/equipment_types.py b/game/equipment_types.py similarity index 100% rename from equipment_types.py rename to game/equipment_types.py diff --git a/exceptions.py b/game/exceptions.py similarity index 100% rename from exceptions.py rename to game/exceptions.py diff --git a/game_map.py b/game/game_map.py similarity index 67% rename from game_map.py rename to game/game_map.py index 1385aff..086708c 100644 --- a/game_map.py +++ b/game/game_map.py @@ -1,24 +1,25 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Iterator, Optional +from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Set -from tcod.console import Console import numpy as np +import tcod -from entity import Actor, Item -import tile_types +import game.tiles if TYPE_CHECKING: - from engine import Engine - from entity import Entity + import game.engine + import game.entity class GameMap: - def __init__(self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()): + def __init__( + self, engine: game.engine.Engine, width: int, height: int, entities: Iterable[game.entity.Entity] = () + ): self.engine = engine self.width, self.height = width, height - self.entities = set(entities) - self.tiles = np.full((width, height), fill_value=tile_types.wall, order="F") + self.entities: Set[game.entity.Entity] = set(entities) + self.tiles = np.full((width, height), fill_value=game.tiles.wall, order="F") self.visible = np.full((width, height), fill_value=False, order="F") # Tiles the player can currently see self.explored = np.full((width, height), fill_value=False, order="F") # Tiles the player has seen before @@ -27,29 +28,34 @@ def __init__(self, engine: Engine, width: int, height: int, entities: Iterable[E @property def gamemap(self) -> GameMap: + """Part 8 refactoring prep: self reference for parent system""" return self @property - def actors(self) -> Iterator[Actor]: + def actors(self) -> Iterator[game.entity.Actor]: """Iterate over this maps living actors.""" - yield from (entity for entity in self.entities if isinstance(entity, Actor) and entity.is_alive) + yield from (entity for entity in self.entities if isinstance(entity, game.entity.Actor) and entity.is_alive) @property - def items(self) -> Iterator[Item]: - yield from (entity for entity in self.entities if isinstance(entity, Item)) + def items(self) -> Iterator[game.entity.Item]: + yield from (entity for entity in self.entities if isinstance(entity, game.entity.Item)) def get_blocking_entity_at_location( self, location_x: int, location_y: int, - ) -> Optional[Entity]: + ) -> Optional[game.entity.Entity]: for entity in self.entities: if entity.blocks_movement and entity.x == location_x and entity.y == location_y: return entity return None - def get_actor_at_location(self, x: int, y: int) -> Optional[Actor]: + def get_blocking_entity_at(self, x: int, y: int) -> Optional[game.entity.Entity]: + """Alias for get_blocking_entity_at_location""" + return self.get_blocking_entity_at_location(x, y) + + def get_actor_at_location(self, x: int, y: int) -> Optional[game.entity.Actor]: for actor in self.actors: if actor.x == x and actor.y == y: return actor @@ -60,7 +66,7 @@ def in_bounds(self, x: int, y: int) -> bool: """Return True if x and y are inside of the bounds of this map.""" return 0 <= x < self.width and 0 <= y < self.height - def render(self, console: Console) -> None: + def render(self, console: tcod.console.Console) -> None: """ Renders the map. @@ -71,15 +77,25 @@ def render(self, console: Console) -> None: console.rgb[0 : self.width, 0 : self.height] = np.select( condlist=[self.visible, self.explored], choicelist=[self.tiles["light"], self.tiles["dark"]], - default=tile_types.SHROUD, + default=game.tiles.SHROUD, ) entities_sorted_for_rendering = sorted(self.entities, key=lambda x: x.render_order.value) for entity in entities_sorted_for_rendering: + # Only print entities that are in the FOV if self.visible[entity.x, entity.y]: console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color) + # Show stairs + if self.visible[self.downstairs_location]: + console.print( + x=self.downstairs_location[0], + y=self.downstairs_location[1], + string=">", + fg=(255, 255, 255), + ) + class GameWorld: """ @@ -89,7 +105,7 @@ class GameWorld: def __init__( self, *, - engine: Engine, + engine: game.engine.Engine, map_width: int, map_height: int, max_rooms: int, @@ -110,7 +126,7 @@ def __init__( self.current_floor = current_floor def generate_floor(self) -> None: - from procgen import generate_dungeon + from game.procgen import generate_dungeon self.current_floor += 1 @@ -120,5 +136,6 @@ def generate_floor(self) -> None: room_max_size=self.room_max_size, map_width=self.map_width, map_height=self.map_height, + current_floor=self.current_floor, engine=self.engine, ) diff --git a/input_handlers.py b/game/input_handlers.py similarity index 68% rename from input_handlers.py rename to game/input_handlers.py index fbd122a..0a1bbd8 100644 --- a/input_handlers.py +++ b/game/input_handlers.py @@ -1,62 +1,21 @@ from __future__ import annotations from typing import TYPE_CHECKING, Callable, Optional, Tuple, Union -import os -import tcod +from tcod import libtcodpy +import tcod.event -from actions import Action, BumpAction, PickupAction, WaitAction -import actions -import color -import exceptions +import game.actions +import game.color +import game.entity +import game.exceptions if TYPE_CHECKING: - from engine import Engine - from entity import Item + import game.engine + import game.entity - -MOVE_KEYS = { - # Arrow keys. - tcod.event.K_UP: (0, -1), - tcod.event.K_DOWN: (0, 1), - tcod.event.K_LEFT: (-1, 0), - tcod.event.K_RIGHT: (1, 0), - tcod.event.K_HOME: (-1, -1), - tcod.event.K_END: (-1, 1), - tcod.event.K_PAGEUP: (1, -1), - tcod.event.K_PAGEDOWN: (1, 1), - # Numpad keys. - tcod.event.K_KP_1: (-1, 1), - tcod.event.K_KP_2: (0, 1), - tcod.event.K_KP_3: (1, 1), - tcod.event.K_KP_4: (-1, 0), - tcod.event.K_KP_6: (1, 0), - tcod.event.K_KP_7: (-1, -1), - tcod.event.K_KP_8: (0, -1), - tcod.event.K_KP_9: (1, -1), - # Vi keys. - tcod.event.K_h: (-1, 0), - tcod.event.K_j: (0, 1), - tcod.event.K_k: (0, -1), - tcod.event.K_l: (1, 0), - tcod.event.K_y: (-1, -1), - tcod.event.K_u: (1, -1), - tcod.event.K_b: (-1, 1), - tcod.event.K_n: (1, 1), -} - -WAIT_KEYS = { - tcod.event.K_PERIOD, - tcod.event.K_KP_5, - tcod.event.K_CLEAR, -} - -CONFIRM_KEYS = { - tcod.event.K_RETURN, - tcod.event.K_KP_ENTER, -} - -ActionOrHandler = Union[Action, "BaseEventHandler"] +# Part 10 refactoring: ActionOrHandler type +ActionOrHandler = Union[game.actions.Action, "BaseEventHandler"] """An event handler return value which can trigger an action or switch active handlers. If a handler is returned then it will become the active handler for future events. @@ -65,51 +24,59 @@ """ +MOVE_KEYS = { + # Arrow keys. + tcod.event.KeySym.UP: (0, -1), + tcod.event.KeySym.DOWN: (0, 1), + tcod.event.KeySym.LEFT: (-1, 0), + tcod.event.KeySym.RIGHT: (1, 0), + tcod.event.KeySym.HOME: (-1, -1), + tcod.event.KeySym.END: (-1, 1), + tcod.event.KeySym.PAGEUP: (1, -1), + tcod.event.KeySym.PAGEDOWN: (1, 1), + # Numpad keys. + tcod.event.KeySym.KP_1: (-1, 1), + tcod.event.KeySym.KP_2: (0, 1), + tcod.event.KeySym.KP_3: (1, 1), + tcod.event.KeySym.KP_4: (-1, 0), + tcod.event.KeySym.KP_6: (1, 0), + tcod.event.KeySym.KP_7: (-1, -1), + tcod.event.KeySym.KP_8: (0, -1), + tcod.event.KeySym.KP_9: (1, -1), + # Vi keys. + tcod.event.KeySym.H: (-1, 0), + tcod.event.KeySym.J: (0, 1), + tcod.event.KeySym.K: (0, -1), + tcod.event.KeySym.L: (1, 0), + tcod.event.KeySym.Y: (-1, -1), + tcod.event.KeySym.U: (1, -1), + tcod.event.KeySym.B: (-1, 1), + tcod.event.KeySym.N: (1, 1), +} + + +# Part 10 refactoring: BaseEventHandler class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]): def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: """Handle an event and return the next active event handler.""" state = self.dispatch(event) if isinstance(state, BaseEventHandler): return state - assert not isinstance(state, Action), f"{self!r} can not handle actions." + assert not isinstance(state, game.actions.Action), f"{self!r} can not handle actions." return self - def on_render(self, console: tcod.Console) -> None: + def on_render(self, console: tcod.console.Console) -> None: raise NotImplementedError() - def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]: + def ev_quit(self, event: tcod.event.Quit) -> Optional[game.actions.Action]: raise SystemExit() - -class PopupMessage(BaseEventHandler): - """Display a popup text window.""" - - def __init__(self, parent_handler: BaseEventHandler, text: str): - self.parent = parent_handler - self.text = text - - def on_render(self, console: tcod.Console) -> None: - """Render the parent and dim the result, then print the message on top.""" - self.parent.on_render(console) - console.tiles_rgb["fg"] //= 8 - console.tiles_rgb["bg"] //= 8 - - console.print( - console.width // 2, - console.height // 2, - self.text, - fg=color.white, - bg=color.black, - alignment=tcod.CENTER, - ) - - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]: - """Any key returns to the parent handler.""" - return self.parent + def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: + pass class EventHandler(BaseEventHandler): - def __init__(self, engine: Engine): + def __init__(self, engine: game.engine.Engine): self.engine = engine def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: @@ -119,6 +86,8 @@ def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: return action_or_state if self.handle_action(action_or_state): # A valid action was performed. + # Type check to ensure player is an Actor before accessing is_alive + assert isinstance(self.engine.player, game.entity.Actor), "Player must be an Actor" if not self.engine.player.is_alive: # The player was killed sometime during or after the action. return GameOverEventHandler(self.engine) @@ -127,7 +96,7 @@ def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: return MainGameEventHandler(self.engine) # Return to the main handler. return self - def handle_action(self, action: Optional[Action]) -> bool: + def handle_action(self, action: Optional[game.actions.Action]) -> bool: """Handle actions returned from event methods. Returns True if the action will advance a turn. @@ -137,21 +106,109 @@ def handle_action(self, action: Optional[Action]) -> bool: try: action.perform() - except exceptions.Impossible as exc: - self.engine.message_log.add_message(exc.args[0], color.impossible) + except game.exceptions.Impossible as exc: + self.engine.message_log.add_message(exc.args[0], game.color.impossible) return False # Skip enemy turn on exceptions. self.engine.handle_enemy_turns() - - self.engine.update_fov() + self.engine.update_fov() # Update the FOV before the players next action. return True + def on_render(self, console: tcod.console.Console) -> None: + self.engine.render(console) + def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: - if self.engine.game_map.in_bounds(event.tile.x, event.tile.y): - self.engine.mouse_location = event.tile.x, event.tile.y + if self.engine.game_map.in_bounds(int(event.position.x), int(event.position.y)): + self.engine.mouse_location = int(event.position.x), int(event.position.y) - def on_render(self, console: tcod.Console) -> None: - self.engine.render(console) + +class MainGameEventHandler(EventHandler): + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: + action: Optional[game.actions.Action] = None + + key = event.sym + modifiers = event.mod + + player = self.engine.player + + if key in MOVE_KEYS: + dx, dy = MOVE_KEYS[key] + action = game.actions.BumpAction(player, dx, dy) + elif key == tcod.event.KeySym.ESCAPE: + action = game.actions.EscapeAction(player) + elif key == tcod.event.KeySym.V: + return HistoryViewer(self.engine) + elif key == tcod.event.KeySym.G: + action = game.actions.PickupAction(player) + elif key == tcod.event.KeySym.I: + return InventoryActivateHandler(self.engine) + elif key == tcod.event.KeySym.D: + return InventoryDropHandler(self.engine) + elif key == tcod.event.KeySym.C: + return CharacterScreenEventHandler(self.engine) + elif key == tcod.event.KeySym.SLASH: + return LookHandler(self.engine) + elif key == tcod.event.KeySym.PERIOD: + if modifiers & (tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT): + # Take stairs down if user presses '>' + return game.actions.TakeStairsAction(player) + else: + # Wait if user presses '.' + action = game.actions.WaitAction(player) + + # No valid key was pressed + return action + + +class GameOverEventHandler(EventHandler): + def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: + action_or_state = self.dispatch(event) + if isinstance(action_or_state, BaseEventHandler): + return action_or_state + return self # Keep this handler active + + +class HistoryViewer(EventHandler): + """Print the history on a larger window which can be navigated.""" + + def __init__(self, engine: game.engine.Engine): + super().__init__(engine) + self.log_length = len(engine.message_log.messages) + self.cursor = self.log_length - 1 + + def on_render(self, console: tcod.console.Console) -> None: + super().on_render(console) # Draw the main state as the background. + + log_console = tcod.console.Console(console.width - 6, console.height - 6) + + # Draw a frame with a custom banner title. + log_console.draw_frame(0, 0, log_console.width, log_console.height) + log_console.print_box(0, 0, log_console.width, 1, "┤Message history├", alignment=libtcodpy.CENTER) + + # Render the message log using the cursor parameter. + self.engine.message_log.render_messages( + log_console, + 1, + 1, + log_console.width - 2, + log_console.height - 2, + self.engine.message_log.messages[: self.cursor + 1], + ) + log_console.blit(console, 3, 3) + + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: + # Fancy conditional movement to make it feel right. + if event.sym in (tcod.event.KeySym.UP, tcod.event.KeySym.K): + self.cursor = max(0, self.cursor - 1) + elif event.sym in (tcod.event.KeySym.DOWN, tcod.event.KeySym.J): + self.cursor = min(self.log_length - 1, self.cursor + 1) + elif event.sym == tcod.event.KeySym.HOME: + self.cursor = 0 + elif event.sym == tcod.event.KeySym.END: + self.cursor = self.log_length - 1 + else: # Any other key moves back to the main game state. + return MainGameEventHandler(self.engine) + return None class AskUserEventHandler(EventHandler): @@ -160,15 +217,12 @@ class AskUserEventHandler(EventHandler): def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: """By default any key exits this input handler.""" if event.sym in { # Ignore modifier keys. - tcod.event.K_LSHIFT, - tcod.event.K_RSHIFT, - tcod.event.K_LCTRL, - tcod.event.K_RCTRL, - tcod.event.K_LALT, - tcod.event.K_RALT, - tcod.event.K_LGUI, - tcod.event.K_RGUI, - tcod.event.K_MODE, + tcod.event.KeySym.LSHIFT, + tcod.event.KeySym.RSHIFT, + tcod.event.KeySym.LCTRL, + tcod.event.KeySym.RCTRL, + tcod.event.KeySym.LALT, + tcod.event.KeySym.RALT, }: return None return self.on_exit() @@ -188,7 +242,7 @@ def on_exit(self) -> Optional[ActionOrHandler]: class CharacterScreenEventHandler(AskUserEventHandler): TITLE = "Character Information" - def on_render(self, console: tcod.Console) -> None: + def on_render(self, console: tcod.console.Console) -> None: super().on_render(console) if self.engine.player.x <= 30: @@ -223,73 +277,6 @@ def on_render(self, console: tcod.Console) -> None: console.print(x=x + 1, y=y + 5, string=f"Defense: {self.engine.player.fighter.defense}") -class LevelUpEventHandler(AskUserEventHandler): - TITLE = "Level Up" - - def on_render(self, console: tcod.Console) -> None: - super().on_render(console) - - if self.engine.player.x <= 30: - x = 40 - else: - x = 0 - - console.draw_frame( - x=x, - y=0, - width=35, - height=8, - title=self.TITLE, - clear=True, - fg=(255, 255, 255), - bg=(0, 0, 0), - ) - - console.print(x=x + 1, y=1, string="Congratulations! You level up!") - console.print(x=x + 1, y=2, string="Select an attribute to increase.") - - console.print( - x=x + 1, - y=4, - string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})", - ) - console.print( - x=x + 1, - y=5, - string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})", - ) - console.print( - x=x + 1, - y=6, - string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})", - ) - - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: - player = self.engine.player - key = event.sym - index = key - tcod.event.K_a - - if 0 <= index <= 2: - if index == 0: - player.level.increase_max_hp() - elif index == 1: - player.level.increase_power() - else: - player.level.increase_defense() - else: - self.engine.message_log.add_message("Invalid entry.", color.invalid) - - return None - - return super().ev_keydown(event) - - def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: - """ - Don't allow the player to click to exit the menu, like normal. - """ - return None - - class InventoryEventHandler(AskUserEventHandler): """This handler lets the user select an item. @@ -298,7 +285,7 @@ class InventoryEventHandler(AskUserEventHandler): TITLE = "" - def on_render(self, console: tcod.Console) -> None: + def on_render(self, console: tcod.console.Console) -> None: """Render an inventory menu, which displays the items in the inventory, and the letter to select them. Will move to a different position based on where the player is located, so the player can always see where they are. @@ -325,11 +312,11 @@ def on_render(self, console: tcod.Console) -> None: y=y, width=width, height=height, + title=self.TITLE, clear=True, fg=(255, 255, 255), bg=(0, 0, 0), ) - console.print(x + 1, y, f" {self.TITLE} ", fg=(0, 0, 0), bg=(255, 255, 255)) if number_of_items_in_inventory > 0: for i, item in enumerate(self.engine.player.inventory.items): @@ -349,18 +336,18 @@ def on_render(self, console: tcod.Console) -> None: def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: player = self.engine.player key = event.sym - index = key - tcod.event.K_a + index = key - tcod.event.KeySym.A if 0 <= index <= 26: try: selected_item = player.inventory.items[index] except IndexError: - self.engine.message_log.add_message("Invalid entry.", color.invalid) + self.engine.message_log.add_message("Invalid entry.", game.color.invalid) return None return self.on_item_selected(selected_item) return super().ev_keydown(event) - def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: + def on_item_selected(self, item: game.entity.Item) -> Optional[ActionOrHandler]: """Called when the user selects a valid item.""" raise NotImplementedError() @@ -370,13 +357,15 @@ class InventoryActivateHandler(InventoryEventHandler): TITLE = "Select an item to use" - def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: + def on_item_selected(self, item: game.entity.Item) -> Optional[ActionOrHandler]: + """Return the action for the selected item.""" if item.consumable: - # Return the action for the selected item. + # Try to return the action for this item. return item.consumable.get_action(self.engine.player) elif item.equippable: - return actions.EquipAction(self.engine.player, item) + return game.actions.EquipAction(self.engine.player, item) else: + self.engine.message_log.add_message("This item cannot be used.", game.color.invalid) return None @@ -385,26 +374,26 @@ class InventoryDropHandler(InventoryEventHandler): TITLE = "Select an item to drop" - def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: + def on_item_selected(self, item: game.entity.Item) -> Optional[ActionOrHandler]: """Drop this item.""" - return actions.DropItem(self.engine.player, item) + return game.actions.DropItem(self.engine.player, item) class SelectIndexHandler(AskUserEventHandler): """Handles asking the user for an index on the map.""" - def __init__(self, engine: Engine): + def __init__(self, engine: game.engine.Engine): """Sets the cursor to the player when this handler is constructed.""" super().__init__(engine) player = self.engine.player engine.mouse_location = player.x, player.y - def on_render(self, console: tcod.Console) -> None: + def on_render(self, console: tcod.console.Console) -> None: """Highlight the tile under the cursor.""" super().on_render(console) x, y = self.engine.mouse_location - console.tiles_rgb["bg"][x, y] = color.white - console.tiles_rgb["fg"][x, y] = color.black + console.rgb["bg"][x, y] = game.color.white + console.rgb["fg"][x, y] = game.color.black def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: """Check for key movement or confirmation keys.""" @@ -427,15 +416,16 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: y = max(0, min(y, self.engine.game_map.height - 1)) self.engine.mouse_location = x, y return None - elif key in CONFIRM_KEYS: + elif key in (tcod.event.KeySym.RETURN, tcod.event.KeySym.KP_ENTER): return self.on_index_selected(*self.engine.mouse_location) return super().ev_keydown(event) def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: """Left click confirms a selection.""" - if self.engine.game_map.in_bounds(*event.tile): + x, y = int(event.position.x), int(event.position.y) + if self.engine.game_map.in_bounds(x, y): if event.button == 1: - return self.on_index_selected(*event.tile) + return self.on_index_selected(x, y) return super().ev_mousebuttondown(event) def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]: @@ -454,12 +444,14 @@ def on_index_selected(self, x: int, y: int) -> MainGameEventHandler: class SingleRangedAttackHandler(SelectIndexHandler): """Handles targeting a single enemy. Only the enemy selected will be affected.""" - def __init__(self, engine: Engine, callback: Callable[[Tuple[int, int]], Optional[Action]]): + def __init__( + self, engine: game.engine.Engine, callback: Callable[[Tuple[int, int]], Optional[game.actions.Action]] + ): super().__init__(engine) self.callback = callback - def on_index_selected(self, x: int, y: int) -> Optional[Action]: + def on_index_selected(self, x: int, y: int) -> Optional[game.actions.Action]: return self.callback((x, y)) @@ -468,16 +460,16 @@ class AreaRangedAttackHandler(SelectIndexHandler): def __init__( self, - engine: Engine, + engine: game.engine.Engine, radius: int, - callback: Callable[[Tuple[int, int]], Optional[Action]], + callback: Callable[[Tuple[int, int]], Optional[game.actions.Action]], ): super().__init__(engine) self.radius = radius self.callback = callback - def on_render(self, console: tcod.Console) -> None: + def on_render(self, console: tcod.console.Console) -> None: """Highlight the tile under the cursor.""" super().on_render(console) @@ -487,123 +479,105 @@ def on_render(self, console: tcod.Console) -> None: console.draw_frame( x=x - self.radius - 1, y=y - self.radius - 1, - width=self.radius ** 2, - height=self.radius ** 2, - fg=color.red, + width=self.radius**2, + height=self.radius**2, + fg=game.color.red, clear=False, ) - def on_index_selected(self, x: int, y: int) -> Optional[Action]: + def on_index_selected(self, x: int, y: int) -> Optional[game.actions.Action]: return self.callback((x, y)) -class MainGameEventHandler(EventHandler): - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: - action: Optional[Action] = None - - key = event.sym - modifier = event.mod - - player = self.engine.player - - if key == tcod.event.K_PERIOD and modifier & (tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT): - return actions.TakeStairsAction(player) - - if key in MOVE_KEYS: - dx, dy = MOVE_KEYS[key] - action = BumpAction(player, dx, dy) - elif key in WAIT_KEYS: - action = WaitAction(player) - - elif key == tcod.event.K_ESCAPE: - raise SystemExit() - elif key == tcod.event.K_v: - return HistoryViewer(self.engine) - - elif key == tcod.event.K_g: - action = PickupAction(player) +class PopupMessage(BaseEventHandler): + """Display a popup text window.""" - elif key == tcod.event.K_i: - return InventoryActivateHandler(self.engine) - elif key == tcod.event.K_d: - return InventoryDropHandler(self.engine) - elif key == tcod.event.K_c: - return CharacterScreenEventHandler(self.engine) - elif key == tcod.event.K_SLASH: - return LookHandler(self.engine) + def __init__(self, parent_handler: BaseEventHandler, text: str): + self.parent = parent_handler + self.text = text - # No valid key was pressed - return action + def on_render(self, console: tcod.console.Console) -> None: + """Render the parent and dim the result, then print the message on top.""" + self.parent.on_render(console) + console.rgb["fg"] //= 8 + console.rgb["bg"] //= 8 + console.print( + console.width // 2, + console.height // 2, + self.text, + fg=game.color.white, + bg=game.color.black, + alignment=tcod.CENTER, + ) -class GameOverEventHandler(EventHandler): - def on_quit(self) -> None: - """Handle exiting out of a finished game.""" - if os.path.exists("savegame.sav"): - os.remove("savegame.sav") # Deletes the active save file. - raise exceptions.QuitWithoutSaving() # Avoid saving a finished game. + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]: + """Any key returns to the parent handler.""" + return self.parent - def ev_quit(self, event: tcod.event.Quit) -> None: - self.on_quit() - def ev_keydown(self, event: tcod.event.KeyDown) -> None: - if event.sym == tcod.event.K_ESCAPE: - self.on_quit() +class LevelUpEventHandler(AskUserEventHandler): + TITLE = "Level Up" + def on_render(self, console: tcod.console.Console) -> None: + super().on_render(console) -CURSOR_Y_KEYS = { - tcod.event.K_UP: -1, - tcod.event.K_DOWN: 1, - tcod.event.K_PAGEUP: -10, - tcod.event.K_PAGEDOWN: 10, -} + if self.engine.player.x <= 30: + x = 40 + else: + x = 0 + console.draw_frame( + x=x, + y=0, + width=35, + height=8, + title=self.TITLE, + clear=True, + fg=(255, 255, 255), + bg=(0, 0, 0), + ) -class HistoryViewer(EventHandler): - """Print the history on a larger window which can be navigated.""" + console.print(x=x + 1, y=1, string="Congratulations! You level up!") + console.print(x=x + 1, y=2, string="Select an attribute to increase.") - def __init__(self, engine: Engine): - super().__init__(engine) - self.log_length = len(engine.message_log.messages) - self.cursor = self.log_length - 1 + console.print( + x=x + 1, + y=4, + string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})", + ) + console.print( + x=x + 1, + y=5, + string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})", + ) + console.print( + x=x + 1, + y=6, + string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})", + ) - def on_render(self, console: tcod.Console) -> None: - super().on_render(console) # Draw the main state as the background. + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: + player = self.engine.player + key = event.sym + index = key - tcod.event.KeySym.A - log_console = tcod.Console(console.width - 6, console.height - 6) + if 0 <= index <= 2: + if index == 0: + player.level.increase_max_hp() + elif index == 1: + player.level.increase_power() + else: + player.level.increase_defense() + else: + self.engine.message_log.add_message("Invalid entry.", game.color.invalid) - # Draw a frame with a custom banner title. - log_console.draw_frame(0, 0, log_console.width, log_console.height) - log_console.print_box(0, 0, log_console.width, 1, "┤Message history├", alignment=tcod.CENTER) + return None - # Render the message log using the cursor parameter. - self.engine.message_log.render_messages( - log_console, - 1, - 1, - log_console.width - 2, - log_console.height - 2, - self.engine.message_log.messages[: self.cursor + 1], - ) - log_console.blit(console, 3, 3) + return super().ev_keydown(event) - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[MainGameEventHandler]: - # Fancy conditional movement to make it feel right. - if event.sym in CURSOR_Y_KEYS: - adjust = CURSOR_Y_KEYS[event.sym] - if adjust < 0 and self.cursor == 0: - # Only move from the top to the bottom when you're on the edge. - self.cursor = self.log_length - 1 - elif adjust > 0 and self.cursor == self.log_length - 1: - # Same with bottom to top movement. - self.cursor = 0 - else: - # Otherwise move while staying clamped to the bounds of the history log. - self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1)) - elif event.sym == tcod.event.K_HOME: - self.cursor = 0 # Move directly to the top message. - elif event.sym == tcod.event.K_END: - self.cursor = self.log_length - 1 # Move directly to the last message. - else: # Any other key moves back to the main game state. - return MainGameEventHandler(self.engine) + def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: + """ + Don't allow the player to click to exit the menu, like normal. + """ return None diff --git a/message_log.py b/game/message_log.py similarity index 72% rename from message_log.py rename to game/message_log.py index e24d8ef..9266ff2 100644 --- a/message_log.py +++ b/game/message_log.py @@ -1,9 +1,9 @@ -from typing import Iterable, List, Reversible, Tuple +from typing import Generator, List, Reversible, Tuple import textwrap import tcod -import color +from game.color import white class Message: @@ -24,7 +24,13 @@ class MessageLog: def __init__(self) -> None: self.messages: List[Message] = [] - def add_message(self, text: str, fg: Tuple[int, int, int] = color.white, *, stack: bool = True) -> None: + def add_message( + self, + text: str, + fg: Tuple[int, int, int] = white, + *, + stack: bool = True, + ) -> None: """Add a message to this log. `text` is the message text, `fg` is the text color. @@ -37,7 +43,14 @@ def add_message(self, text: str, fg: Tuple[int, int, int] = color.white, *, stac else: self.messages.append(Message(text, fg)) - def render(self, console: tcod.Console, x: int, y: int, width: int, height: int) -> None: + def render( + self, + console: tcod.console.Console, + x: int, + y: int, + width: int, + height: int, + ) -> None: """Render this log over the given area. `x`, `y`, `width`, `height` is the rectangular region to render onto @@ -46,8 +59,11 @@ def render(self, console: tcod.Console, x: int, y: int, width: int, height: int) self.render_messages(console, x, y, width, height, self.messages) @staticmethod - def wrap(string: str, width: int) -> Iterable[str]: - """Return a wrapped text message.""" + def wrap(string: str, width: int) -> Generator[str, None, None]: + """Return a wrapped text message. + + Part 8 refactoring: Made public method instead of private. + """ for line in string.splitlines(): # Handle newlines in messages. yield from textwrap.wrap( line, @@ -57,7 +73,13 @@ def wrap(string: str, width: int) -> Iterable[str]: @classmethod def render_messages( - cls, console: tcod.Console, x: int, y: int, width: int, height: int, messages: Reversible[Message] + cls, + console: tcod.console.Console, + x: int, + y: int, + width: int, + height: int, + messages: Reversible[Message], ) -> None: """Render the messages provided. diff --git a/procgen.py b/game/procgen.py similarity index 70% rename from procgen.py rename to game/procgen.py index 3b9fdc7..b99de4d 100644 --- a/procgen.py +++ b/game/procgen.py @@ -1,17 +1,18 @@ from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterator, List, Tuple +import copy import random import tcod -from game_map import GameMap -import entity_factories -import tile_types +import game.entity_factories +import game.tiles if TYPE_CHECKING: - from engine import Engine - from entity import Entity + import game.engine + import game.entity + import game.game_map max_items_by_floor = [ @@ -25,25 +26,25 @@ (6, 5), ] -item_chances: Dict[int, List[Tuple[Entity, int]]] = { - 0: [(entity_factories.health_potion, 35)], - 2: [(entity_factories.confusion_scroll, 10)], - 4: [(entity_factories.lightning_scroll, 25), (entity_factories.sword, 5)], - 6: [(entity_factories.fireball_scroll, 25), (entity_factories.chain_mail, 15)], +item_chances: Dict[int, List[Tuple[game.entity.Entity, int]]] = { + 0: [(game.entity_factories.health_potion, 35)], + 2: [(game.entity_factories.confusion_scroll, 10)], + 4: [(game.entity_factories.lightning_scroll, 25), (game.entity_factories.sword, 5)], + 6: [(game.entity_factories.fireball_scroll, 25), (game.entity_factories.chain_mail, 15)], } -enemy_chances: Dict[int, List[Tuple[Entity, int]]] = { - 0: [(entity_factories.orc, 80)], - 3: [(entity_factories.troll, 15)], - 5: [(entity_factories.troll, 30)], - 7: [(entity_factories.troll, 60)], +enemy_chances: Dict[int, List[Tuple[game.entity.Entity, int]]] = { + 0: [(game.entity_factories.orc, 80)], + 3: [(game.entity_factories.troll, 15)], + 5: [(game.entity_factories.troll, 30)], + 7: [(game.entity_factories.troll, 60)], } -def get_max_value_for_floor(max_value_by_floor: List[Tuple[int, int]], floor: int) -> int: +def get_max_value_for_floor(weighted_chances_by_floor: List[Tuple[int, int]], floor: int) -> int: current_value = 0 - for floor_minimum, value in max_value_by_floor: + for floor_minimum, value in weighted_chances_by_floor: if floor_minimum > floor: break else: @@ -53,10 +54,10 @@ def get_max_value_for_floor(max_value_by_floor: List[Tuple[int, int]], floor: in def get_entities_at_random( - weighted_chances_by_floor: Dict[int, List[Tuple[Entity, int]]], + weighted_chances_by_floor: Dict[int, List[Tuple[game.entity.Entity, int]]], number_of_entities: int, floor: int, -) -> List[Entity]: +) -> List[game.entity.Entity]: entity_weighted_chances = {} for key, values in weighted_chances_by_floor.items(): @@ -101,21 +102,6 @@ def intersects(self, other: RectangularRoom) -> bool: return self.x1 <= other.x2 and self.x2 >= other.x1 and self.y1 <= other.y2 and self.y2 >= other.y1 -def place_entities(room: RectangularRoom, dungeon: GameMap, floor_number: int) -> None: - number_of_monsters = random.randint(0, get_max_value_for_floor(max_monsters_by_floor, floor_number)) - number_of_items = random.randint(0, get_max_value_for_floor(max_items_by_floor, floor_number)) - - monsters: List[Entity] = get_entities_at_random(enemy_chances, number_of_monsters, floor_number) - items: List[Entity] = get_entities_at_random(item_chances, number_of_items, floor_number) - - for entity in monsters + items: - x = random.randint(room.x1 + 1, room.x2 - 1) - y = random.randint(room.y1 + 1, room.y2 - 1) - - if not any(entity.x == x and entity.y == y for entity in dungeon.entities): - entity.spawn(dungeon, x, y) - - def tunnel_between(start: Tuple[int, int], end: Tuple[int, int]) -> Iterator[Tuple[int, int]]: """Return an L-shaped tunnel between these two points.""" x1, y1 = start @@ -134,22 +120,41 @@ def tunnel_between(start: Tuple[int, int], end: Tuple[int, int]) -> Iterator[Tup yield x, y +def place_entities( + room: RectangularRoom, + dungeon: game.game_map.GameMap, + floor_number: int, +) -> None: + number_of_monsters = random.randint(0, get_max_value_for_floor(max_monsters_by_floor, floor_number)) + number_of_items = random.randint(0, get_max_value_for_floor(max_items_by_floor, floor_number)) + + monsters: List[game.entity.Entity] = get_entities_at_random(enemy_chances, number_of_monsters, floor_number) + items: List[game.entity.Entity] = get_entities_at_random(item_chances, number_of_items, floor_number) + + for entity in monsters + items: + x = random.randint(room.x1 + 1, room.x2 - 1) + y = random.randint(room.y1 + 1, room.y2 - 1) + + if not any(entity.x == x and entity.y == y for entity in dungeon.entities): + entity_copy = copy.deepcopy(entity) + entity_copy.place(x, y, dungeon) + + def generate_dungeon( max_rooms: int, room_min_size: int, room_max_size: int, map_width: int, map_height: int, - engine: Engine, -) -> GameMap: + current_floor: int, + engine: game.engine.Engine, +) -> game.game_map.GameMap: """Generate a new dungeon map.""" player = engine.player - dungeon = GameMap(engine, map_width, map_height, entities=[player]) + dungeon = game.game_map.GameMap(engine, map_width, map_height) rooms: List[RectangularRoom] = [] - center_of_last_room = (0, 0) - for _ in range(max_rooms): room_width = random.randint(room_min_size, room_max_size) room_height = random.randint(room_min_size, room_max_size) @@ -166,7 +171,7 @@ def generate_dungeon( # If there are no intersections then the room is valid. # Dig out this rooms inner area. - dungeon.tiles[new_room.inner] = tile_types.floor + dungeon.tiles[new_room.inner] = game.tiles.floor if len(rooms) == 0: # The first room, where the player starts. @@ -174,16 +179,14 @@ def generate_dungeon( else: # All rooms after the first. # Dig out a tunnel between this room and the previous one. for x, y in tunnel_between(rooms[-1].center, new_room.center): - dungeon.tiles[x, y] = tile_types.floor + dungeon.tiles[x, y] = game.tiles.floor - center_of_last_room = new_room.center - - place_entities(new_room, dungeon, engine.game_world.current_floor) - - dungeon.tiles[center_of_last_room] = tile_types.down_stairs - dungeon.downstairs_location = center_of_last_room + place_entities(new_room, dungeon, current_floor) # Finally, append the new room to the list. rooms.append(new_room) + # Add stairs going down + dungeon.downstairs_location = rooms[-1].center + return dungeon diff --git a/render_functions.py b/game/render_functions.py similarity index 57% rename from render_functions.py rename to game/render_functions.py index 8c59c5b..8162614 100644 --- a/render_functions.py +++ b/game/render_functions.py @@ -2,16 +2,16 @@ from typing import TYPE_CHECKING, Tuple -import color +import tcod -if TYPE_CHECKING: - from tcod import Console +from game.color import bar_empty, bar_filled, bar_text - from engine import Engine - from game_map import GameMap +if TYPE_CHECKING: + import game.engine + import game.game_map -def get_names_at_location(x: int, y: int, game_map: GameMap) -> str: +def get_names_at_location(x: int, y: int, game_map: game.game_map.GameMap) -> str: if not game_map.in_bounds(x, y) or not game_map.visible[x, y]: return "" @@ -20,29 +20,34 @@ def get_names_at_location(x: int, y: int, game_map: GameMap) -> str: return names.capitalize() -def render_bar(console: Console, current_value: int, maximum_value: int, total_width: int) -> None: +def render_bar( + console: tcod.console.Console, + current_value: int, + maximum_value: int, + total_width: int, +) -> None: bar_width = int(float(current_value) / maximum_value * total_width) - console.draw_rect(x=0, y=45, width=20, height=1, ch=1, bg=color.bar_empty) + console.draw_rect(x=0, y=45, width=20, height=1, ch=1, bg=bar_empty) if bar_width > 0: - console.draw_rect(x=0, y=45, width=bar_width, height=1, ch=1, bg=color.bar_filled) + console.draw_rect(x=0, y=45, width=bar_width, height=1, ch=1, bg=bar_filled) + + console.print(x=1, y=45, string=f"HP: {current_value}/{maximum_value}", fg=bar_text) + + +def render_names_at_mouse_location(console: tcod.console.Console, x: int, y: int, engine: game.engine.Engine) -> None: + mouse_x, mouse_y = engine.mouse_location + + names_at_mouse_location = get_names_at_location(x=mouse_x, y=mouse_y, game_map=engine.game_map) - console.print(x=1, y=45, string=f"HP: {current_value}/{maximum_value}", fg=color.bar_text) + console.print(x=x, y=y, string=names_at_mouse_location) -def render_dungeon_level(console: Console, dungeon_level: int, location: Tuple[int, int]) -> None: +def render_dungeon_level(console: tcod.console.Console, dungeon_level: int, location: Tuple[int, int]) -> None: """ Render the level the player is currently on, at the given location. """ x, y = location console.print(x=x, y=y, string=f"Dungeon level: {dungeon_level}") - - -def render_names_at_mouse_location(console: Console, x: int, y: int, engine: Engine) -> None: - mouse_x, mouse_y = engine.mouse_location - - names_at_mouse_location = get_names_at_location(x=mouse_x, y=mouse_y, game_map=engine.game_map) - - console.print(x=x, y=y, string=names_at_mouse_location) diff --git a/render_order.py b/game/render_order.py similarity index 100% rename from render_order.py rename to game/render_order.py diff --git a/setup_game.py b/game/setup_game.py similarity index 54% rename from setup_game.py rename to game/setup_game.py index 0716ec4..92cee8e 100644 --- a/setup_game.py +++ b/game/setup_game.py @@ -1,4 +1,5 @@ """Handle the loading and initialization of game sessions.""" + from __future__ import annotations from typing import Optional @@ -7,20 +8,23 @@ import pickle import traceback -from PIL import Image # type: ignore +from PIL import Image +from tcod import libtcodpy +import numpy as np import tcod -from engine import Engine -from game_map import GameWorld -import color -import entity_factories -import input_handlers +import game.color +import game.engine +import game.entity_factories +import game.game_map +import game.input_handlers +import game.procgen -# Load the background image. Pillow returns an object convertable into a NumPy array. -background_image = Image.open("data/menu_background.png") +# Load the background image and remove the alpha channel. +background_image = np.array(Image.open("data/menu_background.png").convert("RGB")) -def new_game() -> Engine: +def new_game() -> game.engine.Engine: """Return a brand new game session as an Engine instance.""" map_width = 80 map_height = 43 @@ -29,26 +33,12 @@ def new_game() -> Engine: room_min_size = 6 max_rooms = 30 - player = copy.deepcopy(entity_factories.player) - - engine = Engine(player=player) - - engine.game_world = GameWorld( - engine=engine, - max_rooms=max_rooms, - room_min_size=room_min_size, - room_max_size=room_max_size, - map_width=map_width, - map_height=map_height, - ) + player = copy.deepcopy(game.entity_factories.player) - engine.game_world.generate_floor() - engine.update_fov() + engine = game.engine.Engine(player=player) - engine.message_log.add_message("Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text) - - dagger = copy.deepcopy(entity_factories.dagger) - leather_armor = copy.deepcopy(entity_factories.leather_armor) + dagger = copy.deepcopy(game.entity_factories.dagger) + leather_armor = copy.deepcopy(game.entity_factories.leather_armor) dagger.parent = player.inventory leather_armor.parent = player.inventory @@ -59,21 +49,33 @@ def new_game() -> Engine: player.inventory.items.append(leather_armor) player.equipment.toggle_equip(leather_armor, add_message=False) + engine.game_world = game.game_map.GameWorld( + engine=engine, + max_rooms=max_rooms, + room_min_size=room_min_size, + room_max_size=room_max_size, + map_width=map_width, + map_height=map_height, + ) + engine.game_world.generate_floor() + engine.update_fov() + + engine.message_log.add_message("Hello and welcome, adventurer, to yet another dungeon!", game.color.welcome_text) return engine -def load_game(filename: str) -> Engine: +def load_game(filename: str) -> game.engine.Engine: """Load an Engine instance from a file.""" with open(filename, "rb") as f: engine = pickle.loads(lzma.decompress(f.read())) - assert isinstance(engine, Engine) + assert isinstance(engine, game.engine.Engine) return engine -class MainMenu(input_handlers.BaseEventHandler): +class MainMenu(game.input_handlers.BaseEventHandler): """Handle the main menu rendering and input.""" - def on_render(self, console: tcod.Console) -> None: + def on_render(self, console: tcod.console.Console) -> None: """Render the main menu on a background image.""" console.draw_semigraphics(background_image, 0, 0) @@ -81,15 +83,15 @@ def on_render(self, console: tcod.Console) -> None: console.width // 2, console.height // 2 - 4, "TOMBS OF THE ANCIENT KINGS", - fg=color.menu_title, - alignment=tcod.CENTER, + fg=game.color.menu_title, + alignment=libtcodpy.CENTER, ) console.print( console.width // 2, console.height - 2, "By (Your name here)", - fg=color.menu_title, - alignment=tcod.CENTER, + fg=game.color.menu_title, + alignment=libtcodpy.CENTER, ) menu_width = 24 @@ -98,24 +100,24 @@ def on_render(self, console: tcod.Console) -> None: console.width // 2, console.height // 2 - 2 + i, text.ljust(menu_width), - fg=color.menu_text, - bg=color.black, - alignment=tcod.CENTER, - bg_blend=tcod.BKGND_ALPHA(64), + fg=game.color.menu_text, + bg=game.color.black, + alignment=libtcodpy.CENTER, + bg_blend=libtcodpy.BKGND_ALPHA(64), ) - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[input_handlers.BaseEventHandler]: - if event.sym in (tcod.event.K_q, tcod.event.K_ESCAPE): + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[game.input_handlers.BaseEventHandler]: + if event.sym in (tcod.event.KeySym.Q, tcod.event.KeySym.ESCAPE): raise SystemExit() - elif event.sym == tcod.event.K_c: + elif event.sym == tcod.event.KeySym.C: try: - return input_handlers.MainGameEventHandler(load_game("savegame.sav")) + return game.input_handlers.MainGameEventHandler(load_game("savegame.sav")) except FileNotFoundError: - return input_handlers.PopupMessage(self, "No saved game to load.") + return game.input_handlers.PopupMessage(self, "No saved game to load.") except Exception as exc: traceback.print_exc() # Print to stderr. - return input_handlers.PopupMessage(self, f"Failed to load save:\n{exc}") - elif event.sym == tcod.event.K_n: - return input_handlers.MainGameEventHandler(new_game()) + return game.input_handlers.PopupMessage(self, f"Failed to load save:\n{exc}") + elif event.sym == tcod.event.KeySym.N: + return game.input_handlers.MainGameEventHandler(new_game()) return None diff --git a/tile_types.py b/game/tiles.py similarity index 88% rename from tile_types.py rename to game/tiles.py index 30475cc..e823828 100644 --- a/tile_types.py +++ b/game/tiles.py @@ -1,5 +1,6 @@ from typing import Tuple +from numpy.typing import NDArray import numpy as np # Tile graphics structured type compatible with Console.tiles_rgb. @@ -28,7 +29,7 @@ def new_tile( transparent: int, dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], -) -> np.ndarray: +) -> NDArray[np.void]: """Helper function for defining individual tile types""" return np.array((walkable, transparent, dark, light), dtype=tile_dt) @@ -48,9 +49,3 @@ def new_tile( dark=(ord(" "), (255, 255, 255), (0, 0, 100)), light=(ord(" "), (255, 255, 255), (130, 110, 50)), ) -down_stairs = new_tile( - walkable=True, - transparent=True, - dark=(ord(">"), (0, 0, 100), (50, 50, 150)), - light=(ord(">"), (255, 255, 255), (200, 180, 50)), -) diff --git a/main.py b/main.py index 1c5a323..d497b5c 100755 --- a/main.py +++ b/main.py @@ -3,15 +3,18 @@ import tcod -import color -import exceptions -import input_handlers -import setup_game +from game.engine import Engine +from game.entity import Entity +from game.exceptions import QuitWithoutSaving +from game.input_handlers import BaseEventHandler, MainGameEventHandler +from game.procgen import generate_dungeon +from game.setup_game import MainMenu +import game.entity_factories -def save_game(handler: input_handlers.BaseEventHandler, filename: str) -> None: +def save_game(handler: game.input_handlers.BaseEventHandler, filename: str) -> None: """If the current event handler has an active Engine then save it.""" - if isinstance(handler, input_handlers.EventHandler): + if isinstance(handler, game.input_handlers.EventHandler): handler.engine.save_as(filename) print("Game saved.") @@ -22,7 +25,7 @@ def main() -> None: tileset = tcod.tileset.load_tilesheet("data/dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD) - handler: input_handlers.BaseEventHandler = setup_game.MainMenu() + handler: game.input_handlers.BaseEventHandler = MainMenu() with tcod.context.new( columns=screen_width, @@ -31,7 +34,7 @@ def main() -> None: title="Yet Another Roguelike Tutorial", vsync=True, ) as context: - root_console = tcod.Console(screen_width, screen_height, order="F") + root_console = tcod.console.Console(screen_width, screen_height, order="F") try: while True: root_console.clear() @@ -40,14 +43,14 @@ def main() -> None: try: for event in tcod.event.wait(): - context.convert_event(event) + event = context.convert_event(event) handler = handler.handle_events(event) except Exception: # Handle exceptions in game. traceback.print_exc() # Print error to stderr. # Then print the error to the message log. - if isinstance(handler, input_handlers.EventHandler): - handler.engine.message_log.add_message(traceback.format_exc(), color.error) - except exceptions.QuitWithoutSaving: + if isinstance(handler, game.input_handlers.EventHandler): + handler.engine.message_log.add_message(traceback.format_exc(), game.color.error) + except QuitWithoutSaving: raise except SystemExit: # Save and quit. save_game(handler, "savegame.sav") diff --git a/requirements.txt b/requirements.txt index 09ff375..6cbb52d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -tcod>=11.15 +tcod>=19.3.1 numpy>=1.18 Pillow>=8.2.0