mirror of
https://github.com/evennia/evennia.git
synced 2026-03-31 21:17:17 +02:00
Rebuild/cleanup evadventure combat handler
This commit is contained in:
parent
96c8e78aba
commit
a5afa75f59
3 changed files with 521 additions and 6 deletions
|
|
@ -99,16 +99,19 @@ Choose who to block:
|
|||
|
||||
"""
|
||||
|
||||
import random
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from evennia.scripts.scripts import DefaultScript
|
||||
from evennia.typeclasses.attributes import AttributeProperty
|
||||
from evennia.utils import dbserialize, delay, evmenu, evtable, logger
|
||||
from evennia.utils.utils import inherits_from
|
||||
from evennia.utils.utils import inherits_from, list_to_string
|
||||
|
||||
from . import rules
|
||||
from .enums import Ability
|
||||
from .characters import EvAdventureCharacter
|
||||
from .enums import Ability, ObjType
|
||||
from .npcs import EvAdventureNPC
|
||||
from .objects import EvAdventureObject
|
||||
|
||||
COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler"
|
||||
COMBAT_HANDLER_INTERVAL = 30
|
||||
|
|
@ -123,6 +126,346 @@ class CombatFailure(RuntimeError):
|
|||
"""
|
||||
|
||||
|
||||
# Combat action classes
|
||||
|
||||
|
||||
class CombatAction:
|
||||
"""
|
||||
Parent class for all actions.
|
||||
|
||||
This represents the executable code to run to perform an action. It is initialized from an
|
||||
'action-dict', a set of properties stored in the action queue by each combatant.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, combathandler, combatant, action_dict):
|
||||
"""
|
||||
Each key-value pair in the action-dict is stored as a property on this class
|
||||
for later access.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing
|
||||
the action.
|
||||
action_dict (dict): A dict containing all properties to initialize on this
|
||||
class. This should not be any keys with `_` prefix, since these are
|
||||
used internally by the class.
|
||||
|
||||
"""
|
||||
self.combathandler = combathandler
|
||||
self.combatant = combatant
|
||||
|
||||
for key, val in action_dict.items():
|
||||
setattr(self, key, val)
|
||||
|
||||
# advantage / disadvantage
|
||||
# These should be read as 'does <recipient> have dis/advantaget against <target>'.
|
||||
def give_advantage(self, recipient, target, **kwargs):
|
||||
self.combathandler.advantage_matrix[recipient][target] = True
|
||||
|
||||
def give_disadvantage(self, recipient, target, **kwargs):
|
||||
self.combathandler.disadvantage_matrix[recipient][target] = True
|
||||
|
||||
def has_advantage(self, recipient, target):
|
||||
return bool(self.combathandler.advantage_matrix[recipient].pop(target, False))
|
||||
|
||||
def has_disadvantage(self, recipient, target):
|
||||
return bool(self.combathandler.disadvantage_matrix[recipient].pop(target, False))
|
||||
|
||||
def lose_advantage(self, recipient, target):
|
||||
self.combathandler.advantage_matrix[recipient][target] = False
|
||||
|
||||
def lose_disadvantage(self, recipient, target):
|
||||
self.combathandler.disadvantage_matrix[recipient][target] = False
|
||||
|
||||
def flee(self, fleer):
|
||||
if fleer not in self.combathandler.fleeing_combatants:
|
||||
# we record the turn on which we started fleeing
|
||||
self.combathandler.fleeing_combatants[fleer] = self.combathandler.turn
|
||||
|
||||
def unflee(self, fleer):
|
||||
self.combathandler.fleeing_combatants.pop(fleer, None)
|
||||
|
||||
def msg(self, message, broadcast=True):
|
||||
"""
|
||||
Convenience route to the combathandler msg-sender mechanism.
|
||||
|
||||
Args:
|
||||
message (str): Message to send; use `$You()` and `$You(other.key)` to refer to
|
||||
the combatant doing the action and other combatants, respectively.
|
||||
|
||||
"""
|
||||
self.combathandler.msg(self, message, combatant=self.combatant, broadcast=broadcast)
|
||||
|
||||
def can_use(self):
|
||||
"""
|
||||
Called to determine if the action is usable with the current settings. This does not
|
||||
actually perform the action.
|
||||
|
||||
Returns:
|
||||
bool: If this action can be used at this time.
|
||||
|
||||
"""
|
||||
return True
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
Perform the action as the combatant. Should normally make use of the properties
|
||||
stored on the class during initialization.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CombatActionDoNothing(CombatAction):
|
||||
"""
|
||||
Action that does nothing.
|
||||
|
||||
Note:
|
||||
Refer to as 'nothing'
|
||||
"""
|
||||
|
||||
|
||||
class CombatActionAttack(CombatAction):
|
||||
"""
|
||||
A regular attack, using a wielded weapon.
|
||||
|
||||
action-dict ('attack')
|
||||
{
|
||||
"defender": Character/Object
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'attack'
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
attacker = self.combatant
|
||||
weapon = attacker.weapon
|
||||
defender = self.defender
|
||||
|
||||
is_hit, quality, txt = rules.dice.opposed_saving_throw(
|
||||
attacker,
|
||||
defender,
|
||||
attack_type=weapon.attack_type,
|
||||
defense_type=attacker.weapon.defense_type,
|
||||
advantage=self.has_advantage(attacker, defender),
|
||||
disadvantage=self.has_disadvantage(attacker, defender),
|
||||
)
|
||||
self.msg(f"$You() $conj(attack) $You({defender.key}) with {weapon.key}: {txt}")
|
||||
if is_hit:
|
||||
# enemy hit, calculate damage
|
||||
weapon_dmg_roll = attacker.weapon.damage_roll
|
||||
|
||||
dmg = rules.dice.roll(weapon_dmg_roll)
|
||||
|
||||
if quality is Ability.CRITICAL_SUCCESS:
|
||||
dmg += rules.dice.roll(weapon_dmg_roll)
|
||||
message = (
|
||||
f" $You() |ycritically|n $conj(hit) $You({defender.key}) for |r{dmg}|n damage!"
|
||||
)
|
||||
else:
|
||||
message = f" $You() $conj(hit) $You({defender.key}) for |r{dmg}|n damage!"
|
||||
self.msg(message)
|
||||
|
||||
# call hook
|
||||
defender.at_damage(dmg, attacker=attacker)
|
||||
|
||||
# note that we mustn't remove anyone from combat yet, because this is
|
||||
# happening simultaneously. So checking of the final hp
|
||||
# and rolling of death etc happens in the combathandler at the end of the turn.
|
||||
|
||||
else:
|
||||
# a miss
|
||||
message = f" $You() $conj(miss) $You({defender.key})."
|
||||
if quality is Ability.CRITICAL_FAILURE:
|
||||
attacker.weapon.quality -= 1
|
||||
message += ".. it's a |rcritical miss!|n, damaging the weapon."
|
||||
self.msg(message)
|
||||
|
||||
|
||||
class CombatActionStunt(CombatAction):
|
||||
"""
|
||||
Perform a stunt the grants a beneficiary (can be self) advantage on their next action against a
|
||||
target. Whenever performing a stunt that would affect another negatively (giving them disadvantage
|
||||
against an ally, or granting an advantage against them, we need to make a check first. We don't
|
||||
do a check if giving an advantage to an ally or ourselves.
|
||||
|
||||
action_dict:
|
||||
{
|
||||
"recipient": Character/NPC,
|
||||
"target": Character/NPC,
|
||||
"advantage": bool, # if False, it's a disadvantage
|
||||
"stunt_type": Ability, # what ability (like STR, DEX etc) to use to perform this stunt.
|
||||
"defense_type": Ability, # what ability to use to defend against (negative) effects of this
|
||||
stunt.
|
||||
}
|
||||
|
||||
Note:
|
||||
refer to as 'stunt'.
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
attacker = self.combatant
|
||||
recipient = self.recipient # the one to receive the effect of the stunt
|
||||
target = self.target # the affected by the stunt (can be the same as recipient/combatant)
|
||||
is_success = False
|
||||
|
||||
if target == self.combatant:
|
||||
# can always grant dis/advantage against yourself
|
||||
defender = attacker
|
||||
is_success = True
|
||||
elif recipient == target:
|
||||
# grant another entity dis/advantage against themselves
|
||||
defender = recipient
|
||||
else:
|
||||
# recipient not same as target; who will defend depends on disadvantage or advantage
|
||||
# to give.
|
||||
defender = target if self.advantage else recipient
|
||||
|
||||
if not is_success:
|
||||
# trying to give advantage to recipient against target. Target defends against caller
|
||||
is_success, _, txt = rules.dice.opposed_saving_throw(
|
||||
attacker,
|
||||
defender,
|
||||
attack_type=self.stunt_type,
|
||||
defense_type=self.defense_type,
|
||||
advantage=self.has_advantage(attacker, defender),
|
||||
disadvantage=self.has_disadvantage(attacker, defender),
|
||||
)
|
||||
|
||||
# deal with results
|
||||
self.msg(f"$You() $conj(attempt) stunt on $You(defender.key). {txt}")
|
||||
if is_success:
|
||||
if self.advantage:
|
||||
self.give_advantage(recipient, target)
|
||||
else:
|
||||
self.give_disadvantage(recipient, target)
|
||||
self.msg(
|
||||
f"%You() $conj(cause) $You({recipient.key}) "
|
||||
f"to gain {'advantage' if self.advantage else 'disadvantage'} "
|
||||
f"against $You({target.key})!"
|
||||
)
|
||||
else:
|
||||
self.msg(f"$You({target.key}) resists! $You() $conj(fail) the stunt.")
|
||||
|
||||
|
||||
class CombatActionUseItem(CombatAction):
|
||||
"""
|
||||
Use an item in combat. This is meant for one-off or limited-use items (so things like
|
||||
scrolls and potions, not swords and shields). If this is some sort of weapon or spell rune,
|
||||
we refer to the item to determine what to use for attack/defense rolls.
|
||||
|
||||
action_dict: }
|
||||
"item": Object
|
||||
"target": Character/NPC/Object
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'use'
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
|
||||
item = self.item
|
||||
user = self.combatant
|
||||
target = self.target
|
||||
|
||||
if user == target:
|
||||
# always manage to use the item on yourself
|
||||
is_success = True
|
||||
else:
|
||||
if item.has_obj_type(ObjType.WEAPON):
|
||||
# this is something that harms the target. We need to roll defense
|
||||
is_success, _, txt = rules.dice.opposed_saving_throw(
|
||||
user,
|
||||
target,
|
||||
attack_type=item.attack_type,
|
||||
defense_type=item.defense_type,
|
||||
advantage=self.has_advantage(user, target),
|
||||
disadvantage=self.has_disadvantage(user, target),
|
||||
)
|
||||
|
||||
item.at_use(self.combatant, self.target)
|
||||
|
||||
|
||||
class CombatActionWield(CombatAction):
|
||||
"""
|
||||
Wield a new weapon (or spell) from your inventory. This will swap out the one you are currently
|
||||
wielding, if any.
|
||||
|
||||
action_dict = {
|
||||
"item": Object
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'wield'.
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
self.combatant.equipment.move(self.item)
|
||||
|
||||
|
||||
class CombatActionFlee(CombatAction):
|
||||
"""
|
||||
Start (or continue) fleeing/disengaging from combat.
|
||||
|
||||
Note:
|
||||
Refer to as 'flee'.
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
self.msg(
|
||||
"$You() $conj(retreat), and will leave combat next round unless someone successfully "
|
||||
"blocks the escape."
|
||||
)
|
||||
self.flee(self.combatant)
|
||||
|
||||
|
||||
class CombatActionHinder(CombatAction):
|
||||
"""
|
||||
Hinder a fleeing opponent from fleeing/disengaging from combat.
|
||||
|
||||
action_dict = {
|
||||
"target": Character/NPC
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'hinder'
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
|
||||
hinderer = self.combatant
|
||||
target = self.target
|
||||
|
||||
is_success, _, txt = rules.dice.opposed_saving_throw(
|
||||
hinderer,
|
||||
target,
|
||||
attack_type=Ability.DEX,
|
||||
defense_type=Ability.DEX,
|
||||
advantage=self.has_advantage(hinderer, target),
|
||||
disadvantage=self.has_disadvantage(hinderer, target),
|
||||
)
|
||||
|
||||
# handle result
|
||||
self.msg(
|
||||
f"$You() $conj(try) to block the retreat of $You({target.key}). {txt}",
|
||||
)
|
||||
if is_success:
|
||||
# managed to stop the target from fleeing/disengaging
|
||||
self.unflee(target)
|
||||
self.msg(f"$You() $conj(block) the retreat of $You({target.key})")
|
||||
else:
|
||||
# failed to hinder the target
|
||||
self.msg(f"$You({target.key}) $conj(dodge) away from you $You()!")
|
||||
|
||||
|
||||
class EvAdventureCombatHandler(DefaultScript):
|
||||
"""
|
||||
This script is created when a combat starts. It 'ticks' the combat and tracks
|
||||
|
|
@ -134,12 +477,59 @@ class EvAdventureCombatHandler(DefaultScript):
|
|||
max_action_queue_size = 1
|
||||
|
||||
# available actions
|
||||
action_classes = {}
|
||||
action_classes = {
|
||||
"nothing": CombatActionDoNothing,
|
||||
"attack": CombatActionAttack,
|
||||
}
|
||||
|
||||
# fallback action if not selecting anything
|
||||
fallback_action = "attack"
|
||||
|
||||
# persistent storage
|
||||
turn = AttributeProperty(0)
|
||||
|
||||
# who is involved in combat, and their action queue,
|
||||
# as {combatant: [actiondict, actiondict,...]}
|
||||
combatants = AttributeProperty(defaultdict(list))
|
||||
|
||||
advantage_matrix = AttributeProperty(defaultdict(dict))
|
||||
disadvantage_matrix = AttributeProperty(defaultdict(dict))
|
||||
|
||||
fleeing_combatants = AttributeProperty(dict)
|
||||
defeated_combatants = AttributeProperty(dict)
|
||||
|
||||
def msg(self, message, combatant=None, broadcast=True):
|
||||
"""
|
||||
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.
|
||||
combatant (Object): The 'You' in the message, if any.
|
||||
broadcast (bool): If `False`, `combatant` must be included and
|
||||
will be the only one to see the message. If `True`, send to
|
||||
everyone in the location.
|
||||
|
||||
Notes:
|
||||
If `combatant` is given, use `$You/you()` markup to create
|
||||
a message that looks different depending on who sees it. Use
|
||||
`$You(combatant_key)` to refer to other combatants.
|
||||
|
||||
"""
|
||||
location = self.obj
|
||||
location_objs = location.contents
|
||||
|
||||
exclude = []
|
||||
if not broadcast and combatant:
|
||||
exclude = [obj for obj in location_objs if obj is not combatant]
|
||||
|
||||
location.msg_contents(
|
||||
message,
|
||||
exclude=exclude,
|
||||
from_obj=combatant,
|
||||
mapping={locobj.key: locobj for locobj in location_objs},
|
||||
)
|
||||
|
||||
def add_combatant(self, combatant):
|
||||
"""
|
||||
Add a new combatant to the battle.
|
||||
|
|
@ -163,6 +553,52 @@ class EvAdventureCombatHandler(DefaultScript):
|
|||
"""
|
||||
self.combatants.pop(combatant, None)
|
||||
|
||||
def stop_combat(self):
|
||||
"""
|
||||
Stop the combat immediately.
|
||||
|
||||
"""
|
||||
for combatant in self.combatants:
|
||||
self.remove_combatant(combatant)
|
||||
self.stop()
|
||||
self.delete()
|
||||
|
||||
def get_sides(self, combatant):
|
||||
"""
|
||||
Get a listing of the two 'sides' of this combat, from the perspective of the provided
|
||||
combatant. The sides don't need to be balanced.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The one whose sides are to determined.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple of lists `(allies, enemies)`, from the perspective of `combatant`.
|
||||
|
||||
Note:
|
||||
The sides are found by checking PCs vs NPCs. PCs can normally not attack other PCs, so
|
||||
are naturally allies. If the current room has the `allow_pvp` Attribute set, then _all_
|
||||
other combatants (PCs and NPCs alike) are considered valid enemies (one could expand
|
||||
this with group mechanics).
|
||||
|
||||
"""
|
||||
if self.obj.allow_pvp:
|
||||
# in pvp, everyone else is an ememy
|
||||
allies = [combatant]
|
||||
enemies = [comb for comb in self.combatants if comb != combatant]
|
||||
else:
|
||||
# otherwise, enemies/allies depend on who combatant is
|
||||
pcs = [comb for comb in self.combatants if inherits_from(comb, EvAdventureCharacter)]
|
||||
npcs = [comb for comb in self.combatants if comb not in pcs]
|
||||
if combatant in pcs:
|
||||
# combatant is a PC, so NPCs are all enemies
|
||||
allies = [comb for comb in pcs if comb != combatant]
|
||||
enemies = npcs
|
||||
else:
|
||||
# combatant is an NPC, so PCs are all enemies
|
||||
allies = [comb for comb in npcs if comb != combatant]
|
||||
enemies = pcs
|
||||
return allies, enemies
|
||||
|
||||
def queue_action(self, combatant, action_dict):
|
||||
"""
|
||||
Queue an action by adding the new actiondict to the back of the queue. If the
|
||||
|
|
@ -181,7 +617,7 @@ class EvAdventureCombatHandler(DefaultScript):
|
|||
"""
|
||||
self.combatants[combatant].append(action_dict)
|
||||
|
||||
def do_next_action(self, combatant):
|
||||
def execute_next_action(self, combatant):
|
||||
"""
|
||||
Perform a combatant's next queued action. Note that there is _always_ an action queued,
|
||||
even if this action is 'do nothing'. We don't pop anything from the queue, instead we keep
|
||||
|
|
@ -204,9 +640,72 @@ class EvAdventureCombatHandler(DefaultScript):
|
|||
|
||||
# use the action-dict to select and create an action from an action class
|
||||
action_class = self.action_classes[action_dict["key"]]
|
||||
action = action_class(**action_dict)
|
||||
action = action_class(combatant, action_dict)
|
||||
|
||||
action.execute(combatant)
|
||||
action.execute()
|
||||
|
||||
def execute_full_turn(self):
|
||||
"""
|
||||
Perform a full turn of combat, performing everyone's actions in random order.
|
||||
|
||||
"""
|
||||
self.turn += 1
|
||||
# random turn order
|
||||
combatants = random.shuffle(list(self.combatants.keys()))
|
||||
|
||||
# do everyone's next queued combat action
|
||||
for combatant in combatants:
|
||||
self.execute_next_action(combatant)
|
||||
|
||||
# check if anyone is defeated
|
||||
for combatant in list(self.combatants.keys()):
|
||||
if combatant.hp <= 0:
|
||||
# PCs roll on the death table here, NPCs die. Even if PCs survive, they
|
||||
# are still out of the fight.
|
||||
combatant.at_defeat()
|
||||
self.defeated_combatants.append(self.combatant.pop(combatant))
|
||||
self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant)
|
||||
|
||||
# check if anyone managed to flee
|
||||
for combatant, started_fleeing in dict(self.fleeing_combatants):
|
||||
if self.turn - started_fleeing > 1:
|
||||
# if they are still alive/fleeing and started fleeing >1 round ago, they succeed
|
||||
self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant)
|
||||
self.remove_combatant(combatant)
|
||||
|
||||
# check if one side won the battle
|
||||
if not self.combatants:
|
||||
# noone left in combat - maybe they killed each other or all fled
|
||||
surviving_combatant = None
|
||||
allies, enemies = (), ()
|
||||
else:
|
||||
# grab a random survivor and check of they have any living enemies.
|
||||
surviving_combatant = random.choice(list(self.combatant.keys()))
|
||||
allies, enemies = self.get_sides(surviving_combatant)
|
||||
|
||||
if not enemies:
|
||||
# one way or another, there are no more enemies to fight
|
||||
still_standing = list_to_string(f"$You({comb.key})" for comb in allies)
|
||||
knocked_out = list_to_string(
|
||||
f"$You({comb.key})" for comb in self.defeated_combatants if comb.hp > 0
|
||||
)
|
||||
killed = list_to_string(
|
||||
comb for comb in self.defeated_combatants if comb not in knocked_out
|
||||
)
|
||||
|
||||
if still_standing:
|
||||
txt = [f"The combat is over. {still_standing} are still standing."]
|
||||
else:
|
||||
txt = ["The combat is over. No-one stands as the victor."]
|
||||
if knocked_out:
|
||||
txt.append(f"{knocked_out} were taken down, but will live.")
|
||||
if killed:
|
||||
txt.append(f"{killed} were killed.")
|
||||
self.msg(txt)
|
||||
self.stop_combat()
|
||||
|
||||
|
||||
# Command-based combat commands
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class ObjType(Enum):
|
|||
HELMET = "helmet"
|
||||
CONSUMABLE = "consumable"
|
||||
GEAR = "gear"
|
||||
THROWABLE = "throwable"
|
||||
MAGIC = "magic"
|
||||
QUEST = "quest"
|
||||
TREASURE = "treasure"
|
||||
|
|
|
|||
|
|
@ -146,6 +146,21 @@ class EvAdventureConsumable(EvAdventureObject):
|
|||
self.delete()
|
||||
|
||||
|
||||
class EvAdventureThrowable(EvAdventureWeapon, EvAdventureConsumable):
|
||||
"""
|
||||
Something you can throw at an enemy to harm them once, like a knife or exploding potion/grenade.
|
||||
|
||||
Note: In Knave, ranged attacks are done with WIS (representing the stillness of your mind?)
|
||||
|
||||
"""
|
||||
|
||||
obj_type = (ObjType.THROWABLE, ObjType.WEAPON, ObjType.CONSUMABLE)
|
||||
|
||||
attack_type = AttributeProperty(Ability.WIS)
|
||||
defense_type = AttributeProperty(Ability.DEX)
|
||||
damage_roll = AttributeProperty("1d6")
|
||||
|
||||
|
||||
class WeaponEmptyHand:
|
||||
"""
|
||||
This is a dummy-class loaded when you wield no weapons. We won't create any db-object for it.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue