Attack!!!

Refactor MovePlayerAction into a few different Action subclasses. Move direction
to a parent MoveAction, and create three new subclasses of MoveAction:

    - BumpAction: perform the test that an action can be performed in the given direction
    - WalkAction, take a step in the given direction
    - MeleeAction, attack another Entity in the given direction

Add an ActionResult class that communicates the result of performing an Action.

    - ActionResult.succeeded indicates whether the action succeeded.
    - ActionResult.done indicates if the action is fully complete or requires followup.
    - ActionResult.alternate specifies the follow-up action to perform.

Convert all the key handling actions to BumpActions.

In the Engine's event handler method, loop until an action is completed,
performing specified alternate actions until the result object indicates the
action is done.
This commit is contained in:
Eryn Wells 2022-05-07 11:16:17 -07:00
parent 57bbb2c3fc
commit 4002b64640
4 changed files with 153 additions and 36 deletions

View file

@ -1,36 +1,75 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
'''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,
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,
overlaps_another_entity)
entity.position = new_player_position
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)

View file

@ -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}

View file

@ -2,7 +2,7 @@
# Eryn Wells <eryn@erynwells.me>
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()

View file

@ -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