Source code for scripts.engine.internal.action

from __future__ import annotations

import logging
import os
import random
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Set, TYPE_CHECKING

from snecs.typedefs import EntityID

from scripts.engine.core import hourglass, matter, world
from scripts.engine.core.component import Aesthetic, Knowledge, Position
from scripts.engine.core.effect import Effect
from scripts.engine.internal.constant import (
    AfflictionCategoryType,
    DirectionType,
    EffectTypeType,
    ProjectileExpiry,
    ReactionTriggerType,
    ResourceType,
    ShapeType,
    TargetingMethodType,
    TerrainCollision,
    TileTag,
    TileTagType,
)
from scripts.engine.internal.definition import DelayedSkillData, ProjectileData
from scripts.engine.internal.event import event_hub, UseSkillEvent
from scripts.engine.world_objects.tile import Tile

if TYPE_CHECKING:
    from typing import Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union


__all__ = ["Skill", "Affliction", "Behaviour", "SkillModifier", "register_action"]


# used by skill modifiers
import scripts.engine.core.effect

_EFFECTS = {k: getattr(scripts.engine.core.effect, k) for k in dir(scripts.engine.core.effect)}


[docs]class Action(ABC): """ Action taken during the game. A container for Effects. """ # details to be overwritten by external data name: str # name of the class description: str icon_path: str # details to be overwritten in subclass target_tags: List[TileTagType] shape: ShapeType shape_size: int # set by instance effects: List[Effect] @abstractmethod def _build_effects(self, entity: EntityID, potency: float = 1.0) -> List[Effect]: """ Build the effects of this skill applying to a single entity. Must be overridden in subclass. """ pass
[docs]class Skill(Action): """ A subclass of Skill represents a skill and holds all the data that is not dependent on the individual cast - stuff like shape, base accuracy, etc. An instance of Skill represents an individual use of that skill, and additionally holds only the data that is tied to the individual use - stuff like the user and target. """ # core data, to be overwritten by external data resource_type: ResourceType resource_cost: int time_cost: int base_cooldown: int # targeting details, to be overwritten in subclass targeting_method: TargetingMethodType # Tile, Direction, Auto cast_tags: List[TileTagType] target_directions: List[DirectionType] # needed for Direction range: int # needed for Tile, Auto # delivery methods, to be overwritten in subclass uses_projectile: bool # usable by for Tile, Direction, Auto projectile_data: Optional[ProjectileData] is_delayed: bool # usable by Tile, Auto - Doesnt make sense for Direction to have a delayed cast. delayed_skill_data: Optional[DelayedSkillData] # blessing related attributes blessings: List[SkillModifier] types: List[str]
[docs] def __init__(self, user: EntityID, target_tile: Tile, direction: DirectionType): self.user: EntityID = user self.target_tile: Tile = target_tile self.direction: DirectionType = direction self.projectile: Optional[EntityID] = None self.delayed_skill: Optional[EntityID] = None # vars needed to keep track of changes self.ignore_entities: List[EntityID] = [] # to ensure entity not hit more than once self.inactive_effects: List[str] = []
def _post_build_effects(self, entity: EntityID, potency: float = 1.0, skill_stack=None) -> List[Effect]: """ Build the effects of this skill applying to a single entity. This function will be used to apply any dynamic tweaks to the effects stack after the subclass generates its stack. """ # handle mutable default if skill_stack is None: skill_stack = [] skill_blessings = matter.get_entitys_component(self.user, Knowledge).skill_blessings relevant_blessings: List[SkillModifier] = [] if self.__class__.__name__ in skill_blessings: relevant_blessings = skill_blessings[self.__class__.__name__] for blessing in relevant_blessings: blessing.apply(skill_stack, self.user, entity) return skill_stack @classmethod def _init_properties(cls): """ Sets the class properties of the skill from the class key """ from scripts.engine.internal import library cls.data = library.SKILLS[cls.__name__] cls.name = cls.__name__ cls.description = cls.data.description cls.base_cooldown = cls.data.cooldown cls.time_cost = cls.data.time_cost cls.icon_path = cls.data.icon_path cls.resource_type = cls.data.resource_type cls.resource_cost = cls.data.resource_cost cls.types = cls.data.types
[docs] def apply(self) -> Iterator[Tuple[EntityID, List[Effect]]]: """ An iterator over pairs of (affected entity, [effects]). Uses target tile. Can apply to an entity multiple times. """ entity_names = [] for entity in matter.get_affected_entities( (self.target_tile.x, self.target_tile.y), self.shape, self.shape_size, self.direction ): yield entity, [ effect for effect in self._build_effects(entity) if effect.__class__.__name__ not in self.inactive_effects ] entity_names.append(matter.get_name(entity))
[docs] def use(self) -> bool: """ If uses_projectile then create a projectile to carry the skill effects. Otherwise call self.apply """ logging.debug(f"'{matter.get_name(self.user)}' used '{self.__class__.__name__}'.") # handle the delivery method of the skill if self.uses_projectile: self._create_projectile() is_successful = True elif self.is_delayed: self._create_delayed_skill() is_successful = True else: is_successful = matter.apply_skill(self) if is_successful: # post interaction event event = UseSkillEvent(origin=self.user, skill_name=self.__class__.__name__) event_hub.post(event) return is_successful
def _create_projectile(self): """ Create a projectile carrying the skill's effects """ projectile_data = self.projectile_data # update projectile instance values projectile_data.creator = self.user projectile_data.skill_name = self.name projectile_data.skill_instance = self projectile_data.direction = self.direction # create the projectile projectile = matter.create_projectile(self.user, (self.target_tile.x, self.target_tile.y), projectile_data) # add projectile to ignore list self.ignore_entities.append(projectile) # save the reference to the projectile entity self.projectile = projectile def _create_delayed_skill(self): delayed_skill_data = self.delayed_skill_data # update delayed skill instance values delayed_skill_data.creator = self.user delayed_skill_data.skill_name = self.name delayed_skill_data.skill_instance = self # create the delayed skill delayed_skill = matter.create_delayed_skill( self.user, (self.target_tile.x, self.target_tile.y), delayed_skill_data ) # add to ignore list self.ignore_entities.append(delayed_skill) # save reference self.delayed_skill = delayed_skill @abstractmethod def _build_effects(self, entity: EntityID, potency: float = 1.0) -> List[Effect]: """ Build the effects of this skill applying to a single entity. Must be overridden in subclass. """ pass
[docs]class Affliction(Action): """ A subclass of Affliction represents an affliction (a semi-permanent modifier) and holds all the data that is not dependent on the individual instances - stuff like applicable targets etc. An instance of Affliction represents an individual application of that affliction, and holds only the data that is tied to the individual use - stuff like the user and target. """ # to be overwritten in subclass, including being set by external data identity_tags: List[EffectTypeType] triggers: List[ReactionTriggerType] category: AfflictionCategoryType
[docs] def __init__(self, origin: EntityID, affected_entity: EntityID, duration: int): self.origin = origin self.affected_entity = affected_entity self.duration = duration
@abstractmethod def _build_effects(self, entity: EntityID, potency: float = 1.0) -> List[Effect]: """ Build the effects of this skill applying to a single entity. Must be overridden in subclass. """ pass @classmethod def _init_properties(cls): """ Sets the class properties of the affliction from the class key """ from scripts.engine.internal import library cls.data = library.AFFLICTIONS[cls.__name__] cls.name = cls.__name__ cls.description = cls.data.description cls.icon_path = cls.data.icon_path cls.category = cls.data.category cls.identity_tags = cls.data.identity_tags cls.triggers = cls.data.triggers
[docs] def apply(self) -> Iterator[Tuple[EntityID, List[Effect]]]: """ Apply the affliction to the affected entity. An iterator over pairs of (affected entity, [effects]). Use affected entity position. Applies to each entity only once. """ from scripts.engine.core import world entity_names = [] entities = set() position = matter.get_entitys_component(self.affected_entity, Position) for coordinate in position.coordinates: for entity in matter.get_affected_entities(coordinate, self.shape, self.shape_size): if entity not in entities: entities.add(entity) yield entity, self._build_effects(entity) entity_names.append(matter.get_name(entity))
[docs] def trigger(self): """ Trigger the affliction on the affected entity """ yield self.affected_entity, self._build_effects(self.affected_entity)
[docs]class Behaviour(ABC): """ Base class for AI behaviours. Not really an Action, as such, more of a super class that determines when npcs will use Actions. """
[docs] def __init__(self, attached_entity: EntityID): self.entity = attached_entity
[docs] @abstractmethod def act(self): """ Perform the behaviour """ pass
[docs]class SkillModifier(ABC): """ The base class for blessings. Blessings modify skills through the effects applied. """ name: str description: str level: str removable: bool conflicts: List[str] skill_types: List[str] # remove/add are applied when the blessing is applied remove_effects: List[str] add_effects: List[Dict[str, Any]] # modifications are applied when the effects are built modify_effects_set: List[Dict[str, Any]] modify_effects_tweak_flat: List[Dict[str, Any]] modify_effects_tweak_percent: List[Dict[str, Any]] # custom args (set by child if JSON doesn't cover the argument needs) custom_args: Dict[str, Any] = {}
[docs] def __init__(self, owner): self.owner = owner
@classmethod def _init_properties(cls): """ Sets the class properties of the blessing from the class key """ from scripts.engine.internal import library cls.data = library.BLESSINGS[cls.__name__] cls.name = cls.__name__ cls.description = cls.data["description"] cls.level = "Base" # assume common exists for now cls.removable = cls.data["removable"] cls.conflicts = cls.data["conflicts"] cls.skill_types = cls.data["skill_types"] cls.remove_effects = cls.data["base_effects"]["remove_effects"] cls.add_effects = cls.data["base_effects"]["add_effects"] cls.modify_effects_set = cls.data["base_effects"]["modify_effects_set"] cls.modify_effects_tweak_flat = cls.data["base_effects"]["modify_effects_tweak_flat"] cls.modify_effects_tweak_percent = cls.data["base_effects"]["modify_effects_tweak_percent"] @property def involved_effects(self) -> Set[str]: """ Get the set of effects involved in the blessing. """ return set([v["effect_id"] for v in self.add_effects + self.modify_effects_set] + self.remove_effects)
[docs] def roll_level(self): """ Runs the level selection algorithm and updates attributes with the applied level. """ levels = [] level_chances = [] total = 0 for level in self.data["levels"]: levels.append(level) total += self.data["levels"][level]["rarity"] level_chances.append(total) random_float = random.random() for i, chance in enumerate(level_chances): if random_float <= chance: break self.set_level(levels[i])
[docs] def set_level(self, level): """ Refreshes the class attributes with the data for the specific blessing level. """ self.level = level if "remove_effects" in self.data["levels"][self.level]["effects"]: self.remove_effects = self.data["levels"][self.level]["effects"]["remove_effects"] if "add_effects" in self.data["levels"][self.level]["effects"]: self.add_effects = self.data["levels"][self.level]["effects"]["add_effects"] if "modify_effects_set" in self.data["levels"][self.level]["effects"]: self.modify_effects_set = self.data["levels"][self.level]["effects"]["modify_effects_set"] if "modify_effects_tweak_flat" in self.data["levels"][self.level]["effects"]: self.modify_effects_tweak_flat = self.data["levels"][self.level]["effects"]["modify_effects_tweak_flat"] if "modify_effects_tweak_percent" in self.data["levels"][self.level]["effects"]: self.modify_effects_tweak_percent = self.data["levels"][self.level]["effects"][ "modify_effects_tweak_percent" ]
[docs] def apply(self, effects: List[Effect], owner, target): """ This is the core function of the blessing. It takes the effect stack and modifies it with the blessing. """ # go through the effects backwards so that the .remove() doesn't mess up indexing for effect in effects[::-1]: effect_name = effect.__class__.__name__ # flat config change for mod in self.modify_effects_tweak_flat: if mod["effect_id"] == effect_name: for value in mod["values"]: current_value = getattr(effect, value) setattr(effect, value, current_value + mod["values"][value]) # set config change for mod in self.modify_effects_set: if mod["effect_id"] == effect_name: for value in mod["values"]: setattr(effect, value, mod["values"][value]) # percent config change for mod in self.modify_effects_tweak_percent: if mod["effect_id"] == effect_name: for value in mod["values"]: current_value = getattr(effect, value) setattr(effect, value, current_value * mod["values"][value]) # remove effects from the stack if applicable if effect_name in self.remove_effects: effects.remove(effect) # add effects to the stack for add_effect in self.add_effects: # build the effect creation arguments from the config args = {"origin": owner, "target": target, "success_effects": [], "failure_effects": []} args.update(add_effect["args"]) if add_effect["effect_id"] in self.custom_args: args.update(self.custom_args) effects.insert(0, _EFFECTS[add_effect["effect_id"]](**args))
[docs]def register_action(cls: Type[Union[Action, Behaviour, SkillModifier]]): """ Initialises the class properties set by external data, if appropriate, and adds to the action registry for use by the engine. """ if "GENERATING_SPHINX_DOCS" in os.environ: # when building in CI these fail return from scripts.engine.internal.data import store if issubclass(cls, Skill): cls._init_properties() store.skill_registry[cls.__name__] = cls elif issubclass(cls, SkillModifier): cls._init_properties() store.blessing_registry[cls.__name__] = cls elif issubclass(cls, Affliction): cls._init_properties() store.affliction_registry[cls.__name__] = cls elif issubclass(cls, Behaviour): store.behaviour_registry[cls.__name__] = cls
######################### REQUIRED ACTION SUBCLASSES ##########################
[docs]class Projectile(Behaviour): """ Move in direction, up to max_range (in tiles). Speed is time spent per tile moved. """
[docs] def __init__(self, attached_entity: EntityID): super().__init__(attached_entity) self.data: ProjectileData = ProjectileData() self.distance_travelled = 0
[docs] def act(self): # flags should_activate = False should_move = False # get info we definitely need entity = self.entity pos = matter.get_entitys_component(entity, Position) current_tile = world.get_tile((pos.x, pos.y)) dir_x, dir_y = self.data.direction[0], self.data.direction[1] target_tile = world.get_tile((current_tile.x + dir_x, current_tile.y + dir_y)) # check if already on top of an entity before moving in case something move into the projectile or the projectile was created on top of an entity player_pos = matter.get_entitys_component(matter.get_player(), Position) if world.tile_has_tag(entity, current_tile, TileTag.OTHER_ENTITY): self.data.skill_instance.target_tile = current_tile should_activate = True logging.debug(f"'{matter.get_name(entity)}' collided with an entity on cast at ({pos.x},{pos.y}).") # if we havent travelled max distance or determined we should activate then move # N.b. not an elif because we want the precheck above to happen in isolation if self.distance_travelled < self.data.range and not should_activate: # can we move if world.tile_has_tag(entity, target_tile, TileTag.OPEN_SPACE): should_move = True else: should_activate, should_move = self._handle_collision(current_tile, target_tile) elif self.distance_travelled >= self.data.range: # we have reached the limit, process expiry and then die if self.data.expiry_type == ProjectileExpiry.ACTIVATE: should_activate = True # update skill instance to point to current position, for when it is applied self.data.skill_instance.target_tile = current_tile else: # at max range and not activating so kill attached entity matter.kill_entity(entity) if should_activate: logging.debug(f"'{matter.get_name(entity)}' is going to activate at ({pos.x},{pos.y}).") # apply skill, rather than using it, as the instance already exists and we are just using the effects matter.apply_skill(self.data.skill_instance) # die after activating matter.kill_entity(entity) elif should_move: logging.debug( f"'{matter.get_name(entity)}' has {self.data.range - self.distance_travelled} range left and" f" is going to move from ({pos.x},{pos.y}) to " f"({pos.x + dir_x},{pos.y + dir_y})." ) move = matter.get_known_skill(entity, "Move") matter.use_skill(entity, move, current_tile, self.data.direction) self.distance_travelled += 1 hourglass.end_turn(entity, self.data.speed)
def _handle_collision(self, current_tile: Tile, target_tile: Tile) -> Tuple[bool, bool]: """ Handle collisions, returning should_activate, should_move and updating target tile and direction if needed """ should_activate = should_move = False if world.tile_has_tags(self.entity, target_tile, [TileTag.BLOCKED_MOVEMENT, TileTag.NO_ENTITY]): collision_type = self.data.terrain_collision if collision_type == TerrainCollision.ACTIVATE: should_activate = True # update skill instance to new target assert isinstance(self.data.skill_instance, Skill) self.data.skill_instance.target_tile = target_tile elif collision_type == TerrainCollision.FIZZLE: # get rid of projectile matter.kill_entity(self.entity) elif collision_type == TerrainCollision.REFLECT: should_move = True # change direction and move new_dir = world.get_reflected_direction( self.entity, (current_tile.x, current_tile.y), (target_tile.x, target_tile.y) ) self.data.direction = new_dir # blocked by entity elif world.tile_has_tag(self.entity, target_tile, TileTag.OTHER_ENTITY): should_activate = True # update skill instance to new target assert isinstance(self.data.skill_instance, Skill) self.data.skill_instance.target_tile = target_tile return should_activate, should_move
[docs]class DelayedSkill(Behaviour): """ After duration ends trigger skill centred on self. """
[docs] def __init__(self, attached_entity: EntityID): super().__init__(attached_entity) # N.B. both must be set after init self.data: DelayedSkillData = DelayedSkillData() self.delayed: bool = False
[docs] def act(self): if not self.delayed: # get time left in round, to align first end of turn to round time_left_in_round = hourglass.get_time_left_in_round() from scripts.engine.internal import library time_per_round = library.GAME_CONFIG.default_values.time_per_round # align to round time and add number of rounds as a delay time_delay = (time_per_round * self.data.duration) + time_left_in_round hourglass.end_turn(self.entity, time_delay) # set flag self.delayed = True logging.debug(f"{matter.get_name(self.entity)} will trigger in {self.data.duration} rounds.") return # apply skill, rather than using it, as the instance already exists and we are just using the effects matter.apply_skill(self.data.skill_instance) # die after activating matter.kill_entity(self.entity)