First working attack in tutorial combat system

This commit is contained in:
Griatch 2022-07-14 20:29:09 +02:00
parent 29ffd5fd06
commit f298de0585
6 changed files with 260 additions and 129 deletions

View file

@ -9,7 +9,7 @@ from evennia.utils.utils import int2str, lazy_property
from . import rules
from .enums import Ability, WieldLocation
from .objects import EvAdventureObject
from .objects import EvAdventureObject, WeaponEmptyHand
class EquipmentError(TypeError):
@ -134,7 +134,7 @@ class EquipmentHandler:
@property
def weapon(self):
"""
Conveniently get the currently active weapon.
Conveniently get the currently active weapon or rune stone.
Returns:
obj or None: The weapon. None if unarmored.
@ -146,6 +146,8 @@ class EquipmentHandler:
weapon = slots[WieldLocation.TWO_HANDS]
if not weapon:
weapon = slots[WieldLocation.WEAPON_HAND]
if not weapon:
weapon = WeaponEmptyHand()
return weapon
def display_loadout(self):
@ -370,6 +372,13 @@ class LivingMixin:
else:
self.msg(f"|g{healer.key} heals you for {healed} health.|n")
def at_damage(self, damage, attacker=None):
"""
Called when attacked and taking damage.
"""
pass
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
@ -401,23 +410,6 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""Allows to access equipment like char.equipment.worn"""
return EquipmentHandler(self)
@property
def weapon(self):
"""
Quick access to the character's currently wielded weapon.
"""
self.equipment.weapon
@property
def armor(self):
"""
Quick access to the character's current armor.
Will return the "Unarmored" armor level (11) if none other are found.
"""
self.equipment.armor or 11
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.
@ -467,21 +459,25 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
self.equipment.remove(moved_object)
def at_damage(self, dmg, attacker=None):
def at_defeat(self):
"""
Called when receiving damage for whatever reason. This
is called *before* hp is evaluated for defeat/death.
This happens when character drops <= 0 HP. For Characters, this means rolling on
the death table.
"""
rules.dice.roll_death(self)
if hp <= 0:
# this means we rolled death on the table
self.handle_death()
else:
# still alive, but lost in some stats
self.location.msg_contents(
f"|y$You() $conj(stagger) back, weakened but still alive.|n", from_obj=self
)
def defeat_message(self, attacker, dmg):
return f"After {attacker.key}'s attack, {self.key} collapses in a heap."
def at_defeat(self, attacker, dmg):
"""
At this point, character has been defeated but is not killed (their
hp >= 0 but they lost ability bonuses). Called after being defeated in combat or
other situation where health is lost below or equal to 0.
Sent out to everyone in the location by the combathandler.
"""
@ -490,3 +486,6 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
Called when character dies.
"""
self.location.msg_contents(
f"|r$You() $conj(collapse) in a heap. No getting back from that.|n", from_obj=self
)

View file

@ -104,7 +104,7 @@ from datetime import datetime
from evennia.scripts.scripts import DefaultScript
from evennia.typeclasses.attributes import AttributeProperty
from evennia.utils import dbserialize, delay, evmenu, evtable
from evennia.utils import dbserialize, delay, evmenu, evtable, logger
from evennia.utils.utils import make_iter
from . import rules
@ -121,6 +121,11 @@ class CombatFailure(RuntimeError):
"""
# -----------------------------------------------------------------------------------
# Combat Actions
# -----------------------------------------------------------------------------------
class CombatAction:
"""
This is the base of a combat-action, like 'attack' Inherit from this to make new actions.
@ -141,8 +146,6 @@ class CombatAction:
# use None to do nothing (jump directly to registering the action)
next_menu_node = "node_select_target"
# 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
@ -153,12 +156,15 @@ class CombatAction:
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)
"""
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(message, combatant=self.combatant, broadcast=broadcast)
def __serialize_dbobjs__(self):
"""
@ -207,14 +213,26 @@ class CombatAction:
return True if self.max_uses is None else self.uses < (self.max_uses or 0)
def pre_use(self, *args, **kwargs):
"""
Called just before the main action.
"""
pass
def use(self, *args, **kwargs):
"""
Main activation of the action. This happens simultaneously to other actions.
"""
pass
def post_use(self, *args, **kwargs):
self.uses += 1
self.combathandler.msg(self.post_action_text.format(**kwargs))
"""
Called just after the action has been taken.
"""
pass
class CombatActionAttack(CombatAction):
@ -237,27 +255,53 @@ class CombatActionAttack(CombatAction):
"""
attacker = self.combatant
weapon = self.combatant.equipment.weapon
# figure out advantage (gained by previous stunts)
advantage = bool(self.combathandler.advantage_matrix[attacker].pop(defender, False))
# figure out disadvantage (gained by enemy stunts/actions)
disadvantage = bool(self.combathandler.disadvantage_matrix[attacker].pop(defender, False))
is_hit, quality = rules.dice.opposed_saving_throw(
is_hit, quality, txt = rules.dice.opposed_saving_throw(
attacker,
defender,
attack_type=attacker.weapon.attack_type,
defense_type=attacker.weapon.defense_type,
attack_type=weapon.attack_type,
defense_type=attacker.equipment.weapon.defense_type,
advantage=advantage,
disadvantage=disadvantage,
)
self.msg(f"$You() $conj(attack) $You({defender.key}) with {weapon.key}: {txt}")
if is_hit:
self.combathandler.resolve_damage(
attacker, defender, critical=quality == "critical success"
)
# enemy hit, calculate damage
weapon_dmg_roll = attacker.equipment.weapon.damage_roll
# TODO messaging here
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)
defender.hp -= dmg
# 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.equipment.weapon.quality -= 1
message += ".. it's a |rcritical miss!|n, damaging the weapon."
self.msg(message)
class CombatActionStunt(CombatAction):
@ -299,7 +343,7 @@ class CombatActionStunt(CombatAction):
attacker = self.combatant
advantage, disadvantage = False, False
is_success, _ = rules.dice.opposed_saving_throw(
is_success, _, txt = rules.dice.opposed_saving_throw(
attacker,
defender,
attack_type=self.attack_type,
@ -307,13 +351,13 @@ class CombatActionStunt(CombatAction):
advantage=advantage,
disadvantage=disadvantage,
)
self.msg(f"$You() $conj(attempt) stunt on $You(defender.key). {txt}")
if is_success:
if advantage:
self.combathandler.gain_advantage(attacker, defender)
else:
self.combathandler.gain_disadvantage(defender, attacker)
self.msg
# only spend a use after being successful
self.uses += 1
@ -376,11 +420,14 @@ class CombatActionFlee(CombatAction):
"Disengage from combat. Use successfully two times in a row to leave combat at the "
"end of the second round. If someone Blocks you successfully, this counter is reset."
)
priority = -5 # checked last
def use(self, *args, **kwargs):
# it's safe to do this twice
self.msg(
"$You() retreats, and will leave combat next round unless someone successfully "
"blocks them."
)
self.combathandler.flee(self.combatant)
@ -409,7 +456,7 @@ class CombatActionBlock(CombatAction):
advantage = bool(self.advantage_matrix[combatant].pop(fleeing_target, False))
disadvantage = bool(self.disadvantage_matrix[combatant].pop(fleeing_target, False))
is_success, _ = rules.dice.opposed_saving_throw(
is_success, _, txt = rules.dice.opposed_saving_throw(
combatant,
fleeing_target,
attack_type=self.attack_type,
@ -417,12 +464,14 @@ class CombatActionBlock(CombatAction):
advantage=advantage,
disadvantage=disadvantage,
)
self.msg(f"$You() tries to block the retreat of $You({fleeing_target.key}). {txt}")
if is_success:
# managed to stop the target from fleeing/disengaging
self.combatant.unflee(fleeing_target)
self.msg("$You() blocks the retreat of $You({fleeing_target.key})")
else:
pass # they are getting away!
self.msg("$You({fleeing_target.key}) dodges away from you $You()!")
class CombatActionSwapWieldedWeaponOrSpell(CombatAction):
@ -450,8 +499,6 @@ class CombatActionSwapWieldedWeaponOrSpell(CombatAction):
next_menu_node = "node_select_wield_from_inventory"
post_action_text = "{combatant} switches weapons."
def use(self, combatant, item, *args, **kwargs):
# this will make use of the item
combatant.inventory.use(item)
@ -470,10 +517,9 @@ class CombatActionUseItem(CombatAction):
next_menu_node = "node_select_use_item_from_inventory"
post_action_text = "{combatant} used an item."
def use(self, combatant, item, *args, **kwargs):
item.use(combatant, *args, **kwargs)
self.msg("$You() $conj(use) an item.")
class CombatActionDoNothing(CombatAction):
@ -492,6 +538,14 @@ class CombatActionDoNothing(CombatAction):
post_action_text = "{combatant} does nothing this turn."
def use(self, *args, **kwargs):
self.msg("$You() $conj(hesitate), accomplishing nothing.")
# -----------------------------------------------------------------------------------
# Combat handler
# -----------------------------------------------------------------------------------
class EvAdventureCombatHandler(DefaultScript):
"""
@ -618,6 +672,8 @@ class EvAdventureCombatHandler(DefaultScript):
self.interval - warning_time, self._warn_time, warning_time
)
self.msg(f"|y_______________________ start turn {self.turn} ___________________________|n")
for combatant in self.combatants:
# cycle combat menu
self._init_menu(combatant)
@ -628,10 +684,15 @@ class EvAdventureCombatHandler(DefaultScript):
End of turn operations.
1. Do all regular actions
2. Roll for any death events
2. Remove combatants that disengaged successfully
3. Timeout advantages/disadvantages
"""
self.msg(
f"|y__________________ turn resolution (turn {self.turn}) ____________________|n\n"
)
# do all actions
for combatant in self.combatants:
# read the current action type selected by the player
@ -639,7 +700,16 @@ class EvAdventureCombatHandler(DefaultScript):
combatant, (CombatActionDoNothing(self, combatant), (), {})
)
# perform the action on the CombatAction instance
action.use(*args, **kwargs)
try:
action.pre_use(*args, **kwargs)
action.use(*args, **kwargs)
action.post_use(*args, **kwargs)
except Exception as err:
combatant.msg(
f"An error ({err}) occurred when performing this action.\n"
"Please report the problem to an admin."
)
logger.log_trace()
# handle disengaging combatants
@ -647,14 +717,37 @@ class EvAdventureCombatHandler(DefaultScript):
for combatant in self.combatants:
# check disengaging combatants (these are combatants that managed
# to stay at disengaging distance for a turn)
# not get their escape blocked last turn
if combatant in self.fleeing_combatants:
self.fleeing_combatants.remove(combatant)
if combatant.hp <= 0:
# characters roll on the death table here, npcs usually just die
combatant.at_defeat()
# tell everyone
self.msg(combatant.defeat_message(attacker, dmg), combatant=combatant)
if defender.hp > 0:
# death roll didn't kill them - 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.",
combatant=combatant,
broadcast=False,
)
defender.at_defeat() # note - NPC monsters may still 'die' here
else:
# outright killed
defender.at_death()
# no matter the result, the combatant is out
to_remove.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.msg(f"|y$You() $conj(are) out of combat.|n", combatant=combatant)
self.remove_combatant(combatant)
# refresh stunt timeouts (note - self.stunt_duration is the same for
@ -788,7 +881,7 @@ class EvAdventureCombatHandler(DefaultScript):
if comb is combatant:
continue
name = combatant.key
name = comb.key
health = f"{comb.hurt_level}"
fleeing = ""
if comb in self.fleeing_combatants:
@ -798,24 +891,37 @@ class EvAdventureCombatHandler(DefaultScript):
return str(table)
def msg(self, message, targets=None):
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.
targets (Object or list, optional): Sends message only to
one or more particular combatants. If unset, send to
everyone in the combat.
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.
"""
if targets:
for target in make_iter(targets):
target.msg(message)
else:
for target in self.combatants:
target.msg(message)
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 gain_advantage(self, combatant, target):
"""
@ -839,48 +945,6 @@ class EvAdventureCombatHandler(DefaultScript):
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.dice.roll(weapon_dmg_roll)
if critical:
dmg += rules.dice.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.dice.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_key, *args, **kwargs):
"""
Register an action based on its `.key`.
@ -927,7 +991,9 @@ class EvAdventureCombatHandler(DefaultScript):
return list(self.combatant_actions[combatant].values())
# ------------ start combat menu definitions
# -----------------------------------------------------------------------------------
# Combat Menu definitions
# -----------------------------------------------------------------------------------
def _register_action(caller, raw_string, **kwargs):
@ -1165,7 +1231,9 @@ def node_wait_start(caller, raw_string, **kwargs):
return text, options
# -------------- end of combat menu definitions
# -----------------------------------------------------------------------------------
# Access function
# -----------------------------------------------------------------------------------
def join_combat(caller, *targets, session=None):

View file

@ -69,6 +69,14 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter):
"""
self.hp = self.hp_max
def ai_combat_next_action(self):
"""
The combat engine should ask this method in order to
get the next action the npc should perform in combat.
"""
pass
class EvAdventureShopKeeper(EvAdventureNPC):
"""

View file

@ -85,6 +85,20 @@ class EvAdventureWeapon(EvAdventureObject):
damage_roll = AttributeProperty("1d6")
class WeaponEmptyHand:
"""
This is used when you wield no weapons. We won't create any db-object for it.
"""
key = "Empty Fists"
inventory_use_slot = WieldLocation.WEAPON_HAND
attack_type = Ability.STR
defense_type = Ability.ARMOR
damage_roll = "1d4"
quality = 100000 # let's assume fists are always available ...
class EvAdventureRunestone(EvAdventureWeapon):
"""
Base class for magic runestones. In _Knave_, every spell is represented by a rune stone

View file

@ -81,7 +81,7 @@ class EvAdventureRollEngine:
if 0 < diesize > max_diesize:
raise TypeError(f"Invalid die-size used (must be between 1 and {max_diesize} sides)")
# At this point we know we have valid input - roll and all dice together
# At this point we know we have valid input - roll and add dice together
return sum(randint(1, diesize) for _ in range(number))
def roll_with_advantage_or_disadvantage(self, advantage=False, disadvantage=False):
@ -98,7 +98,7 @@ class EvAdventureRollEngine:
"""
if not (advantage or disadvantage) or (advantage and disadvantage):
# normal roll
# normal roll, or advantage cancels disadvantage
return self.roll("1d20")
elif advantage:
return max(self.roll("1d20"), self.roll("1d20"))
@ -129,9 +129,10 @@ class EvAdventureRollEngine:
modifier (int, optional): An additional +/- modifier to the roll.
Returns:
tuple: (bool, str): If the save was passed or not. The second element is the
quality of the roll - None (normal), "critical fail" and "critical success".
tuple: A tuple `(bool, str, str)`. The bool indicates if the save was passed or not.
The second element is the quality of the roll - None (normal),
"critical fail" and "critical success". Last element is a text detailing
the roll, for display purposes.
Notes:
Advantage and disadvantage cancel each other out.
@ -147,7 +148,25 @@ class EvAdventureRollEngine:
quality = Ability.CRITICAL_SUCCESS
else:
quality = None
return (dice_roll + bonus + modifier) > target, quality
result = dice_roll + bonus + modifier > target
# determine text output
rolltxt = "d20 "
if advantage and disadvantage:
rolltxt = "d20 (advantage canceled by disadvantage)"
elif advantage:
rolltxt = "|g2d20|n (advantage: picking highest) "
elif disadvantage:
rolltxt = "|r2d20|n (disadvantage: picking lowest) "
bontxt = f"(+{bonus})"
modtxt = ""
if modifier:
modtxt = f" + {modifier}" if modifier > 0 else f" - {abs(modifier)}"
qualtxt = f" ({quality.value}!)" if quality else ""
txt = f"{dice_roll} + {bonus_type.value}{bontxt}{modtxt} -> |w{result}{qualtxt}|n"
return (dice_roll + bonus + modifier) > target, quality, txt
def opposed_saving_throw(
self,
@ -174,14 +193,16 @@ class EvAdventureRollEngine:
modifier (int): An additional +/- modifier to the roll.
Returns:
tuple: (bool, str): If the attack succeed or not. The second element is the
quality of the roll - None (normal), "critical fail" and "critical success".
tuple: (bool, str, str): If the attack succeed or not. The second element is the
quality of the roll - None (normal), "critical fail" and "critical success". Last
element is a text that summarizes the details of the roll.
Notes:
Advantage and disadvantage cancel each other out.
"""
defender_defense = getattr(defender, defense_type.value, 1) + 10
return self.saving_throw(
defender_defense = getattr(defender, defense_type.value, 1)
result, quality, txt = self.saving_throw(
attacker,
bonus_type=attack_type,
target=defender_defense,
@ -189,6 +210,9 @@ class EvAdventureRollEngine:
disadvantage=disadvantage,
modifier=modifier,
)
txt = f"Roll vs {defense_type.value}({defender_defense}):\n{txt}"
return result, quality, txt
def roll_random_table(self, dieroll, table_choices):
"""

View file

@ -21,9 +21,15 @@ from evennia.scripts.scripthandler import ScriptHandler
from evennia.typeclasses.attributes import ModelAttributeBackend, NickHandler
from evennia.typeclasses.models import TypeclassBase
from evennia.utils import ansi, create, funcparser, logger, search
from evennia.utils.utils import (class_from_module, is_iter, lazy_property,
list_to_string, make_iter, to_str,
variable_from_module)
from evennia.utils.utils import (
class_from_module,
is_iter,
lazy_property,
list_to_string,
make_iter,
to_str,
variable_from_module,
)
_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE
@ -714,7 +720,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
for obj in contents:
func(obj, **kwargs)
def msg_contents(self, text=None, exclude=None, from_obj=None, mapping=None, **kwargs):
def msg_contents(
self,
text=None,
exclude=None,
from_obj=None,
mapping=None,
raise_funcparse_errors=False,
**kwargs,
):
"""
Emits a message to all objects inside this object.
@ -738,6 +752,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
in the `text` string. If `<object>` doesn't have a `get_display_name`
method, it will be returned as a string. If not set, a key `you` will
be auto-added to point to `from_obj` if given, otherwise to `self`.
raise_funcparse_errors (bool, optional): If set, a failing `$func()` will
lead to an outright error. If unset (default), the failing `$func()`
will instead appear in output unparsed.
**kwargs: Keyword arguments will be passed on to `obj.msg()` for all
messaged objects.
@ -802,7 +820,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
# actor-stance replacements
inmessage = _MSG_CONTENTS_PARSER.parse(
inmessage,
raise_errors=True,
raise_errors=raise_funcparse_errors,
return_string=True,
caller=you,
receiver=receiver,