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>
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
from ..object import Actor
from .result import ActionResult
@ -8,12 +8,11 @@ from .result import ActionResult
if TYPE_CHECKING:
from ..engine import Engine
class Action:
'''An action that an Entity should perform.'''
def __init__(self, actor: Optional[Actor] = None):
self.actor = actor
'''An action with no specific actor'''
# pylint: disable=unused-argument
def perform(self, engine: 'Engine') -> ActionResult:
'''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
complete the action.
'''
raise NotImplementedError()
return self.success()
def failure(self) -> ActionResult:
'''Create an ActionResult indicating failure with no follow-up'''
@ -38,6 +37,20 @@ class Action:
'''Create an ActionResult indicating success with no follow-up'''
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:
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.
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
'''
import random
from typing import TYPE_CHECKING
from .. import items
from .. import log
from ..geometry import Vector
from ..object import Actor, Item
from .action import Action
from .action import Action, ActionWithActor
from .result import ActionResult
if TYPE_CHECKING:
@ -34,9 +32,6 @@ if TYPE_CHECKING:
class ExitAction(Action):
'''Exit the game.'''
def __init__(self):
super().__init__(None)
def perform(self, engine: 'Engine') -> ActionResult:
raise SystemExit()
@ -45,12 +40,10 @@ class RegenerateRoomsAction(Action):
'''Regenerate the dungeon map'''
def perform(self, engine: 'Engine') -> ActionResult:
return ActionResult(self, success=False)
# pylint: disable=abstract-method
return self.failure()
class MoveAction(Action):
class MoveAction(ActionWithActor):
'''An abstract Action that requires a direction to complete.'''
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:
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:
assert entity_occupying_position.blocks_movement
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.'''
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)
self.actor.position = new_position
actor.position = new_position
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:
return ActionResult(self, alternate=HealAction(self.actor, 1))
return ActionResult(self, alternate=HealAction(actor, random.randint(1, 3)))
except AttributeError:
pass
@ -134,8 +133,13 @@ class MeleeAction(MoveAction):
self.target = target
def perform(self, engine: 'Engine') -> ActionResult:
assert self.actor.fighter and self.target.fighter
fighter = self.actor.fighter
target_fighter = self.target.fighter
try:
damage = self.actor.fighter.attack_power - self.target.fighter.defense
damage = fighter.attack_power - target_fighter.defense
if damage > 0 and self.target:
log.ACTIONS.debug('%s attacks %s for %d damage!', self.actor, self.target, damage)
self.target.fighter.hit_points -= damage
@ -160,21 +164,24 @@ class MeleeAction(MoveAction):
return self.success()
class WaitAction(Action):
class WaitAction(ActionWithActor):
'''Wait a turn'''
def perform(self, engine: 'Engine') -> ActionResult:
log.ACTIONS.debug('%s is waiting a turn', self.actor)
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:
return ActionResult(self, alternate=HealAction(self.actor, 1))
return ActionResult(self, alternate=HealAction(self.actor, random.randint(1, 3)))
return self.success()
class DieAction(Action):
class DieAction(ActionWithActor):
'''Kill an Actor'''
def perform(self, engine: 'Engine') -> ActionResult:
@ -193,7 +200,7 @@ class DieAction(Action):
return self.success()
class DropItemAction(Action):
class DropItemAction(ActionWithActor):
'''Drop an item'''
def __init__(self, actor: 'Actor', item: 'Item'):
@ -205,7 +212,7 @@ class DropItemAction(Action):
return self.success()
class HealAction(Action):
class HealAction(ActionWithActor):
'''Heal a target actor some number of hit points'''
def __init__(self, actor: 'Actor', hit_points_to_recover: int):

View file

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

View file

@ -9,7 +9,7 @@ import tcod
from . import log
from . import monsters
from .actions.action import Action
from .actions.action import Action, ActionWithActor
from .actions.result import ActionResult
from .ai import HostileEnemy
from .configuration import Configuration
@ -125,6 +125,10 @@ class Engine:
def process_input_action(self, action: Action):
'''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('|-> %s', action.actor)
@ -165,10 +169,11 @@ class Engine:
if self.map.visible[tuple(ent.position)]:
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)
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.'''
result = action.perform(self)

View file

@ -102,11 +102,17 @@ class Actor(Entity):
position: Optional[Point] = None,
blocks_movement: Optional[bool] = True,
render_order: RenderOrder = RenderOrder.ACTOR,
ai: Optional[Type['AI']] = None,
ai: Optional['AI'] = None,
fighter: Optional[Fighter] = None,
fg: 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
self.ai = ai
@ -152,13 +158,14 @@ class Hero(Actor):
return 8
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'
class Monster(Actor):
'''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(
maximum_hit_points=species.maximum_hit_points,
attack_power=species.attack_power,
@ -187,13 +194,14 @@ class Monster(Actor):
return True
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}'
class Item(Entity):
'''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,
position=position,
blocks_movement=False,