Refactor Action into Action and ActionWithActor

The base class Actor doesn't declare a (optional) actor attribute.
The ActionWithActor has a non-optional actor attribute.

This makes the type checker happier, and means we can have some actions
that don't have actors.
This commit is contained in:
Eryn Wells 2023-02-12 16:34:37 -08:00
parent 8efd3ce207
commit 7e00f58a40
5 changed files with 72 additions and 39 deletions

View file

@ -1,6 +1,6 @@
# Eryn Wells <eryn@erynwells.me> # Eryn Wells <eryn@erynwells.me>
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from ..object import Actor from ..object import Actor
from .result import ActionResult from .result import ActionResult
@ -8,12 +8,11 @@ from .result import ActionResult
if TYPE_CHECKING: if TYPE_CHECKING:
from ..engine import Engine from ..engine import Engine
class Action: class Action:
'''An action that an Entity should perform.''' '''An action with no specific actor'''
def __init__(self, actor: Optional[Actor] = None):
self.actor = actor
# pylint: disable=unused-argument
def perform(self, engine: 'Engine') -> ActionResult: def perform(self, engine: 'Engine') -> ActionResult:
'''Perform this action. '''Perform this action.
@ -28,7 +27,7 @@ class Action:
A result object reflecting how the action was handled, and what follow-up actions, if any, are needed to A result object reflecting how the action was handled, and what follow-up actions, if any, are needed to
complete the action. complete the action.
''' '''
raise NotImplementedError() return self.success()
def failure(self) -> ActionResult: def failure(self) -> ActionResult:
'''Create an ActionResult indicating failure with no follow-up''' '''Create an ActionResult indicating failure with no follow-up'''
@ -38,6 +37,20 @@ class Action:
'''Create an ActionResult indicating success with no follow-up''' '''Create an ActionResult indicating success with no follow-up'''
return ActionResult(self, success=True) return ActionResult(self, success=True)
def __str__(self) -> str:
return self.__class__.__name__
def __repr__(self):
return f'{self.__class__.__name__}()'
class ActionWithActor(Action):
'''An action that assigned to an actor'''
def __init__(self, actor: Actor):
super().__init__()
self.actor = actor
def __str__(self) -> str: def __str__(self) -> str:
return f'{self.__class__.__name__} for {self.actor!s}' return f'{self.__class__.__name__} for {self.actor!s}'

View file

@ -1,6 +1,3 @@
#!/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. 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 via keyboard input), or from non-player entities (e.g. AI deciboard input), or from non-player entities (e.g. AI
@ -18,13 +15,14 @@ Action : Base class of all actions
WaitAction WaitAction
''' '''
import random
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .. import items from .. import items
from .. import log from .. import log
from ..geometry import Vector from ..geometry import Vector
from ..object import Actor, Item from ..object import Actor, Item
from .action import Action from .action import Action, ActionWithActor
from .result import ActionResult from .result import ActionResult
if TYPE_CHECKING: if TYPE_CHECKING:
@ -34,9 +32,6 @@ if TYPE_CHECKING:
class ExitAction(Action): class ExitAction(Action):
'''Exit the game.''' '''Exit the game.'''
def __init__(self):
super().__init__(None)
def perform(self, engine: 'Engine') -> ActionResult: def perform(self, engine: 'Engine') -> ActionResult:
raise SystemExit() raise SystemExit()
@ -45,12 +40,10 @@ class RegenerateRoomsAction(Action):
'''Regenerate the dungeon map''' '''Regenerate the dungeon map'''
def perform(self, engine: 'Engine') -> ActionResult: def perform(self, engine: 'Engine') -> ActionResult:
return ActionResult(self, success=False) return self.failure()
# pylint: disable=abstract-method
class MoveAction(Action): class MoveAction(ActionWithActor):
'''An abstract Action that requires a direction to complete.''' '''An abstract Action that requires a direction to complete.'''
def __init__(self, actor: Actor, direction: Vector): def __init__(self, actor: Actor, direction: Vector):
@ -100,6 +93,8 @@ class BumpAction(MoveAction):
if not position_is_in_bounds or not position_is_walkable: if not position_is_in_bounds or not position_is_walkable:
return self.failure() return self.failure()
# TODO: I'm passing entity_occupying_position into the ActionResult below, but the type checker doesn't
# understand that the entity is an Actor. I think I need some additional checks here.
if entity_occupying_position: if entity_occupying_position:
assert entity_occupying_position.blocks_movement assert entity_occupying_position.blocks_movement
return ActionResult(self, alternate=MeleeAction(self.actor, self.direction, entity_occupying_position)) return ActionResult(self, alternate=MeleeAction(self.actor, self.direction, entity_occupying_position))
@ -111,15 +106,19 @@ class WalkAction(MoveAction):
'''Walk one step in the given direction.''' '''Walk one step in the given direction.'''
def perform(self, engine: 'Engine') -> ActionResult: def perform(self, engine: 'Engine') -> ActionResult:
new_position = self.actor.position + self.direction actor = self.actor
assert actor.fighter
new_position = actor.position + self.direction
log.ACTIONS.debug('Moving %s to %s', self.actor, new_position) log.ACTIONS.debug('Moving %s to %s', self.actor, new_position)
self.actor.position = new_position actor.position = new_position
try: try:
should_recover_hit_points = self.actor.fighter.passively_recover_hit_points(5) should_recover_hit_points = actor.fighter.passively_recover_hit_points(5)
if should_recover_hit_points: if should_recover_hit_points:
return ActionResult(self, alternate=HealAction(self.actor, 1)) return ActionResult(self, alternate=HealAction(actor, random.randint(1, 3)))
except AttributeError: except AttributeError:
pass pass
@ -134,8 +133,13 @@ class MeleeAction(MoveAction):
self.target = target self.target = target
def perform(self, engine: 'Engine') -> ActionResult: def perform(self, engine: 'Engine') -> ActionResult:
assert self.actor.fighter and self.target.fighter
fighter = self.actor.fighter
target_fighter = self.target.fighter
try: try:
damage = self.actor.fighter.attack_power - self.target.fighter.defense damage = fighter.attack_power - target_fighter.defense
if damage > 0 and self.target: if damage > 0 and self.target:
log.ACTIONS.debug('%s attacks %s for %d damage!', self.actor, self.target, damage) log.ACTIONS.debug('%s attacks %s for %d damage!', self.actor, self.target, damage)
self.target.fighter.hit_points -= damage self.target.fighter.hit_points -= damage
@ -160,21 +164,24 @@ class MeleeAction(MoveAction):
return self.success() return self.success()
class WaitAction(Action): class WaitAction(ActionWithActor):
'''Wait a turn''' '''Wait a turn'''
def perform(self, engine: 'Engine') -> ActionResult: def perform(self, engine: 'Engine') -> ActionResult:
log.ACTIONS.debug('%s is waiting a turn', self.actor) log.ACTIONS.debug('%s is waiting a turn', self.actor)
if self.actor == engine.hero: if self.actor == engine.hero:
should_recover_hit_points = self.actor.fighter.passively_recover_hit_points(10) assert self.actor.fighter
fighter = self.actor.fighter
should_recover_hit_points = fighter.passively_recover_hit_points(20)
if should_recover_hit_points: if should_recover_hit_points:
return ActionResult(self, alternate=HealAction(self.actor, 1)) return ActionResult(self, alternate=HealAction(self.actor, random.randint(1, 3)))
return self.success() return self.success()
class DieAction(Action): class DieAction(ActionWithActor):
'''Kill an Actor''' '''Kill an Actor'''
def perform(self, engine: 'Engine') -> ActionResult: def perform(self, engine: 'Engine') -> ActionResult:
@ -193,7 +200,7 @@ class DieAction(Action):
return self.success() return self.success()
class DropItemAction(Action): class DropItemAction(ActionWithActor):
'''Drop an item''' '''Drop an item'''
def __init__(self, actor: 'Actor', item: 'Item'): def __init__(self, actor: 'Actor', item: 'Item'):
@ -205,7 +212,7 @@ class DropItemAction(Action):
return self.success() return self.success()
class HealAction(Action): class HealAction(ActionWithActor):
'''Heal a target actor some number of hit points''' '''Heal a target actor some number of hit points'''
def __init__(self, actor: 'Actor', hit_points_to_recover: int): def __init__(self, actor: 'Actor', hit_points_to_recover: int):

View file

@ -7,7 +7,7 @@ import numpy as np
import tcod import tcod
from . import log from . import log
from .actions.action import Action from .actions.action import ActionWithActor
from .actions.game import BumpAction, WaitAction from .actions.game import BumpAction, WaitAction
from .components import Component from .components import Component
from .geometry import Direction, Point from .geometry import Direction, Point
@ -26,7 +26,7 @@ class AI(Component):
super().__init__() super().__init__()
self.entity = entity self.entity = entity
def act(self, engine: 'Engine') -> Optional[Action]: def act(self, engine: 'Engine') -> Optional[ActionWithActor]:
'''Produce an action to perform''' '''Produce an action to perform'''
raise NotImplementedError() raise NotImplementedError()
@ -38,7 +38,7 @@ class HostileEnemy(AI):
beeline for her. beeline for her.
''' '''
def act(self, engine: 'Engine') -> Optional[Action]: def act(self, engine: 'Engine') -> Optional[ActionWithActor]:
visible_tiles = tcod.map.compute_fov( visible_tiles = tcod.map.compute_fov(
engine.map.tiles['transparent'], engine.map.tiles['transparent'],
pov=tuple(self.entity.position), pov=tuple(self.entity.position),

View file

@ -9,7 +9,7 @@ import tcod
from . import log from . import log
from . import monsters from . import monsters
from .actions.action import Action from .actions.action import Action, ActionWithActor
from .actions.result import ActionResult from .actions.result import ActionResult
from .ai import HostileEnemy from .ai import HostileEnemy
from .configuration import Configuration from .configuration import Configuration
@ -125,6 +125,10 @@ class Engine:
def process_input_action(self, action: Action): def process_input_action(self, action: Action):
'''Process an Action from player input''' '''Process an Action from player input'''
if not isinstance(action, ActionWithActor):
log.ACTIONS_TREE.error('Attempted to process input action with no actor')
return
log.ACTIONS_TREE.info('Processing Hero Actions') log.ACTIONS_TREE.info('Processing Hero Actions')
log.ACTIONS_TREE.info('|-> %s', action.actor) log.ACTIONS_TREE.info('|-> %s', action.actor)
@ -165,10 +169,11 @@ class Engine:
if self.map.visible[tuple(ent.position)]: if self.map.visible[tuple(ent.position)]:
log.ACTIONS_TREE.info('%s-> %s', '|' if i < len(entities) - 1 else '`', ent) log.ACTIONS_TREE.info('%s-> %s', '|' if i < len(entities) - 1 else '`', ent)
action = ent_ai.act(self) action = ent_ai.act(engine=self)
if action:
self._perform_action_until_done(action) self._perform_action_until_done(action)
def _perform_action_until_done(self, action: Action) -> ActionResult: def _perform_action_until_done(self, action: ActionWithActor) -> ActionResult:
'''Perform the given action and any alternate follow-up actions until the action chain is done.''' '''Perform the given action and any alternate follow-up actions until the action chain is done.'''
result = action.perform(self) result = action.perform(self)

View file

@ -102,11 +102,17 @@ class Actor(Entity):
position: Optional[Point] = None, position: Optional[Point] = None,
blocks_movement: Optional[bool] = True, blocks_movement: Optional[bool] = True,
render_order: RenderOrder = RenderOrder.ACTOR, render_order: RenderOrder = RenderOrder.ACTOR,
ai: Optional[Type['AI']] = None, ai: Optional['AI'] = None,
fighter: Optional[Fighter] = None, fighter: Optional[Fighter] = None,
fg: Optional[Tuple[int, int, int]] = None, fg: Optional[Tuple[int, int, int]] = None,
bg: Optional[Tuple[int, int, int]] = None): bg: Optional[Tuple[int, int, int]] = None):
super().__init__(symbol, position=position, blocks_movement=blocks_movement, fg=fg, bg=bg, render_order=render_order) super().__init__(
symbol,
position=position,
blocks_movement=blocks_movement,
fg=fg,
bg=bg,
render_order=render_order)
# Components # Components
self.ai = ai self.ai = ai
@ -152,13 +158,14 @@ class Hero(Actor):
return 8 return 8
def __str__(self) -> str: def __str__(self) -> str:
assert self.fighter
return f'Hero!{self.identifier} at {self.position} with {self.fighter.hit_points}/{self.fighter.maximum_hit_points} hp' return f'Hero!{self.identifier} at {self.position} with {self.fighter.hit_points}/{self.fighter.maximum_hit_points} hp'
class Monster(Actor): class Monster(Actor):
'''An instance of a Species''' '''An instance of a Species'''
def __init__(self, species: Species, ai_class: Type['AI'], position: Point = None): def __init__(self, species: Species, ai_class: Type['AI'], position: Optional[Point] = None):
fighter = Fighter( fighter = Fighter(
maximum_hit_points=species.maximum_hit_points, maximum_hit_points=species.maximum_hit_points,
attack_power=species.attack_power, attack_power=species.attack_power,
@ -187,13 +194,14 @@ class Monster(Actor):
return True return True
def __str__(self) -> str: def __str__(self) -> str:
assert self.fighter
return f'{self.name}!{self.identifier} with {self.fighter.hit_points}/{self.fighter.maximum_hit_points} hp at {self.position}' return f'{self.name}!{self.identifier} with {self.fighter.hit_points}/{self.fighter.maximum_hit_points} hp at {self.position}'
class Item(Entity): class Item(Entity):
'''An instance of an Item''' '''An instance of an Item'''
def __init__(self, kind: items.Item, position: Point = None, name: str = None): def __init__(self, kind: items.Item, position: Optional[Point] = None, name: Optional[str] = None):
super().__init__(kind.symbol, super().__init__(kind.symbol,
position=position, position=position,
blocks_movement=False, blocks_movement=False,