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)