More work on evadventure combathandler

This commit is contained in:
Griatch 2022-03-28 15:11:14 +02:00
parent a7ced1dbfc
commit ab2e84a40f
2 changed files with 315 additions and 41 deletions

View file

@ -328,6 +328,20 @@ class EvAdventureCharacter(DefaultCharacter):
"""
# TODO
def heal(self, hp, healer=None):
"""
Heal the character by a certain amount of HP.
"""
damage = self.hp_max - self.hp
healed = min(damage, hp)
self.hp += healed
if healer is self:
self.msg(f"|gYou heal yourself for {healed} health.|n")
else:
self.msg(f"|g{healer.key} heals you for {healed} health.|n")
def at_pre_object_receive(self, moved_object, source_location, **kwargs):
"""
Hook called by Evennia before moving an object here. Return False to abort move.
@ -378,13 +392,7 @@ class EvAdventureCharacter(DefaultCharacter):
self.equipment.remove(moved_object)
def at_pre_damage(self, dmg, attacker=None):
"""
Called when receiving damage for whatever reason. This
is called *before* hp is evaluated for defeat/death.
"""
def at_post_damage(self, dmg, attacker=None):
def at_damage(self, dmg, attacker=None):
"""
Called when receiving damage for whatever reason. This
is called *before* hp is evaluated for defeat/death.

View file

@ -20,10 +20,13 @@ from collections import defaultdict
from evennia.scripts.scripts import DefaultScript
from evennia.typeclasses.attributes import AttributeProperty
from evennia.utils.utils import make_iter
from evennia.utils import evmenu
from . import rules
MIN_RANGE = 0
MAX_RANGE = 4
MAX_MOVE_RATE = 2
STUNT_DURATION = 2
RANGE_NAMES = {
0: "close", # melee, short weapons, fists. long weapons with disadvantage
@ -34,20 +37,66 @@ RANGE_NAMES = {
}
class AttackFailure(RuntimeError):
class CombatFailure(RuntimeError):
"""
Cannot attack for some reason.
Some failure during actions.
"""
class CombatAction:
"""
This describes a combat-action, like 'attack'.
"""
key = 'action'
status_text = "{combatant} performs an action."
# move actions can be combined with other actions
is_move_action = False
def __init__(self, combathandler):
self.combathandler = combathandler
def can_use(self, combatant, *args, **kwargs):
"""
Determine if combatant can use this action.
"""
return True
def use(self, combatant, *args, **kwargs):
"""
Use action
"""
self.combathandler.msg(self.status_text.format(combatant=combatant))
class CombatActionDoNothing(CombatAction):
"""
Do nothing this turn.
"""
status_text = "{combatant} does nothing this turn."
class CombatActionStunt(CombatAction):
"""
Perform a stunt.
"""
class EvAdventureCombat(DefaultScript):
class EvAdventureCombatHandler(DefaultScript):
"""
This script is created when combat is initialized and stores a queue
of all active participants. It's also possible to join (or leave) the fray later.
"""
combatants = AttributeProperty(default=list())
action_queue = AttributeProperty(default=dict())
combatants = AttributeProperty(list())
action_queue = AttributeProperty(dict())
turn_stats = AttributeProperty(defaultdict(list))
# turn counter - abstract time
turn = AttributeProperty(default=0)
@ -57,7 +106,10 @@ class EvAdventureCombat(DefaultScript):
advantage_matrix = AttributeProperty(defaultdict(dict))
disadvantage_matrix = AttributeProperty(defaultdict(dict))
stunt_duration = 2
disengaging_combatants = AttributeProperty(default=list())
# actions that will be performed before a normal action
move_actions = ("approach", "withdraw")
def _refresh_distance_matrix(self):
"""
@ -103,6 +155,13 @@ class EvAdventureCombat(DefaultScript):
combatant1_distances[combatant2] = start_optimal
combatant2_distances[combatant1] = start_optimal
def _update_turn_stats(self, combatant, message):
"""
Store combat messages to display at the end of turn.
"""
self.turn_stats[combatant].append(message)
def _start_turn(self):
"""
New turn events
@ -110,21 +169,59 @@ class EvAdventureCombat(DefaultScript):
"""
self.turn += 1
self.action_queue = {}
self.turn_stats = defaultdict(list)
def _end_turn(self):
"""
End of turn cleanup.
End of turn operations.
1. Do all moves
2. Do all regular actions
3. Remove combatants that disengaged successfully
4. Timeout advantages/disadvantages set for longer than STUNT_DURATION
"""
# first do all moves
for combatant in self.combatants:
action, args, kwargs = self.action_queue[combatant].get(
"move", ("do_nothing", (), {}))
getattr(self, f"action_{action}")(combatant, *args, **kwargs)
# next do all regular actions
for combatant in self.combatants:
action, args, kwargs = self.action_qeueue[combatant].get(
"action", ("do_nothing", (), {}))
getattr(self, f"action_{action}")(combatant, *args, **kwargs)
# handle disengaging combatants
to_remove = []
for combatant in self.combatants:
# check disengaging combatants (these are combatants that managed
# to stay at disengaging distance for a turn)
if combatant in self.disengaging_combatants:
self.disengaging_combatants.remove(combatant)
to_remove.append(combatant)
elif all(1 for distance in self.distance_matrix[combatant].values()
if distance == MAX_RANGE):
# if at max distance (disengaging) from everyone, they are disengaging
self.disengaging_combatants.append(combatant)
for combatant in to_remove:
# for clarity, we remove here rather than modifying the combatant list
# inside the previous loop
self.msg(f"{combatant.key} disengaged and left combat.")
self.remove_combatant(combatant)
# refresh stunt timeouts
oldest_stunt_age = self.turn - self.stunt_duration
oldest_stunt_age = self.turn - STUNT_DURATION
advantage_matrix = self.advantage_matrix
disadvantage_matrix = self.disadvantage_matrix
# to avoid modifying the dict while we iterate over it, we
# put the results in new dicts. This also avoids us having to
# delete from the old dicts.
# rebuild advantages with the (possibly cropped) list of combatants
# we make new matrices in order to make sure disengaged combatants are
# not included.
new_advantage_matrix = {}
new_disadvantage_matrix = {}
@ -137,9 +234,20 @@ class EvAdventureCombat(DefaultScript):
target: set_at_turn for target, turn in disadvantage_matrix.items()
if set_at_turn > oldest_stunt_age
}
self.advantage_matrix = new_advantage_matrix
self.disadvantage_matrix = new_disadvantage_matrix
def add_combatant(self, combatant):
if combatant not in self.combatants:
self.combatants.append(combatant)
self._refresh_distance_matrix()
def remove_combatant(self, combatant):
if combatant in self.combatants:
self.combatants.remove(combatant)
self._refresh_distance_matrix()
def msg(self, message, targets=None):
"""
Central place for sending messages to combatants. This allows
@ -159,17 +267,8 @@ class EvAdventureCombat(DefaultScript):
for target in self.combatants:
target.msg(message)
def add_combatant(self, combatant):
if combatant not in self.combatants:
self.combatants.append(combatant)
self._refresh_distance_matrix()
def remove_combatant(self, combatant):
if combatant in self.combatants:
self.combatants.remove(combatant)
self._refresh_distance_matrix()
def move_relative_to(self, combatant, target_combatant, change):
def move_relative_to(self, combatant, target_combatant, change,
min_dist=MIN_RANGE, max_dist=MAX_RANGE):
"""
Change the distance to a target.
@ -181,7 +280,9 @@ class EvAdventureCombat(DefaultScript):
"""
current_dist = self.distance_matrix[combatant][target_combatant]
new_dist = max(MIN_RANGE, min(MAX_RANGE, current_dist + change))
change = max(0, min(MAX_MOVE_RATE, change))
new_dist = max(min_dist, min(max_dist, current_dist + change))
self.distance_matrix[combatant][target_combatant] = new_dist
self.distance_matrix[target_combatant][combatant] = new_dist
@ -239,18 +340,41 @@ class EvAdventureCombat(DefaultScript):
# defender still alive
self.msg(defender)
def stunt(self, attacker, defender, attack_type="agility",
defense_type="agility", optimal_distance=0, suboptimal_distance=1,
advantage=True, beneficiary=None):
def register_action(self, combatant, action="do_nothing", *args, **kwargs):
"""
Stunts does not hurt anyone, but are used to give advantage/disadvantage to combatants
Register an action by-name.
Args:
combatant (Object): The one performing the action.
action (str): An available action, will be prepended with `action_` and
used to call the relevant handler on this script.
*args: Will be passed to the action method `action_<action>`.
**kwargs: Will be passed into the action method `action_<action>`.
"""
if action in self.move_actions:
self.action_queue[combatant]["move"] = (action, args, kwargs)
else:
self.action_queue[combatant]["action"] = (action, args, kwargs)
# action verbs. All of these start with action_* and should also accept
# *args, **kwargs so that we can make the call-mechanism generic.
def action_do_nothing(self, combatant, *args, **kwargs):
"""Do nothing for a turn."""
def action_stunt(self, attacker, defender, attack_type="agility",
defense_type="agility", optimal_distance=0, suboptimal_distance=1,
advantage=True, beneficiary=None, *args, **kwargs):
"""
Stunts does not cause damage but are used to give advantage/disadvantage to combatants
for later turns. The 'attacker' here is the one attemting the stunt against the 'defender'.
If successful, advantage is given to attacker against defender and disadvantage to
defender againt attacker. It's also possible to replace the attacker with another combatant
against the defender - allowing to aid/hinder others on the battlefield.
Stunt-modifers last a maximum of two turns and are not additive. Advantages and
disadvantages against the same target cancel each other out.
disadvantages relative to the same target cancel each other out.
Args:
attacker (Object): The one attempting the stunt.
@ -270,12 +394,12 @@ class EvAdventureCombat(DefaultScript):
distance = self.distance_matrix[attacker][defender]
disadvantage = False
if suboptimal_distance == distance:
# fighting at the wrong range is not good
# stunts need to be within range
disadvantage = True
elif self._get_optimal_distance(attacker) != distance:
# if we are neither at optimal nor suboptimal distance, we can't do the stunt
# from here.
raise AttackFailure(f"You can't perform this stunt "
raise CombatFailure(f"You can't perform this stunt "
f"from {RANGE_NAMES[distance]} distance (must be "
f"{RANGE_NAMES[suboptimal_distance]} or, even better, "
f"{RANGE_NAMES[optimal_distance]}).")
@ -295,9 +419,10 @@ class EvAdventureCombat(DefaultScript):
return is_success
def attack(self, attacker, defender):
def action_attack(self, attacker, defender, *args, **kwargs):
"""
Make an attack against a defender. This takes into account distance.
Make an attack against a defender. This takes into account distance. The
attack type/defense depends on the weapon/spell/whatever used.
"""
# check if attacker is at optimal distance
@ -314,7 +439,7 @@ class EvAdventureCombat(DefaultScript):
elif self._get_optimal_distance(attacker) != distance:
# if we are neither at optimal nor suboptimal distance, we can't
# attack from this range
raise AttackFailure(f"You can't attack with {attacker.weapon.key} "
raise CombatFailure(f"You can't attack with {attacker.weapon.key} "
f"from {RANGE_NAMES[distance]} distance.")
is_hit, quality = rules.EvAdventureRollEngine.opposed_saving_throw(
@ -327,3 +452,144 @@ class EvAdventureCombat(DefaultScript):
self.resolve_damage(attacker, defender, critical=quality == "critical success")
return is_hit
def action_heal(self, combatant, target, max_distance=1, healing_roll="1d6", *args, **kwargs):
"""
Heal a target. Target can be the combatant itself.
Args:
combatant (Object): The one performing the heal.
target (Object): The one to be healed (can be the same as combatant).
max_distance (int): Distances *up to* this range allow for healing.
healing_roll (str): The die roll for how many HP to heal.
Raises:
CombatFailure: If too far away to heal target.
"""
if target is not combatant:
distance = self.distance_matrix[attacker][defender]
if distance > max_distance:
raise CombatFailure(f"Too far away to heal {target.key}.")
target.heal(rules.EvAdventureRollEngine.roll(healing_roll), healer=combatant)
def action_approach(self, combatant, other_combatant, change, *args, **kwargs):
"""
Approach target. Closest is 0. This can be combined with another action.
"""
self.move_relative_to(combatant, other_combatant, -abs(change), min_dist=MIN_RANGE)
def action_withdraw(self, combatant, other_combatant, change):
"""
Withdraw from target. Most distant is range 3 - further and you'll be disengaging.
This can be combined with another action.
"""
self.move_relative_to(combatant, other_combatant, abs(change), max_dist=3)
def action_flee(self, combatant, *args, **kwargs):
"""
Fleeing/disengaging from combat means moving towards 'disengaging' range from
everyone else and staying there for one turn.
"""
for other_combatant in self.combatants:
self.move_relative_to(combatant, other_combatant, MAX_MOVE_RATE, max_dist=MAX_RANGE)
def action_chase(self, combatant, fleeing_target, *args, **kwargs):
"""
Chasing is a way to counter a 'flee' action. It is a maximum movement towards the target
and will mean a DEX contest, if the fleeing target loses, they are moved back from
'disengaging' range and remain in combat at the new distance (likely 2 if max movement
is 2). Advantage/disadvantage are considered.
"""
ability = "dexterity"
advantage = bool(self.advantage_matrix[attacker].pop(fleeing_target, False))
disadvantage = bool(self.disadvantage_matrix[attacker].pop(fleeing_target, False))
is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw(
combatant, fleeing_target,
attack_type=ability, defense_type=ability,
advantage=advantage, disadvantage=disadvantage
)
if is_success:
# managed to stop the target from fleeing/disengaging - move closer
if fleeing_target in self.disengaging_combatants:
self.disengaging_combatants.remove(fleeing_target)
self.approach(combatant, fleeing_target, change=MAX_MOVE_RATE)
return is_success
# combat menu
def _register_action(caller, raw_string, **kwargs):
"""
Register action with handler.
"""
action = kwargs.get['action']
action_args = kwargs['action_args']
action_kwargs = kwargs['action_kwargs']
combat = caller.scripts.get("combathandler")
combat.register_action(
caller, action=action, *action_args, **action_kwargs
)
def node_select_target(caller, raw_string, **kwargs):
"""
Menu node allowing for selecting a target among all combatants. This combines
with all other actions.
"""
action = kwargs.get('action')
action_args = kwargs.get('action_args')
action_kwargs = kwargs.get('action_kwargs')
combat = caller.scripts.get("combathandler")
text = "Select target for |w{action}|n."
combatants = [combatant for combatant in combat.combatants if combatant is not caller]
options = [
{
"desc": combatant.key,
"goto": (_register_action, {"action": action,
"args": action_args,
"kwargs": action_kwargs})
}
for combatant in combat.combatants]
# make the apply-self option always the last one
options.append(
{
"desc": "(yourself)",
"goto": (_register_action, {"action": action,
"args": action_args,
"kwargs": action_kwargs})
}
)
return text, options
def node_select_action(caller, raw_string, **kwargs):
"""
Menu node for selecting a combat action.
"""
combat = caller.scripts.get("combathandler")
text = combat.get_previous_turn_status(caller)
options = combat.get_available_options(caller)
# TODO - reshuffle options
options = {
"desc": action,
"goto": ("node_select_target", {"action": action,
})
}
return text, options