evennia/evennia/contrib/tutorials/evadventure/combat_turnbased.py
2022-05-26 16:23:11 +02:00

589 lines
19 KiB
Python

"""
EvAdventure turn-based combat
This implements a turn-based combat style, where both sides have a little longer time to
choose their next action. If they don't react before a timer runs out, the previous action
will be repeated. This means that a 'twitch' style combat can be created using the same
mechanism, by just speeding up each 'turn'.
The combat is handled with a `Script` shared between all combatants; this tracks the state
of combat and handles all timing elements.
Unlike in base _Knave_, the MUD version's combat is simultaneous; everyone plans and executes
their turns simultaneously with minimum downtime.
This version is simplified to not worry about things like optimal range etc. So a bow can be used
the same as a sword in battle. One could add a 1D range mechanism to add more strategy by requiring
optimizal positioning.
"""
from dataclasses import dataclass
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, evtable
from .enums import Ability
from . import rules
class CombatFailure(RuntimeError):
"""
Some failure during actions.
"""
class CombatAction:
"""
This is the base of a combat-action, like 'attack' or defend.
Inherit from this to make new actions.
"""
key = 'action'
help_text = "Combat action to perform."
# action to echo to everyone.
post_action_text = "{combatant} performed an action."
max_uses = None # None for unlimited
# in which order (highest first) to perform the action. If identical, use random order
priority = 0
def __init__(self, combathandler, combatant):
self.combathandler = combathandler
self.combatant = combatant
self.uses = 0
def msg(self, message, broadcast=False):
if broadcast:
# send to everyone in combat.
self.combathandler.msg(message)
else:
# send only to the combatant.
self.combatant.msg(message)
def get_help(self, *args, **kwargs):
return self.help_text
def can_use(self, combatant, *args, **kwargs):
"""
Determine if combatant can use this action. In this implementation,
it fails if already use all of a usage-limited action.
Args:
combatant (Object): The one performing the action.
*args: Any optional arguments.
**kwargs: Any optional keyword arguments.
Returns:
tuple: (bool, motivation) - if not available, will describe why,
if available, should describe what the action does.
"""
return True if self.uses is None else self.uses < self.max_uses
def pre_use(self, *args, **kwargs):
pass
def use(self, *args, **kwargs):
pass
def post_use(self, *args, **kwargs):
self.uses += 1
self.combathandler.msg(self.post_action_text.format(combatant=combatant))
class CombatActionDoNothing(CombatAction):
"""
Do nothing this turn.
"""
help_text = "Hold you position, doing nothing."
post_action_text = "{combatant} does nothing this turn."
class CombatActionStunt(CombatAction):
"""
Perform a stunt. A stunt grants an advantage to yours or another player for their next
action, or a disadvantage to yours or an enemy's next action.
Note that while the check happens between the user and a target, another (the 'beneficiary'
could still gain the effect. This allows for boosting allies or making them better
defend against an enemy.
Note: We only count a use if the stunt is successful; they will still spend their turn, but won't
spend a use unless they succeed.
"""
give_advantage = True
give_disadvantage = False
max_uses = 1
priority = -1
# how many turns the stunt's effect apply (that is, how quickly it must be used before the
# advantage/disadvantage is lost).
duration = 5
attack_type = Ability.DEX
defense_type = Ability.DEX
help_text = ("Perform a stunt against a target. This will give you or an ally advantage "
"on your next action against the same target [range 0-1, one use per combat. "
"Bonus lasts for two turns].")
def use(self, attacker, defender, *args, beneficiary=None, **kwargs):
# quality doesn't matter for stunts, they are either successful or not
is_success, _ = rules.EvAdventureRollEngine.opposed_saving_throw(
attacker, defender,
attack_type=self.attack_type,
defense_type=self.defense_type,
advantage=False, disadvantage=disadvantage,
)
if is_success:
beneficiary = beneficiary if beneficiary else attacker
if advantage:
self.combathandler.gain_advantage(beneficiary, defender)
else:
self.combathandler.gain_disadvantage(defender, beneficiary)
self.msg
# only spend a use after being successful
uses += 1
class CombatActionAttack(CombatAction):
"""
A regular attack, using a wielded weapon. Depending on weapon type, this will be a ranged or
melee attack.
"""
key = "attack"
priority = 1
def use(self, attacker, defender, *args, **kwargs):
"""
Make an attack against a defender.
"""
# figure out advantage (gained by previous stunts)
advantage = bool(self.combathandler.advantage_matrix[attacker].pop(defender, False))
# figure out disadvantage (by distance or by previous action)
disadvantage = bool(self.combathandler.disadvantage_matrix[attacker].pop(defender, False))
is_hit, quality = rules.EvAdventureRollEngine.opposed_saving_throw(
attacker, defender,
attack_type=attacker.weapon.attack_type,
defense_type=attacker.weapon.defense_type,
advantage=advantage, disadvantage=disadvantage
)
if is_hit:
self.combathandler.resolve_damage(attacker, defender,
critical=quality == "critical success")
# TODO messaging here
class CombatActionUseItem(CombatAction):
"""
Use an item in combat. This is meant for one-off or limited-use items, like potions, scrolls or
wands. We offload the usage checks and usability to the item's own hooks. It's generated dynamically
from the items in the character's inventory (you could also consider using items in the room this way).
Each usable item results in one possible action.
It relies on the combat_* hooks on the item:
combat_get_help
combat_can_use
combat_pre_use
combat_pre
combat_post_use
"""
def get_help(self, item, *args):
return item.combat_get_help(*args)
def can_use(self, item, combatant, *args, **kwargs):
return item.combat_can_use(combatant, self.combathandler, *args, **kwargs)
def pre_use(self, item, *args, **kwargs):
item.combat_pre_use(*args, **kwargs)
def use(self, item, combatant, target, *args, **kwargs):
item.combat_use(combatant, target, *args, **kwargs)
def post_use(self, item, *args, **kwargs):
item.combat_post_use(*args, **kwargs)
class CombatActionFlee(CombatAction):
"""
Fleeing/disengaging from combat means doing nothing but 'running away' for two turn. Unless
someone attempts and succeeds in their 'chase' action, you will leave combat by fleeing at the
end of the second turn.
"""
key = "flee"
priority = -1
def use(self, combatant, target, *args, **kwargs):
# it's safe to do this twice
self.combathandler.flee(combatant)
class CombatActionChase(CombatAction):
"""
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.
"""
key = "chase"
priority = -5 # checked last
attack_type = Ability.DEX # or is it CON?
defense_type = Ability.DEX
def use(self, combatant, fleeing_target, *args, **kwargs):
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=self.attack_type, defense_type=self.defense_type,
advantage=advantage, disadvantage=disadvantage
)
if is_success:
# managed to stop the target from fleeing/disengaging
self.combatant.unflee(fleeing_target)
else:
pass # they are getting away!
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.
"""
# these will all be checked if they are available at a given time.
all_action_classes = [
CombatActionDoNothing,
CombatActionChase,
CombatActionUseItem,
CombatActionStunt,
CombatActionAttack
]
# attributes
# stores all combatants active in the combat
combatants = AttributeProperty(list())
combatant_actions = AttributeProperty(defaultdict(dict))
action_queue = AttributeProperty(dict())
turn_stats = AttributeProperty(defaultdict(list))
# turn counter - abstract time
turn = AttributeProperty(default=0)
# advantages or disadvantages gained against different targets
advantage_matrix = AttributeProperty(defaultdict(dict))
disadvantage_matrix = AttributeProperty(defaultdict(dict))
fleeing_combatants = AttributeProperty(default=list())
# actions that will be performed before a normal action
move_actions = ("approach", "withdraw")
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
"""
self.turn += 1
self.action_queue = {}
self.turn_stats = defaultdict(list)
def _end_turn(self):
"""
End of turn operations.
1. Do all regular actions
2. Remove combatants that disengaged successfully
3. Timeout advantages/disadvantages
"""
# do all actions
for combatant in self.combatants:
# read the current action type selected by the player
action_class, args, kwargs = self.action_queue[combatant]
# get the already initialized CombatAction instance (where state can be tracked)
action = self.combatant_actions[combatant][action_class]
# perform the action on the CombatAction instance
action.use(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.fleeing_combatants:
self.disengaging_combatants.remove(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 - STUNT_DURATION
advantage_matrix = self.advantage_matrix
disadvantage_matrix = self.disadvantage_matrix
# 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 = {}
for combatant in self.combatants:
new_advantage_matrix[combatant] = {
target: set_at_turn for target, turn in advantage_matrix.items()
if set_at_turn > oldest_stunt_age
}
new_disadvantage_matrix[combatant] = {
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)
for action_class in self.all_action_classes:
self.combatant_actions[combatant][action_class] = action_class(self, combatant)
def remove_combatant(self, combatant):
if combatant in self.combatants:
self.combatants.remove(combatant)
self.combatant_actions[combatant][action_class].pop(None)
def get_combat_summary(self, combatant):
"""
Get a summary of the current combat state from the perspective of a
given combatant.
You (5/10 health)
Foo (Hurt) [Running away - use 'chase' to stop them!]
Bar (Perfect health)
"""
table = evtable.EvTable(border_width=0)
# 'You' display
fleeing = ""
if combatant in self.fleeing_combatants:
fleeing = " You are running away! Use 'flee' again next turn."
table.add_row(f"You ({combatant.hp} / {combatant.hp_max} health){fleeing}")
for comb in self.combatants:
if comb is combatant:
continue
name = combatant.key
health = f"{comb.hurt_level}"
fleeing = ""
if comb in self.fleeing_combatants:
fleeing = " [Running away! Use 'chase' to stop them!"
table.add_row(f"{name} ({health}){fleeing}")
return str(table)
def msg(self, message, targets=None):
"""
Central place for sending messages to combatants. This allows
for adding any combat-specific text-decoration in one place.
Args:
message (str): The message to send.
targets (Object or list, optional): Sends message only to
one or more particular combatants. If unset, send to
everyone in the combat.
"""
if targets:
for target in make_iter(targets):
target.msg(message)
else:
for target in self.combatants:
target.msg(message)
def gain_advantage(self, combatant, target):
"""
Gain advantage against target. Spent by actions.
"""
self.advantage_matrix[combatant][target] = self.turn
def gain_disadvantage(self, combatant, target):
"""
Gain disadvantage against target. Spent by actions.
"""
self.disadvantage_matrix[combatant][target] = self.turn
def flee(self, combatant):
if combatant not in self.fleeing_combatants:
self.fleeing_combatants.append(combatant)
def unflee(self, combatant):
if combatant in self.fleeing_combatants:
self.fleeing_combatants.remove(combatant)
def resolve_damage(self, attacker, defender, critical=False):
"""
Apply damage to defender. On a critical hit, the damage die
is rolled twice.
"""
weapon_dmg_roll = attacker.weapon.damage_roll
dmg = rules.EvAdventureRollEngine.roll(weapon_dmg_roll)
if critical:
dmg += rules.EvAdventureRollEngine.roll(weapon_dmg_roll)
defender.hp -= dmg
# call hook
defender.at_damage(dmg, attacker=attacker)
if defender.hp <= 0:
# roll on death table. This may or may not kill you
rules.EvAdventureRollEngine.roll_death(self)
# tell everyone
self.msg(defender.defeat_message(attacker, dmg))
if defender.hp > 0:
# they are weakened, but with hp
self.msg("You are alive, but out of the fight. If you want to press your luck, "
"you need to rejoin the combat.", targets=defender)
defender.at_defeat() # note - NPC monsters may still 'die' here
else:
# outright killed
defender.at_death()
# no matter the result, the combatant is out
self.remove_combatant(defender)
else:
# defender still alive
self.msg(defender)
def register_action(self, combatant, action=None, *args, **kwargs):
"""
Register an action by-name.
Args:
combatant (Object): The one performing the action.
action (CombatAction): An available action, will be prepended with `action_` and
used to call the relevant handler on this script.
"""
if not action:
action = CombatActionDoNothing
self.action_queue[combatant] = (action, args, kwargs)
# combat menu
combat_script = """
"""
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)
options = {
"desc": action,
"goto": ("node_select_target", {"action": action,
})
}
return text, options