diff --git a/roguebasin/actions.py b/roguebasin/actions.py index 6b20174..0990475 100644 --- a/roguebasin/actions.py +++ b/roguebasin/actions.py @@ -1,36 +1,75 @@ #!/usr/bin/env python3 # Eryn Wells +'''This module defines all of the actions that can be performed by the game. These actions can come from the player +(e.g. via keyboard input), or from non-player entities (e.g. AI deciboard input), or from non-player entities (e.g. AI +decisions). + +Class Hierarchy +--------------- + +Action : Base class of all actions + MoveAction : Base class for all actions that are performed with a direction + WalkAction + MeleeAction + ExitAction +''' + import logging from typing import Optional, TYPE_CHECKING from .geometry import Direction +from .object import Entity if TYPE_CHECKING: from .engine import Engine - from .object import Entity LOG = logging.getLogger('events') class ActionResult: - '''An object that represents the result of an Action. + '''The result of an Action. + + `Action.perform()` returns an instance of this class to inform the caller of the result Attributes ---------- - success : bool + action : Action + The Action that was performed + success : bool, optional True if the action succeeded - done : bool - True if the action is complete, and no follow-up action is needed. + done : bool, optional + True if the action is complete, and no follow-up action is needed alternate : Action, optional An alternate action to perform if this action failed ''' - def __init__(self, success: bool, done: bool = True, alternate: Optional['Action'] = None): - self.success = success - self.done = done + def __init__(self, action: 'Action', *, + success: Optional[bool] = None, + done: Optional[bool] = None, + alternate: Optional['Action'] = None): + self.action = action self.alternate = alternate + if success is not None: + self.success = success + elif alternate: + self.success = False + else: + self.success = True + + if done is not None: + self.done = done + elif self.success: + self.done = True + else: + self.done = not alternate + + def __repr__(self): + return f'{self.__class__.__name__}({self.action}, success={self.success}, done={self.done}, alternate={self.alternate})' + class Action: - def perform(self, engine: 'Engine', entity: 'Entity') -> ActionResult: + '''An action that an Entity should perform.''' + + def perform(self, engine: 'Engine', entity: Entity) -> ActionResult: '''Perform this action. Parameters @@ -52,28 +91,86 @@ class Action: return f'{self.__class__.__name__}()' class ExitAction(Action): - def perform(self, engine: 'Engine', entity: 'Entity') -> ActionResult: + '''Exit the game.''' + + def perform(self, engine: 'Engine', entity: Entity) -> ActionResult: raise SystemExit() class RegenerateRoomsAction(Action): - def perform(self, engine: 'Engine', entity: 'Entity') -> ActionResult: - return ActionResult(True) + '''Regenerate the dungeon map''' + + def perform(self, engine: 'Engine', entity: Entity) -> ActionResult: + return ActionResult(self, success=False) + +# pylint: disable=abstract-method +class MoveAction(Action): + '''An abstract Action that requires a direction to complete.''' -class MovePlayerAction(Action): def __init__(self, direction: Direction): + super().__init__() self.direction = direction - def perform(self, engine: 'Engine', entity: 'Entity') -> None: - new_player_position = entity.position + self.direction + def __repr__(self): + return f'{self.__class__.__name__}({self.direction})' - position_is_in_bounds = engine.map.tile_is_in_bounds(new_player_position) - position_is_walkable = engine.map.tile_is_walkable(new_player_position) - overlaps_another_entity = any(new_player_position == ent.position for ent in engine.entities if ent is not entity) +class BumpAction(MoveAction): + '''Attempt to perform a movement action in a direction. - if position_is_in_bounds and position_is_walkable and not overlaps_another_entity: - LOG.info('Moving hero to %s (in_bounds:%s walkable:%s overlaps:%s)', - new_player_position, - position_is_in_bounds, - position_is_walkable, - overlaps_another_entity) - entity.position = new_player_position + This action tests if an action in the direction is possible and returns the action that can be completed. + + Attributes + ---------- + direction : Direction + The direction to test + ''' + + def perform(self, engine: 'Engine', entity: Entity) -> ActionResult: + new_position = entity.position + self.direction + + position_is_in_bounds = engine.map.tile_is_in_bounds(new_position) + position_is_walkable = engine.map.tile_is_walkable(new_position) + + for ent in engine.entities: + if new_position != ent.position: + continue + entity_occupying_position = ent + break + else: + entity_occupying_position = None + + LOG.info('Bumping %s (in_bounds:%s walkable:%s overlaps:%s)', + new_position, + position_is_in_bounds, + position_is_walkable, + entity_occupying_position) + + if not position_is_in_bounds or not position_is_walkable: + return ActionResult(self, success=False) + + if entity_occupying_position: + return ActionResult(self, alternate=MeleeAction(self.direction, entity_occupying_position)) + + return ActionResult(self, alternate=WalkAction(self.direction)) + + +class WalkAction(MoveAction): + '''Walk one step in the given direction.''' + + def perform(self, engine: 'Engine', entity: Entity) -> ActionResult: + new_position = entity.position + self.direction + + LOG.info('Moving %s to %s', entity, new_position) + entity.position = new_position + + return ActionResult(self, success=True) + +class MeleeAction(MoveAction): + '''Perform a melee attack on another entity''' + + def __init__(self, direction: Direction, target: Entity): + super().__init__(direction) + self.target = target + + def perform(self, engine: 'Engine', entity: Entity) -> ActionResult: + LOG.info('Attack! %s', self.target) + return ActionResult(self, success=True) diff --git a/roguebasin/engine.py b/roguebasin/engine.py index 136a08b..22dd6c4 100644 --- a/roguebasin/engine.py +++ b/roguebasin/engine.py @@ -66,7 +66,26 @@ class Engine: if not action: return - action.perform(self, self.hero) + result = action.perform(self, self.hero) + LOG.debug('Performed action success=%s done=%s alternate=%s', result.success, result.done, result.alternate) + + while not result.done: + alternate = result.alternate + assert alternate is not None, f'Action {result.action} incomplete but no alternate action given' + + result = alternate.perform(self, self.hero) + LOG.debug('Performed action success=%s done=%s alternate=%s', result.success, result.done, result.alternate) + + if result.success: + LOG.info('Action succeded!') + break + + if result.done: + LOG.info('Action failed!') + break + + if not result.success and result.done: + return directions = list(Direction.all()) moved_entities: MutableSet[Entity] = {self.hero} diff --git a/roguebasin/events.py b/roguebasin/events.py index 2ca754d..7d837b4 100644 --- a/roguebasin/events.py +++ b/roguebasin/events.py @@ -2,7 +2,7 @@ # Eryn Wells import tcod -from .actions import Action, ExitAction, MovePlayerAction, RegenerateRoomsAction +from .actions import Action, ExitAction, RegenerateRoomsAction, BumpAction from .geometry import Direction from typing import Optional @@ -16,21 +16,21 @@ class EventHandler(tcod.event.EventDispatch[Action]): sym = event.sym if sym == tcod.event.KeySym.b: - action = MovePlayerAction(Direction.SouthWest) + action = BumpAction(Direction.SouthWest) elif sym == tcod.event.KeySym.h: - action = MovePlayerAction(Direction.West) + action = BumpAction(Direction.West) elif sym == tcod.event.KeySym.j: - action = MovePlayerAction(Direction.South) + action = BumpAction(Direction.South) elif sym == tcod.event.KeySym.k: - action = MovePlayerAction(Direction.North) + action = BumpAction(Direction.North) elif sym == tcod.event.KeySym.l: - action = MovePlayerAction(Direction.East) + action = BumpAction(Direction.East) elif sym == tcod.event.KeySym.n: - action = MovePlayerAction(Direction.SouthEast) + action = BumpAction(Direction.SouthEast) elif sym == tcod.event.KeySym.u: - action = MovePlayerAction(Direction.NorthEast) + action = BumpAction(Direction.NorthEast) elif sym == tcod.event.KeySym.y: - action = MovePlayerAction(Direction.NorthWest) + action = BumpAction(Direction.NorthWest) elif sym == tcod.event.KeySym.SPACE: action = RegenerateRoomsAction() diff --git a/roguebasin/geometry.py b/roguebasin/geometry.py index 203cc1f..988f675 100644 --- a/roguebasin/geometry.py +++ b/roguebasin/geometry.py @@ -35,7 +35,7 @@ class Vector: yield self.dy def __str__(self): - return f'(δx:{self.x}, δy:{self.y})' + return f'(δx:{self.dx}, δy:{self.dy})' class Direction: North = Vector(0, -1) @@ -49,6 +49,7 @@ class Direction: @classmethod def all(cls) -> Iterator['Direction']: + '''Iterate through all directions, starting with North and proceeding clockwise''' yield Direction.North yield Direction.NorthEast yield Direction.East