from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Tuple, TYPE_CHECKING
from snecs.typedefs import EntityID
import scripts.engine.core.matter
from scripts.engine.core import query, world
from scripts.engine.core.component import Afflictions, CombatStats, Identity, Knowledge, Lifespan, Position, Resources
from scripts.engine.internal import library
from scripts.engine.internal.constant import DamageType, DamageTypeType, Direction, DirectionType, PrimaryStatType, PrimaryStat, TileTag, HitType, HitTypeType
from scripts.engine.internal.event import (
AffectCooldownEvent,
AffectStatEvent,
AfflictionEvent,
AlterTerrainEvent,
DamageEvent,
event_hub,
MoveEvent,
)
if TYPE_CHECKING:
from typing import List, Dict, Optional
__all__ = [
"Effect",
"DamageEffect",
"MoveSelfEffect",
"MoveOtherEffect",
"AffectStatEffect",
"ApplyAfflictionEffect",
"AffectCooldownEffect",
"AlterTerrainEffect",
]
[docs]class Effect(ABC):
"""
A collection of parameters and instructions to apply a change to an entity's or tile's state.
"""
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
potency: float = 1.0,
):
self.origin = origin
self.target = target
self.success_effects: List[Effect] = success_effects
self.failure_effects: List[Effect] = failure_effects
self.potency: float = potency
[docs] @abstractmethod
def evaluate(self):
"""
Evaluate the effect, triggering more if needed. Must be overridden by subclass
"""
pass
[docs]class AftershockEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
):
super().__init__(origin, target, success_effects, failure_effects)
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
center_position = scripts.engine.core.matter.get_entitys_component(self.target, Position)
affected_tiles = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
entities_hit = []
for tile in affected_tiles:
target_tile_pos = (tile[0] + center_position.x, tile[1] + center_position.y)
target_tile = world.get_tile(target_tile_pos)
if world.tile_has_tag(self.origin, target_tile, TileTag.ACTOR):
entities_hit += scripts.engine.core.matter.get_entities_on_tile(target_tile)
for entity in entities_hit:
damage_effect = DamageEffect(
origin=self.origin,
success_effects=[],
failure_effects=[],
target=entity,
stat_to_target=PrimaryStat.VIGOUR,
accuracy=library.GAME_CONFIG.base_values.accuracy,
damage=int(library.GAME_CONFIG.base_values.damage * self.potency),
damage_type=DamageType.MUNDANE,
mod_stat=PrimaryStat.CLOUT,
mod_amount=0.1,
)
self.success_effects.append(damage_effect)
return True, self.success_effects
[docs]class DamageEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
stat_to_target: PrimaryStatType,
accuracy: int,
damage: int,
damage_type: DamageTypeType,
mod_stat: PrimaryStatType,
mod_amount: float,
potency: float = 1.0,
):
super().__init__(origin, target, success_effects, failure_effects, potency)
self.accuracy = accuracy
self.stat_to_target = stat_to_target
self.damage = damage
self.damage_type = damage_type
self.mod_amount = mod_amount
self.mod_stat = mod_stat
self.hit_type_effects: Dict[HitTypeType, List[Effect]] = {HitType.HIT: [], HitType.GRAZE: [], HitType.CRIT: []}
self.force_hit_type: Optional[HitTypeType] = None
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
"""
Resolve the damage effect and return the conditional effects based on if the damage is greater than 0.
"""
logging.debug("Evaluating Damage Effect...")
if self.damage <= 0:
logging.info(f"Damage given to DamageEffect is {self.damage} and was therefore not executed.")
return False, self.failure_effects
elif not scripts.engine.core.matter.entity_has_component(self.target, Resources):
logging.info(f"Target doesnt have resources so damage cannot be applied.")
return False, self.failure_effects
elif not scripts.engine.core.matter.entity_has_component(self.target, CombatStats):
logging.warning(f"Target doesnt have combatstats so damage cannot be calculated.")
return False, self.failure_effects
elif not scripts.engine.core.matter.entity_has_component(self.origin, CombatStats):
logging.warning(f"Attacker doesnt have combatstats so damage cannot be calculated.")
return False, self.failure_effects
# get combat stats
defenders_stats = scripts.engine.core.matter.get_entitys_component(self.target, CombatStats)
attackers_stats = scripts.engine.core.matter.get_entitys_component(self.origin, CombatStats)
# get hit type
stat_to_target_value = getattr(defenders_stats, self.stat_to_target.lower())
to_hit_score = scripts.engine.core.matter.calculate_to_hit_score(
attackers_stats.accuracy, self.accuracy, stat_to_target_value
)
if self.force_hit_type:
hit_type = self.force_hit_type
else:
hit_type = scripts.engine.core.matter.get_hit_type(to_hit_score)
# calculate damage
resist_value = getattr(defenders_stats, "resist_" + self.damage_type.lower())
mod_value = getattr(attackers_stats, self.mod_stat.lower())
damage = scripts.engine.core.matter.calculate_damage(self.damage, mod_value, resist_value, hit_type)
# apply the damage
if scripts.engine.core.matter.apply_damage(self.target, damage):
# add effects relevant to the hit type to the stack
self.success_effects += self.hit_type_effects[hit_type]
defenders_resources = scripts.engine.core.matter.get_entitys_component(self.target, Resources)
# post interaction event
event = DamageEvent(
origin=self.origin,
target=self.target,
amount=damage,
damage_type=self.damage_type,
remaining_hp=defenders_resources.health,
)
event_hub.post(event)
# check if target is dead
if damage >= defenders_resources.health:
scripts.engine.core.matter.kill_entity(self.target)
return True, self.success_effects
else:
return False, self.failure_effects
[docs]class MoveSelfEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
direction: DirectionType,
move_amount: int,
):
super().__init__(origin, target, success_effects, failure_effects)
self.direction = direction
self.move_amount = move_amount
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
"""
Resolve the move effect and return the conditional effects based on if the target moved the full amount.
"""
logging.debug("Evaluating Move Self Effect...")
entity = self.target
success = False
# confirm targeting self
if self.origin != entity:
logging.debug(
f"Failed to move {scripts.engine.core.matter.get_name(entity)} as they are not the originator."
)
return False, self.failure_effects
# check target has position
if not scripts.engine.core.matter.entity_has_component(entity, Position):
logging.debug(f"Failed to move {scripts.engine.core.matter.get_name(entity)} as they have no Position.")
return False, self.failure_effects
dir_x, dir_y = self.direction
pos = scripts.engine.core.matter.get_entitys_component(entity, Position)
# loop each target tile in turn
for _ in range(0, self.move_amount):
new_x = pos.x + dir_x
new_y = pos.y + dir_y
blocked = world.is_direction_blocked(entity, dir_x, dir_y)
success = not blocked
if not blocked:
# named _position as typing was inferring from position above
_position = scripts.engine.core.matter.get_entitys_component(entity, Position)
# update position
if _position:
logging.debug(
f"->'{scripts.engine.core.matter.get_name(self.target)}' moved from ({pos.x},{pos.y}) to ({new_x},"
f"{new_y})."
)
_position.set(new_x, new_y)
# post interaction event
event = MoveEvent(
origin=self.origin, target=self.target, direction=self.direction, new_pos=(new_x, new_y)
)
event_hub.post(event)
success = True
if success:
return True, self.success_effects
else:
return False, self.failure_effects
[docs]class MoveOtherEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
direction: DirectionType,
move_amount: int,
):
super().__init__(origin, target, success_effects, failure_effects)
self.direction = direction
self.move_amount = move_amount
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
"""
Resolve the move effect and return the conditional effects based on if the target moved the full amount.
"""
logging.debug("Evaluating Move Other Effect...")
success = False
entity = self.target
# confirm not targeting self
if self.origin == entity:
logging.debug(f"Failed to move {scripts.engine.core.matter.get_name(entity)} as they are the originator.")
return False, self.failure_effects
# check target has position
if not scripts.engine.core.matter.entity_has_component(entity, Position):
logging.debug(f"Failed to move {scripts.engine.core.matter.get_name(entity)} as they have no Position.")
return False, self.failure_effects
pos = scripts.engine.core.matter.get_entitys_component(entity, Position)
dir_x, dir_y = self.direction
# loop each target tile in turn
for _ in range(0, self.move_amount):
new_x = pos.x + dir_x
new_y = pos.y + dir_y
blocked = world.is_direction_blocked(entity, dir_x, dir_y)
success = not blocked
if not blocked:
# named _position as typing was inferring from position above
_position = scripts.engine.core.matter.get_entitys_component(entity, Position)
# update position
if _position:
logging.debug(
f"->'{scripts.engine.core.matter.get_name(self.target)}' moved from ({pos.x},{pos.y}) to ({new_x},"
f"{new_y})."
)
_position.set(new_x, new_y)
# post interaction event
event = MoveEvent(
origin=self.origin, target=self.target, direction=self.direction, new_pos=(new_x, new_y)
)
event_hub.post(event)
success = True
if success:
return True, self.success_effects
else:
return False, self.failure_effects
[docs]class AffectStatEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
cause_name: str,
stat_to_target: PrimaryStatType,
affect_amount: int,
):
super().__init__(origin, target, success_effects, failure_effects)
self.stat_to_target = stat_to_target
self.affect_amount = affect_amount
self.cause_name = cause_name
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
"""
Log the affliction and the stat modification in the Affliction component.
"""
logging.debug("Evaluating Affect Stat Effect...")
success = False
stats = scripts.engine.core.matter.get_entitys_component(self.target, CombatStats)
# if successfully applied
if stats.add_mod(self.stat_to_target, self.cause_name, self.affect_amount):
# post interaction event
event = AffectStatEvent(
origin=self.origin,
target=self.target,
stat_to_target=self.stat_to_target,
amount=self.affect_amount,
)
event_hub.post(event)
success = True
if success:
return True, self.success_effects
else:
return False, self.failure_effects
[docs]class ApplyAfflictionEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
affliction_name: str,
duration: int,
):
super().__init__(origin, target, success_effects, failure_effects)
self.affliction_name = affliction_name
self.duration = duration
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
"""
Applies an affliction to an entity
"""
logging.debug("Evaluating Apply Affliction Effect...")
affliction_name = self.affliction_name
origin = self.origin
target = self.target
duration = self.duration
affliction_instance = scripts.engine.core.matter.create_affliction(affliction_name, origin, target, duration)
# check for immunities
if scripts.engine.core.matter.entity_has_immunity(target, affliction_name):
logging.debug(
f"'{scripts.engine.core.matter.get_name(self.origin)}' failed to apply {affliction_name} to "
f"'{scripts.engine.core.matter.get_name(self.target)}' as they are immune."
)
return False, self.failure_effects
# add the affliction to the afflictions component
if scripts.engine.core.matter.entity_has_component(target, Afflictions):
afflictions = scripts.engine.core.matter.get_entitys_component(target, Afflictions)
afflictions.add(affliction_instance)
scripts.engine.core.matter.apply_affliction(affliction_instance)
# add immunities to prevent further applications for the duration
scripts.engine.core.matter.add_immunity(target, affliction_name, duration + 2)
# post interaction event
event = AfflictionEvent(
origin=origin,
target=target,
affliction_name=affliction_name,
)
event_hub.post(event)
return True, self.success_effects
# didn't have the component, fail
return False, self.failure_effects
[docs]class AffectCooldownEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
skill_name: str,
affect_amount: int,
):
super().__init__(origin, target, success_effects, failure_effects)
self.skill_name = skill_name
self.affect_amount = affect_amount
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
"""
Reduces the cooldown of a skill of an entity
"""
logging.debug("Evaluating Reduce Skill Cooldown Effect...")
knowledge = scripts.engine.core.matter.get_entitys_component(self.target, Knowledge)
if knowledge:
current_cooldown = knowledge.cooldowns[self.skill_name]
knowledge.set_skill_cooldown(self.skill_name, current_cooldown - self.affect_amount)
# post interaction event
event = AffectCooldownEvent(
origin=self.origin,
target=self.target,
amount=self.affect_amount,
)
event_hub.post(event)
logging.debug(
f"Reduced cooldown of skill '{self.skill_name}' from {current_cooldown} to "
f"{knowledge.cooldowns[self.skill_name]}"
)
return True, self.success_effects
return False, self.failure_effects
[docs]class AlterTerrainEffect(Effect):
[docs] def __init__(
self,
origin: EntityID,
target: EntityID,
success_effects: List[Effect],
failure_effects: List[Effect],
terrain_name: str,
affect_amount: int,
):
super().__init__(origin, target, success_effects, failure_effects)
self.terrain_name = terrain_name
self.affect_amount = affect_amount
[docs] def evaluate(self) -> Tuple[bool, List[Effect]]:
"""
Create or reduce the duration of temporary terrain.
"""
logging.debug("Evaluating Alter Terrain Effect...")
# check duration
if self.affect_amount <= 0:
success = self._reduce_terrain_duration()
else:
success = self._create_terrain()
# return effect sets
if success:
return True, self.success_effects
else:
return False, self.failure_effects
def _create_terrain(self) -> bool:
result = False
duplicate = False
terrain_name = self.terrain_name
target_pos = scripts.engine.core.matter.get_entitys_component(self.target, Position)
# check target location doesnt already have the given terrain
for entity, (position, identity, lifespan) in query.position_and_identity_and_lifespan:
assert isinstance(position, Position)
assert isinstance(identity, Identity)
assert isinstance(lifespan, Lifespan)
if identity.name == terrain_name and position.x == target_pos.x and position.y == target_pos.y:
duplicate = True
break
# can we create the terrain?
if not duplicate:
# create target
terrain_data = library.TERRAIN[terrain_name]
scripts.engine.core.matter.create_terrain(terrain_data, (target_pos.x, target_pos.y), self.affect_amount)
result = True
# post interaction event
event = AlterTerrainEvent(
origin=self.origin, target=self.target, terrain_name=self.terrain_name, duration=self.affect_amount
)
event_hub.post(event)
return result
def _reduce_terrain_duration(self) -> bool:
result = False
terrain_name = self.terrain_name
target_pos = scripts.engine.core.matter.get_entitys_component(self.target, Position)
# check there is a terrain at target to reduce duration of
for entity, (position, identity, lifespan) in query.position_and_identity_and_lifespan:
assert isinstance(position, Position)
assert isinstance(identity, Identity)
assert isinstance(lifespan, Lifespan)
if identity.name == terrain_name and position.x == target_pos.x and position.y == target_pos.y:
lifespan.duration -= self.affect_amount
result = True
break
if result:
# post interaction event
event = AlterTerrainEvent(
origin=self.origin, target=self.target, terrain_name=self.terrain_name, duration=self.affect_amount
)
event_hub.post(event)
return result