mirror of
https://github.com/evennia/evennia.git
synced 2025-10-29 19:35:56 +00:00
Split evadventure combat code into several modules
This commit is contained in:
parent
6a4f293ab9
commit
049e4fbb35
File diff suppressed because it is too large
Load Diff
546
evennia/contrib/tutorials/evadventure/combat_base.py
Normal file
546
evennia/contrib/tutorials/evadventure/combat_base.py
Normal file
@ -0,0 +1,546 @@
|
||||
"""
|
||||
EvAdventure Base combat utilities.
|
||||
|
||||
This establishes the basic building blocks for combat:
|
||||
|
||||
- `CombatAction` - classes encompassing all the working around an action. They are initialized
|
||||
from 'action-dicts` - dictionaries with all the relevant data for the particular invocation
|
||||
- `CombatHandler` - base class for running a combat. Exactly how this is used depends on the
|
||||
type of combat intended (twitch- or turn-based) so many details of this will be implemented
|
||||
in child classes.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
import random
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from evennia import CmdSet, Command, create_script, default_cmds
|
||||
from evennia.commands.command import InterruptCommand
|
||||
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 display_len, inherits_from, list_to_string, pad
|
||||
|
||||
from . import rules
|
||||
from .characters import EvAdventureCharacter
|
||||
from .enums import ABILITY_REVERSE_MAP, Ability, ObjType
|
||||
from .npcs import EvAdventureNPC
|
||||
from .objects import EvAdventureObject
|
||||
|
||||
COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler"
|
||||
COMBAT_HANDLER_INTERVAL = 30
|
||||
|
||||
|
||||
class CombatFailure(RuntimeError):
|
||||
"""
|
||||
Some failure during actions.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
# store the action dicts' keys as properties accessible as e.g. action.target etc
|
||||
for key, val in action_dict.items():
|
||||
if not key.startswith("_"):
|
||||
setattr(self, key, val)
|
||||
|
||||
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(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
|
||||
|
||||
def post_execute(self):
|
||||
"""
|
||||
Called after execution.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CombatActionHold(CombatAction):
|
||||
"""
|
||||
Action that does nothing.
|
||||
|
||||
Note:
|
||||
Refer to as 'hold'
|
||||
|
||||
action_dict = {
|
||||
"key": "hold"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CombatActionAttack(CombatAction):
|
||||
"""
|
||||
A regular attack, using a wielded weapon.
|
||||
|
||||
action-dict = {
|
||||
"key": "attack",
|
||||
"target": Character/Object
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'attack'
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
attacker = self.combatant
|
||||
weapon = attacker.weapon
|
||||
target = self.target
|
||||
|
||||
if weapon.at_pre_use(attacker, target):
|
||||
weapon.use(attacker, target, advantage=self.has_advantage(attacker, target))
|
||||
weapon.at_post_use(attacker, target)
|
||||
|
||||
|
||||
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 = {
|
||||
"key": "stunt",
|
||||
"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):
|
||||
combathandler = self.combathandler
|
||||
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)
|
||||
txt = ""
|
||||
|
||||
if 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
|
||||
|
||||
# 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=combathandler.has_advantage(attacker, defender),
|
||||
disadvantage=combathandler.has_disadvantage(attacker, defender),
|
||||
)
|
||||
|
||||
self.msg(f"$You() $conj(attempt) stunt on $You({defender.key}). {txt}")
|
||||
|
||||
# deal with results
|
||||
if is_success:
|
||||
if self.advantage:
|
||||
combathandler.give_advantage(recipient, target)
|
||||
else:
|
||||
combathandler.give_disadvantage(recipient, target)
|
||||
if recipient == self.combatant:
|
||||
self.msg(
|
||||
f"$You() $conj(gain) {'advantage' if self.advantage else 'disadvantage'} "
|
||||
f"against $You({target.key})!"
|
||||
)
|
||||
else:
|
||||
self.msg(
|
||||
f"$You() $conj(cause) $You({recipient.key}) "
|
||||
f"to gain {'advantage' if self.advantage else 'disadvantage'} "
|
||||
f"against $You({target.key})!"
|
||||
)
|
||||
self.msg(
|
||||
"|yHaving succeeded, you hold back to plan your next move.|n [hold]",
|
||||
broadcast=False,
|
||||
)
|
||||
combathandler.queue_action(attacker, combathandler.default_action_dict)
|
||||
else:
|
||||
self.msg(f"$You({defender.key}) $conj(resist)! $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 = {
|
||||
"key": "use",
|
||||
"item": Object
|
||||
"target": Character/NPC/Object/None
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'use'
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
item = self.item
|
||||
user = self.combatant
|
||||
target = self.target
|
||||
|
||||
if item.at_pre_use(user, target):
|
||||
item.use(
|
||||
user,
|
||||
target,
|
||||
advantage=self.has_advantage(user, target),
|
||||
disadvantage=self.has_disadvantage(user, target),
|
||||
)
|
||||
item.at_post_use(user, target)
|
||||
# to back to idle after this
|
||||
self.combathandler.queue_action(self.combatant, self.combathandler.fallback_action_dict)
|
||||
|
||||
|
||||
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 = {
|
||||
"key": "wield",
|
||||
"item": Object
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'wield'.
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
self.combatant.equipment.move(self.item)
|
||||
self.combathandler.queue_action(self.combatant, self.combathandler.fallback_action_dict)
|
||||
|
||||
|
||||
# main combathandler
|
||||
|
||||
|
||||
class EvAdventureCombatHandlerBase(DefaultScript):
|
||||
"""
|
||||
This script is created when a combat starts. It 'ticks' the combat and tracks
|
||||
all sides of it.
|
||||
|
||||
"""
|
||||
|
||||
# available actions in combat
|
||||
action_classes = {
|
||||
"hold": CombatActionHold,
|
||||
"attack": CombatActionAttack,
|
||||
"stunt": CombatActionStunt,
|
||||
"use": CombatActionUseItem,
|
||||
"wield": CombatActionWield,
|
||||
}
|
||||
|
||||
# fallback action if not selecting anything
|
||||
fallback_action_dict = AttributeProperty({"key": "hold"}, autocreate=False)
|
||||
|
||||
@classmethod
|
||||
def get_or_create_combathandler(cls, obj, combathandler_key="combathandler", **kwargs):
|
||||
"""
|
||||
Get or create a combathandler on `obj`.
|
||||
|
||||
Args:
|
||||
obj (any): The Typeclassed entity to store the CombatHandler Script on. This could be
|
||||
a location (for turn-based combat) or a Character (for twitch-based combat).
|
||||
Keyword Args:
|
||||
combathandler_key (str): They key name for the script. Will be 'combathandler' by default.
|
||||
**kwargs: Arguments to the Script, if it is created.
|
||||
|
||||
"""
|
||||
if not obj:
|
||||
raise CombatFailure("Cannot start combat without a place to do it!")
|
||||
|
||||
combathandler = obj.ndb.combathandler
|
||||
if not combathandler:
|
||||
combathandler = obj.scripts.get(combathandler_name).first()
|
||||
if not combathandler:
|
||||
# have to create from scratch
|
||||
persistent = kwargs.pop("persistent", True)
|
||||
combathandler = create_script(
|
||||
cls, key=combathandler_key, obj=obj, persistent=persistent, **kwargs
|
||||
)
|
||||
self.caller.ndb.combathandler = combathandler
|
||||
return combathandler
|
||||
|
||||
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 get_combat_summary(self, combatant):
|
||||
"""
|
||||
Get a 'battle report' - an overview of the current state of combat from the perspective
|
||||
of one of the sides.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant to get.
|
||||
|
||||
Returns:
|
||||
EvTable: A table representing the current state of combat.
|
||||
|
||||
Example:
|
||||
::
|
||||
|
||||
Goblin shaman (Perfect)[attack]
|
||||
Gregor (Hurt)[attack] Goblin brawler(Hurt)[attack]
|
||||
Bob (Perfect)[stunt] vs Goblin grunt 1 (Hurt)[attack]
|
||||
Goblin grunt 2 (Perfect)[hold]
|
||||
Goblin grunt 3 (Wounded)[flee]
|
||||
|
||||
"""
|
||||
allies, enemies = self.get_sides(combatant)
|
||||
# we must include outselves at the top of the list (we are not returned from get_sides)
|
||||
allies.insert(0, combatant)
|
||||
nallies, nenemies = len(allies), len(enemies)
|
||||
|
||||
# prepare colors and hurt-levels
|
||||
allies = [
|
||||
f"{ally} ({ally.hurt_level})[{self.get_next_action_dict(ally)['key']}]"
|
||||
for ally in allies
|
||||
]
|
||||
enemies = [
|
||||
f"{enemy} ({enemy.hurt_level})[{self.get_next_action_dict(enemy)['key']}]"
|
||||
for enemy in enemies
|
||||
]
|
||||
|
||||
# the center column with the 'vs'
|
||||
vs_column = ["" for _ in range(max(nallies, nenemies))]
|
||||
vs_column[len(vs_column) // 2] = "|wvs|n"
|
||||
|
||||
# the two allies / enemies columns should be centered vertically
|
||||
diff = abs(nallies - nenemies)
|
||||
top_empty = diff // 2
|
||||
bot_empty = diff - top_empty
|
||||
topfill = ["" for _ in range(top_empty)]
|
||||
botfill = ["" for _ in range(bot_empty)]
|
||||
|
||||
if nallies >= nenemies:
|
||||
enemies = topfill + enemies + botfill
|
||||
else:
|
||||
allies = topfill + allies + botfill
|
||||
|
||||
# make a table with three columns
|
||||
return evtable.EvTable(
|
||||
table=[
|
||||
evtable.EvColumn(*allies, align="l"),
|
||||
evtable.EvColumn(*vs_column, align="c"),
|
||||
evtable.EvColumn(*enemies, align="r"),
|
||||
],
|
||||
border=None,
|
||||
maxwidth=78,
|
||||
)
|
||||
|
||||
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).
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def give_advantage(self, recipient, target):
|
||||
"""
|
||||
Let a benefiter gain advantage against the target.
|
||||
|
||||
Args:
|
||||
recipient (Character or NPC): The one to gain the advantage. This may or may not
|
||||
be the same entity that creates the advantage in the first place.
|
||||
target (Character or NPC): The one against which the target gains advantage. This
|
||||
could (in principle) be the same as the benefiter (e.g. gaining advantage on
|
||||
some future boost)
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def give_disadvantage(self, recipient, target):
|
||||
"""
|
||||
Let an affected party gain disadvantage against a target.
|
||||
|
||||
Args:
|
||||
recipient (Character or NPC): The one to get the disadvantage.
|
||||
target (Character or NPC): The one against which the target gains disadvantage, usually an enemy.
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def has_advantage(self, combatant, target):
|
||||
"""
|
||||
Check if a given combatant has advantage against a target.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The one to check if they have advantage
|
||||
target (Character or NPC): The target to check advantage against.
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def has_disadvantage(self, combatant, target):
|
||||
"""
|
||||
Check if a given combatant has disadvantage against a target.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The one to check if they have disadvantage
|
||||
target (Character or NPC): The target to check disadvantage against.
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def queue_action(self, combatant, action_dict):
|
||||
"""
|
||||
Queue an action by adding the new actiondict to the back of the queue. If the
|
||||
queue was alrady at max-size, the front of the queue will be discarded.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): A combatant queueing the action.
|
||||
action_dict (dict): A dict describing the action class by name along with properties.
|
||||
|
||||
Example:
|
||||
If the queue max-size is 3 and was `[a, b, c]` (where each element is an action-dict),
|
||||
then using this method to add the new action-dict `d` will lead to a queue `[b, c, d]` -
|
||||
that is, adding the new action will discard the one currently at the front of the queue
|
||||
to make room.
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def execute_next_action(self, combatant):
|
||||
"""
|
||||
Perform a combatant's next action.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing and action.
|
||||
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def start_combat(self):
|
||||
"""
|
||||
Start combat.
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def check_stop_combat(self):
|
||||
"""
|
||||
Check if this combat should be aborted, whatever this means for the particular
|
||||
the particular combat type.
|
||||
|
||||
Stop the combat immediately. This should also do all needed cleanup.
|
||||
|
||||
Keyword Args:
|
||||
kwargs: Any extra keyword args used.
|
||||
|
||||
Returns:
|
||||
bool: If `True`, the `stop_combat` method sho
|
||||
|
||||
"""
|
||||
raise NotImplemented
|
||||
|
||||
def stop_combat(self):
|
||||
"""
|
||||
Stop combat. This should also do all cleanup.
|
||||
"""
|
||||
raise NotImplemented
|
||||
843
evennia/contrib/tutorials/evadventure/combat_turnbased.py
Normal file
843
evennia/contrib/tutorials/evadventure/combat_turnbased.py
Normal file
@ -0,0 +1,843 @@
|
||||
"""
|
||||
EvAdventure Turn-based combat
|
||||
|
||||
This implements a turn-based (Final Fantasy, etc) style of MUD combat.
|
||||
|
||||
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.
|
||||
|
||||
The combat is controlled through a menu:
|
||||
|
||||
------------------- main menu
|
||||
Combat
|
||||
|
||||
You have 30 seconds to choose your next action. If you don't decide, you will hesitate and do
|
||||
nothing. Available actions:
|
||||
|
||||
1. [A]ttack/[C]ast spell at <target> using your equipped weapon/spell
|
||||
3. Make [S]tunt <target/yourself> (gain/give advantage/disadvantage for future attacks)
|
||||
4. S[W]ap weapon / spell rune
|
||||
5. [U]se <item>
|
||||
6. [F]lee/disengage (takes one turn, during which attacks have advantage against you)
|
||||
8. [H]esitate/Do nothing
|
||||
|
||||
You can also use say/emote between rounds.
|
||||
As soon as all combatants have made their choice (or time out), the round will be resolved
|
||||
simultaneusly.
|
||||
|
||||
-------------------- attack/cast spell submenu
|
||||
|
||||
Choose the target of your attack/spell:
|
||||
0: Yourself 3: <enemy 3> (wounded)
|
||||
1: <enemy 1> (hurt)
|
||||
2: <enemy 2> (unharmed)
|
||||
|
||||
------------------- make stunt submenu
|
||||
|
||||
Stunts are special actions that don't cause damage but grant advantage for you or
|
||||
an ally for future attacks - or grant disadvantage to your enemy's future attacks.
|
||||
The effects of stunts start to apply *next* round. The effect does not stack, can only
|
||||
be used once and must be taken advantage of within 5 rounds.
|
||||
|
||||
Choose stunt:
|
||||
1: Trip <target> (give disadvantage DEX)
|
||||
2: Feint <target> (get advantage DEX against target)
|
||||
3: ...
|
||||
|
||||
-------------------- make stunt target submenu
|
||||
|
||||
Choose the target of your stunt:
|
||||
0: Yourself 3: <combatant 3> (wounded)
|
||||
1: <combatant 1> (hurt)
|
||||
2: <combatant 2> (unharmed)
|
||||
|
||||
------------------- swap weapon or spell run
|
||||
|
||||
Choose the item to wield.
|
||||
1: <item1>
|
||||
2: <item2> (two hands)
|
||||
3: <item3>
|
||||
4: ...
|
||||
|
||||
------------------- use item
|
||||
|
||||
Choose item to use.
|
||||
1: Healing potion (+1d6 HP)
|
||||
2: Magic pebble (gain advantage, 1 use)
|
||||
3: Potion of glue (give disadvantage to target)
|
||||
|
||||
------------------- Hesitate/Do nothing
|
||||
|
||||
You hang back, passively defending.
|
||||
|
||||
------------------- Disengage
|
||||
|
||||
You retreat, getting ready to get out of combat. Use two times in a row to
|
||||
leave combat. You flee last in a round.
|
||||
"""
|
||||
|
||||
|
||||
from .combat_base import (
|
||||
CombatAction,
|
||||
CombatActionHold,
|
||||
CombatActionStunt,
|
||||
CombatActionUserItem,
|
||||
CombatActionWield,
|
||||
EvAdventureCombatHandler,
|
||||
)
|
||||
from .enums import Ability
|
||||
|
||||
|
||||
# turnbased-combat needs the flee action too
|
||||
class CombatActionFlee(CombatAction):
|
||||
"""
|
||||
Start (or continue) fleeing/disengaging from combat.
|
||||
|
||||
action_dict = {
|
||||
"key": "flee",
|
||||
}
|
||||
|
||||
Note:
|
||||
Refer to as 'flee'.
|
||||
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
combathandler = self.combathandler
|
||||
|
||||
if self.combatant not in combathandler.fleeing_combatants:
|
||||
# we record the turn on which we started fleeing
|
||||
combathandler.fleeing_combatants[self.combatant] = self.combathandler.turn
|
||||
|
||||
# show how many turns until successful flight
|
||||
current_turn = combathandler.turn
|
||||
started_fleeing = combathandler.fleeing_combatants[self.combatant]
|
||||
flee_timeout = combathandler.flee_timeout
|
||||
time_left = flee_timeout - (current_turn - started_fleeing)
|
||||
|
||||
if time_left > 0:
|
||||
self.msg(
|
||||
"$You() $conj(retreat), being exposed to attack while doing so (will escape in "
|
||||
f"{time_left} $pluralize(turn, {time_left}))."
|
||||
)
|
||||
|
||||
|
||||
class EvAdventureTurnbasedCombatHandler(EvAdventureCombatHandler):
|
||||
"""
|
||||
A version of the combathandler, handling turn-based combat.
|
||||
|
||||
"""
|
||||
|
||||
# available actions in combat
|
||||
action_classes = {
|
||||
"hold": CombatActionHold,
|
||||
"attack": CombatActionAttack,
|
||||
"stunt": CombatActionStunt,
|
||||
"use": CombatActionUseItem,
|
||||
"wield": CombatActionWield,
|
||||
"flee": CombatActionFlee,
|
||||
}
|
||||
|
||||
# how many turns you must be fleeing before escaping
|
||||
flee_timeout = AttributeProperty(3, autocreate=False)
|
||||
|
||||
# how many turns you must be fleeing before escaping
|
||||
flee_timeout = AttributeProperty(3, autocreate=False)
|
||||
|
||||
# fallback action if not selecting anything
|
||||
fallback_action_dict = AttributeProperty({"key": "attack"}, autocreate=False)
|
||||
|
||||
# persistent storage
|
||||
|
||||
turn = AttributeProperty(0)
|
||||
# who is involved in combat, and their queued action
|
||||
# as {combatant: actiondict, ...}
|
||||
combatants = AttributeProperty(dict)
|
||||
|
||||
# who has advantage against whom
|
||||
advantage_matrix = AttributeProperty(defaultdict(dict))
|
||||
disadvantage_matrix = AttributeProperty(defaultdict(dict))
|
||||
|
||||
fleeing_combatants = AttributeProperty(dict)
|
||||
defeated_combatants = AttributeProperty(list)
|
||||
|
||||
# usable script properties
|
||||
# .is_active - show if timer is running
|
||||
|
||||
def give_advantage(self, recipient, target):
|
||||
"""
|
||||
Let a benefiter gain advantage against the target.
|
||||
|
||||
Args:
|
||||
recipient (Character or NPC): The one to gain the advantage. This may or may not
|
||||
be the same entity that creates the advantage in the first place.
|
||||
target (Character or NPC): The one against which the target gains advantage. This
|
||||
could (in principle) be the same as the benefiter (e.g. gaining advantage on
|
||||
some future boost)
|
||||
|
||||
"""
|
||||
self.advantage_matrix[recipient][target] = True
|
||||
|
||||
def give_disadvantage(self, recipient, target, **kwargs):
|
||||
"""
|
||||
Let an affected party gain disadvantage against a target.
|
||||
|
||||
Args:
|
||||
recipient (Character or NPC): The one to get the disadvantage.
|
||||
target (Character or NPC): The one against which the target gains disadvantage, usually an enemy.
|
||||
|
||||
"""
|
||||
self.disadvantage_matrix[recipient][target] = True
|
||||
self.combathandler.advantage_matrix[recipient][target] = False
|
||||
|
||||
def has_advantage(self, combatant, target, **kwargs):
|
||||
"""
|
||||
Check if a given combatant has advantage against a target.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The one to check if they have advantage
|
||||
target (Character or NPC): The target to check advantage against.
|
||||
|
||||
"""
|
||||
return bool(self.combathandler.advantage_matrix[recipient].pop(target, False)) or (
|
||||
target in self.combathandler.fleeing_combatants
|
||||
)
|
||||
|
||||
def has_disadvantage(self, combatant, target):
|
||||
"""
|
||||
Check if a given combatant has disadvantage against a target.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The one to check if they have disadvantage
|
||||
target (Character or NPC): The target to check disadvantage against.
|
||||
|
||||
"""
|
||||
|
||||
def has_disadvantage(self, recipient, target):
|
||||
return bool(self.combathandler.disadvantage_matrix[recipient].pop(target, False)) or (
|
||||
recipient in self.combathandler.fleeing_combatants
|
||||
)
|
||||
|
||||
def add_combatant(self, combatant):
|
||||
"""
|
||||
Add a new combatant to the battle. Can be called multiple times safely.
|
||||
|
||||
Args:
|
||||
*combatants (EvAdventureCharacter, EvAdventureNPC): Any number of combatants to add to
|
||||
the combat.
|
||||
Returns:
|
||||
bool: If this combatant was newly added or not (it was already in combat).
|
||||
|
||||
"""
|
||||
if combatant not in self.combatants:
|
||||
self.combatants[combatant] = self.fallback_action_dict
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_combatant(self, combatant):
|
||||
"""
|
||||
Remove a combatant from the battle. This removes their queue.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): A combatant to add to
|
||||
the combat.
|
||||
|
||||
"""
|
||||
self.combatants.pop(combatant, None)
|
||||
# clean up menu if it exists
|
||||
if combatant.ndb._evmenu:
|
||||
combatant.ndb._evmenu.close_menu()
|
||||
|
||||
def start_combat(self, **kwargs):
|
||||
"""
|
||||
This actually starts the combat. It's safe to run this multiple times
|
||||
since it will only start combat if it isn't already running.
|
||||
|
||||
"""
|
||||
if not self.is_active:
|
||||
self.start(**kwargs)
|
||||
|
||||
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
|
||||
queue was alrady at max-size, the front of the queue will be discarded.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): A combatant queueing the action.
|
||||
action_dict (dict): A dict describing the action class by name along with properties.
|
||||
|
||||
Example:
|
||||
If the queue max-size is 3 and was `[a, b, c]` (where each element is an action-dict),
|
||||
then using this method to add the new action-dict `d` will lead to a queue `[b, c, d]` -
|
||||
that is, adding the new action will discard the one currently at the front of the queue
|
||||
to make room.
|
||||
|
||||
"""
|
||||
self.combatants[combatant] = action_dict
|
||||
|
||||
# track who inserted actions this turn (non-persistent)
|
||||
did_action = set(self.ndb.did_action or ())
|
||||
did_action.add(combatant)
|
||||
if len(did_action) >= len(self.combatants):
|
||||
# everyone has inserted an action. Start next turn without waiting!
|
||||
self.force_repeat()
|
||||
|
||||
def get_next_action_dict(self, combatant, rotate_queue=True):
|
||||
"""
|
||||
Give the action_dict for the next action that will be executed.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant to get the action for.
|
||||
rotate_queue (bool, optional): Rotate the queue after getting the action dict.
|
||||
|
||||
Returns:
|
||||
dict: The next action-dict in the queue.
|
||||
|
||||
"""
|
||||
return self.combatants.get(combatant, self.fallback_action_dict)
|
||||
|
||||
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 'hold'. We don't pop anything from the queue, instead we keep
|
||||
rotating the queue. When the queue has a length of one, this means just repeating the
|
||||
same action over and over.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing and action.
|
||||
|
||||
Example:
|
||||
If the combatant's action queue is `[a, b, c]` (where each element is an action-dict),
|
||||
then calling this method will lead to action `a` being performed. After this method, the
|
||||
queue will be rotated to the left and be `[b, c, a]` (so next time, `b` will be used).
|
||||
|
||||
"""
|
||||
# this gets the next dict and rotates the queue
|
||||
action_dict = self.combatants.get(combatants, self.fallback_action_dict)
|
||||
|
||||
# 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(self, combatant, action_dict)
|
||||
|
||||
action.execute()
|
||||
action.post_execute()
|
||||
self.check_stop_combat()
|
||||
|
||||
def check_stop_combat(self):
|
||||
# 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.combatants.keys()))
|
||||
allies, enemies = self.get_sides(surviving_combatant)
|
||||
|
||||
if not enemies:
|
||||
# if 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(comb for comb in self.defeated_combatants if comb.hp > 0)
|
||||
killed = list_to_string(comb for comb in self.defeated_combatants if comb.hp <= 0)
|
||||
|
||||
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()
|
||||
|
||||
def at_repeat(self):
|
||||
"""
|
||||
This method is called every time Script repeats (every `interval` seconds). Performs a full
|
||||
turn of combat, performing everyone's actions in random order.
|
||||
|
||||
"""
|
||||
self.turn += 1
|
||||
# random turn order
|
||||
combatants = list(self.combatants.keys())
|
||||
random.shuffle(combatants) # shuffles in place
|
||||
|
||||
# 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.combatants.pop(combatant)
|
||||
self.defeated_combatants.append(combatant)
|
||||
self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant)
|
||||
|
||||
# check if anyone managed to flee
|
||||
flee_timeout = self.flee_timeout
|
||||
for combatant, started_fleeing in self.fleeing_combatants.items():
|
||||
if self.turn - started_fleeing >= flee_timeout:
|
||||
# if they are still alive/fleeing and have been fleeing long enough, escape
|
||||
self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant)
|
||||
self.remove_combatant(combatant)
|
||||
|
||||
# check if one side won the battle
|
||||
self.check_stop_combat()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------------
|
||||
#
|
||||
# Turn-based combat (Final Fantasy style), using a menu
|
||||
#
|
||||
# Activate by adding the CmdTurnCombat command to Character cmdset, then
|
||||
# use it to attack a target.
|
||||
#
|
||||
# -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_combathandler(caller):
|
||||
turn_length = 30
|
||||
flee_timeout = 3
|
||||
return EvAdventureTurnbasedCombatHandler.get_or_create_combathandler(
|
||||
caller.location,
|
||||
attributes=[("turn_length", turn_length), ("flee_timeout", flee_timeout)],
|
||||
)
|
||||
|
||||
|
||||
def _queue_action(caller, raw_string, **kwargs):
|
||||
action_dict = kwargs["action_dict"]
|
||||
_get_combathandler(caller).queue_action(caller, action_dict)
|
||||
return "node_combat"
|
||||
|
||||
|
||||
def _step_wizard(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Many options requires stepping through several steps, wizard style. This
|
||||
will redirect back/forth in the sequence.
|
||||
|
||||
E.g. Stunt boost -> Choose ability to boost -> Choose recipient -> Choose target -> queue
|
||||
|
||||
"""
|
||||
caller.msg(f"_step_wizard kwargs: {kwargs}")
|
||||
steps = kwargs.get("steps", [])
|
||||
nsteps = len(steps)
|
||||
istep = kwargs.get("istep", -1)
|
||||
# one of abort, back, forward
|
||||
step_direction = kwargs.get("step", "forward")
|
||||
|
||||
match step_direction:
|
||||
case "abort":
|
||||
# abort this wizard, back to top-level combat menu, dropping changes
|
||||
return "node_combat"
|
||||
case "back":
|
||||
# step back in wizard
|
||||
if istep <= 0:
|
||||
return "node_combat"
|
||||
istep = kwargs["istep"] = istep - 1
|
||||
return steps[istep], kwargs
|
||||
case _:
|
||||
# forward (default)
|
||||
if istep >= nsteps - 1:
|
||||
# we are already at end of wizard - queue action!
|
||||
return _queue_action(caller, raw_string, **kwargs)
|
||||
else:
|
||||
# step forward
|
||||
istep = kwargs["istep"] = istep + 1
|
||||
return steps[istep], kwargs
|
||||
|
||||
|
||||
def _get_default_wizard_options(caller, **kwargs):
|
||||
"""
|
||||
Get the standard wizard options for moving back/forward/abort. This can be appended to
|
||||
the end of other options.
|
||||
|
||||
"""
|
||||
|
||||
return [
|
||||
{"key": ("back", "b"), "goto": (_step_wizard, {**kwargs, **{"step": "back"}})},
|
||||
{"key": ("abort", "a"), "goto": (_step_wizard, {**kwargs, **{"step": "abort"}})},
|
||||
]
|
||||
|
||||
|
||||
def node_choose_enemy_target(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Choose an enemy as a target for an action
|
||||
"""
|
||||
text = "Choose an enemy to target."
|
||||
action_dict = kwargs["action_dict"]
|
||||
|
||||
combathandler = _get_combathandler(caller)
|
||||
_, enemies = combathandler.get_sides(caller)
|
||||
|
||||
options = [
|
||||
{
|
||||
"desc": target.get_display_name(caller),
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{**kwargs, **{"action_dict": {**action_dict, **{"target": target}}}},
|
||||
),
|
||||
}
|
||||
for target in enemies
|
||||
]
|
||||
options.extend(_get_default_wizard_options(caller, **kwargs))
|
||||
return text, options
|
||||
|
||||
|
||||
def node_choose_allied_target(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Choose an enemy as a target for an action
|
||||
"""
|
||||
text = "Choose an ally to target."
|
||||
action_dict = kwargs["action_dict"]
|
||||
|
||||
combathandler = _get_combathandler(caller)
|
||||
allies, _ = combathandler.get_sides(caller)
|
||||
|
||||
# can choose yourself
|
||||
options = [
|
||||
{
|
||||
"desc": "Yourself",
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
**kwargs,
|
||||
**{
|
||||
"action_dict": {
|
||||
**{**action_dict, **{"target": caller, "recipient": caller}}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
[
|
||||
{
|
||||
"desc": target.get_display_name(caller),
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
**kwargs,
|
||||
**{
|
||||
"action_dict": {
|
||||
**action_dict,
|
||||
**{"target": target, "recipient": target},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
for target in allies
|
||||
]
|
||||
)
|
||||
options.extend(_get_default_wizard_options(caller, **kwargs))
|
||||
return text, options
|
||||
|
||||
|
||||
def node_choose_ability(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Select an ability to use/boost etc.
|
||||
"""
|
||||
text = "Choose the ability to apply"
|
||||
action_dict = kwargs["action_dict"]
|
||||
|
||||
options = [
|
||||
{
|
||||
"desc": abi.value,
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
**kwargs,
|
||||
**{
|
||||
"action_dict": {**action_dict, **{"stunt_type": abi, "defense_type": abi}},
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
for abi in (
|
||||
Ability.STR,
|
||||
Ability.DEX,
|
||||
Ability.CON,
|
||||
Ability.INT,
|
||||
Ability.INT,
|
||||
Ability.WIS,
|
||||
Ability.CHA,
|
||||
)
|
||||
]
|
||||
options.extend(_get_default_wizard_options(caller, **kwargs))
|
||||
return text, options
|
||||
|
||||
|
||||
def node_choose_use_item(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Choose item to use.
|
||||
|
||||
"""
|
||||
text = "Select the item"
|
||||
action_dict = kwargs["action_dict"]
|
||||
|
||||
options = [
|
||||
{
|
||||
"desc": item.get_display_name(caller),
|
||||
"goto": (_step_wizard, {**kwargs, **{**action_dict, **{"item": item}}}),
|
||||
}
|
||||
for item in caller.equipment.get_usable_objects_from_backpack()
|
||||
]
|
||||
if not options:
|
||||
text = "There are no usable items in your inventory!"
|
||||
|
||||
options.extend(_get_default_wizard_options(caller, **kwargs))
|
||||
return text, options
|
||||
|
||||
|
||||
def node_choose_wield_item(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Choose item to use.
|
||||
|
||||
"""
|
||||
text = "Select the item"
|
||||
action_dict = kwargs["action_dict"]
|
||||
|
||||
options = [
|
||||
{
|
||||
"desc": item.get_display_name(caller),
|
||||
"goto": (_step_wizard, {**kwargs, **{**action_dict, **{"item": item}}}),
|
||||
}
|
||||
for item in caller.equipment.get_wieldable_objects_from_backpack()
|
||||
]
|
||||
if not options:
|
||||
text = "There are no items in your inventory that you can wield!"
|
||||
|
||||
options.extend(_get_default_wizard_options(caller, **kwargs))
|
||||
return text, options
|
||||
|
||||
|
||||
def node_combat(caller, raw_string, **kwargs):
|
||||
"""Base combat menu"""
|
||||
|
||||
combathandler = _get_combathandler(caller)
|
||||
|
||||
text = combathandler.get_combat_summary(caller)
|
||||
options = [
|
||||
{
|
||||
"desc": "attack an enemy",
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
"steps": ["node_choose_enemy_target"],
|
||||
"action_dict": {"key": "attack", "target": None},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
"desc": "Stunt - gain a later advantage against a target",
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
"steps": [
|
||||
"node_choose_ability",
|
||||
"node_choose_allied_target",
|
||||
"node_choose_enemy_target",
|
||||
],
|
||||
"action_dict": {"key": "stunt", "advantage": True},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
"desc": "Stunt - give an enemy disadvantage against yourself or an ally",
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
"steps": [
|
||||
"node_choose_ability",
|
||||
"node_choose_enemy_target",
|
||||
"node_choose_allied_target",
|
||||
],
|
||||
"action_dict": {"key": "stunt", "advantage": False},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
"desc": "Use an item on yourself or an ally",
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
"steps": ["node_choose_use_item", "node_choose_allied_target"],
|
||||
"action_dict": {"key": "use", "item": None, "target": None},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
"desc": "Use an item on an enemy",
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
"steps": ["node_choose_use_item", "node_choose_enemy_target"],
|
||||
"action_dict": {"key": "use", "item": None, "target": None},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
"desc": "Wield/swap with an item from inventory",
|
||||
"goto": (
|
||||
_step_wizard,
|
||||
{
|
||||
"steps": ["node_choose_wield_item"],
|
||||
"action_dict": {"key": "wield", "item": None},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
"desc": "flee!",
|
||||
"goto": (_queue_action, {"action_dict": {"key": "flee"}}),
|
||||
},
|
||||
{
|
||||
"desc": "hold, doing nothing",
|
||||
"goto": (_queue_action, {"action_dict": {"key": "hold"}}),
|
||||
},
|
||||
]
|
||||
|
||||
return text, options
|
||||
|
||||
|
||||
# Add this command to the Character cmdset to make turn-based combat available.
|
||||
|
||||
|
||||
class _CmdTurnCombatBase(_CmdCombatBase):
|
||||
"""
|
||||
Override parent class to slow down the tick for more clearly turn-based play.
|
||||
|
||||
"""
|
||||
|
||||
combathandler_name = "combathandler"
|
||||
combat_tick = 30
|
||||
flee_timeout = 2
|
||||
|
||||
|
||||
class CmdTurnAttack(_CmdTurnCombatBase):
|
||||
"""
|
||||
Start or join combat.
|
||||
|
||||
Usage:
|
||||
attack [<target>]
|
||||
|
||||
"""
|
||||
|
||||
key = "attack"
|
||||
aliases = ["hit", "turnbased combat"]
|
||||
|
||||
def parse(self):
|
||||
super().parse()
|
||||
self.args = self.args.strip()
|
||||
|
||||
def func(self):
|
||||
if not self.args:
|
||||
self.msg("What are you attacking?")
|
||||
return
|
||||
|
||||
target = self.caller.search(self.args)
|
||||
if not target:
|
||||
return
|
||||
|
||||
if not hasattr(target, "hp"):
|
||||
self.msg(f"You can't attack that.")
|
||||
return
|
||||
elif target.hp <= 0:
|
||||
self.msg(f"{target.get_display_name(self.caller)} is already down.")
|
||||
return
|
||||
|
||||
if target.is_pc and not target.location.allow_pvp:
|
||||
self.msg("PvP combat is not allowed here!")
|
||||
return
|
||||
|
||||
# add combatants to combathandler. this can be done safely over and over
|
||||
self.combathandler.add_combatant(self.caller)
|
||||
self.combathandler.queue_action(self.caller, {"key": "attack", "target": target})
|
||||
self.combathandler.add_combatant(target)
|
||||
self.combathandler.start_combat()
|
||||
|
||||
# build and start the menu
|
||||
evmenu.EvMenu(
|
||||
self.caller,
|
||||
{
|
||||
"node_choose_enemy_target": node_choose_enemy_target,
|
||||
"node_choose_allied_target": node_choose_allied_target,
|
||||
"node_choose_ability": node_choose_ability,
|
||||
"node_choose_use_item": node_choose_use_item,
|
||||
"node_choose_wield_item": node_choose_wield_item,
|
||||
"node_combat": node_combat,
|
||||
},
|
||||
startnode="node_combat",
|
||||
combathandler=self.combathandler,
|
||||
# cmdset_mergetype="Union",
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
|
||||
class TurnAttackCmdSet(CmdSet):
|
||||
"""
|
||||
CmdSet for the turn-based combat.
|
||||
"""
|
||||
|
||||
def at_cmdset_creation(self):
|
||||
self.add(CmdTurnAttack())
|
||||
503
evennia/contrib/tutorials/evadventure/combat_twitch.py
Normal file
503
evennia/contrib/tutorials/evadventure/combat_twitch.py
Normal file
@ -0,0 +1,503 @@
|
||||
"""
|
||||
EvAdventure Twitch-based combat
|
||||
|
||||
This implements a 'twitch' (aka DIKU or other traditional muds) style of MUD combat.
|
||||
|
||||
"""
|
||||
from evennia import AttributeProperty
|
||||
from evennia.commands.command import Command, InterruptCommand
|
||||
from evennia.scripts.scripts import DefaultScript
|
||||
from evennia.utils.create import create_script
|
||||
from evennia.utils.utils import repeat, unrepeat
|
||||
|
||||
from .combat import (
|
||||
CombatActionAttack,
|
||||
CombatActionHold,
|
||||
CombatActionStunt,
|
||||
CombatActionUserItem,
|
||||
CombatActionWield,
|
||||
EvAdventureCombatHandlerBase,
|
||||
)
|
||||
from .enums import ABILITY_REVERSE_MAP, Ability, ObjType
|
||||
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
||||
"""
|
||||
This is created on the combatant when combat starts. It tracks only the combatants
|
||||
side of the combat and handles when the next action will happen.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# fixed properties
|
||||
action_classes = {
|
||||
"hold": CombatActionHold,
|
||||
"attack": CombatActionAttack,
|
||||
"stunt": CombatActionStunt,
|
||||
"use": CombatActionUseItem,
|
||||
"wield": CombatActionWield,
|
||||
}
|
||||
|
||||
# dynamic properties
|
||||
|
||||
advantages_against = AttributeProperty(dict)
|
||||
disadvantages_against = AttributeProperty(dict)
|
||||
|
||||
action_dict = AttributeProperty(dict)
|
||||
fallback_action_dict = AttributePropety({"key": "hold", "dt": 0})
|
||||
|
||||
# stores the current ticker reference, so we can manipulate it later
|
||||
current_ticker_ref = AttributeProperty(None)
|
||||
|
||||
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 that combatant itself is not included in either of these.
|
||||
|
||||
"""
|
||||
# get all entities involved in combat by looking up their combathandlers
|
||||
combatants = [
|
||||
comb
|
||||
for comb in self.obj.location.contents
|
||||
if hasattr(comb, "scripts") and comb.scripts.has(self.key)
|
||||
]
|
||||
|
||||
if self.obj.location.allow_pvp:
|
||||
# in pvp, everyone else is an enemy
|
||||
allies = [combatant]
|
||||
enemies = [comb for comb in combatants if comb != combatant]
|
||||
else:
|
||||
# otherwise, enemies/allies depend on who combatant is
|
||||
pcs = [comb for comb in combatants if inherits_from(comb, EvAdventureCharacter)]
|
||||
npcs = [comb for comb in 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 give_advantage(self, recipient, target):
|
||||
"""
|
||||
Let a benefiter gain advantage against the target.
|
||||
|
||||
Args:
|
||||
recipient (Character or NPC): The one to gain the advantage. This may or may not
|
||||
be the same entity that creates the advantage in the first place.
|
||||
target (Character or NPC): The one against which the target gains advantage. This
|
||||
could (in principle) be the same as the benefiter (e.g. gaining advantage on
|
||||
some future boost)
|
||||
|
||||
"""
|
||||
self.advantages_against[target] = True
|
||||
|
||||
def give_disadvantage(self, recipient, target):
|
||||
"""
|
||||
Let an affected party gain disadvantage against a target.
|
||||
|
||||
Args:
|
||||
recipient (Character or NPC): The one to get the disadvantage.
|
||||
target (Character or NPC): The one against which the target gains disadvantage, usually an enemy.
|
||||
|
||||
"""
|
||||
self.disadvantages_against[target] = True
|
||||
|
||||
def has_advantage(self, combatant, target):
|
||||
"""
|
||||
Check if a given combatant has advantage against a target.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The one to check if they have advantage
|
||||
target (Character or NPC): The target to check advantage against.
|
||||
|
||||
"""
|
||||
return self.advantages_against.get(target, False)
|
||||
|
||||
def has_disadvantage(self, combatant, target):
|
||||
"""
|
||||
Check if a given combatant has disadvantage against a target.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The one to check if they have disadvantage
|
||||
target (Character or NPC): The target to check disadvantage against.
|
||||
|
||||
"""
|
||||
return self.disadvantages_against.get(target, False)
|
||||
|
||||
def queue_action(self, action_dict):
|
||||
"""
|
||||
Schedule the next action to fire.
|
||||
|
||||
Args:
|
||||
action_dict (dict): The new action-dict to initialize.
|
||||
|
||||
"""
|
||||
|
||||
if action_dict["key"] not in self.action_classes:
|
||||
self.obj.msg("This is an unkown action!")
|
||||
return
|
||||
|
||||
# store action dict and schedule it to run in dt time
|
||||
self.action_dict = action_dict
|
||||
dt = action_dict.get("dt", 0)
|
||||
|
||||
if self.current_ticker_ref:
|
||||
# we already have a current ticker going - abort it
|
||||
unrepeat(self.current_ticker_ref)
|
||||
if dt <= 0:
|
||||
# no repeat
|
||||
self.tickerhandler_ref = None
|
||||
else:
|
||||
# always schedule the task to be repeating, cancel later otherwise. We store
|
||||
# the tickerhandler's ref to make sure we can remove it later
|
||||
self.tickerhandler_ref = repeat(dt, self.execute_next_action, id_string="combat")
|
||||
|
||||
def execute_next_action(self):
|
||||
"""
|
||||
Triggered after a delay by the command
|
||||
"""
|
||||
action_dict = self.action_dict
|
||||
action_class = self.action_classes[action_dict["key"]]
|
||||
action = action_class(self, combatant, action_dict)
|
||||
|
||||
if action.can_use():
|
||||
action.execute()
|
||||
action.post_execute()
|
||||
|
||||
if not action_dict.get("repeat", True):
|
||||
# not a repeating action, use the fallback (normally the original attack)
|
||||
self.action_dict = fallback_action_dict
|
||||
self.queue_action(fallback_action_dict.get("dt", 0))
|
||||
|
||||
def check_stop_combat(self):
|
||||
# check if one side won the battle.
|
||||
allies, enemies = self.get_sides()
|
||||
|
||||
if not allies and not enemies:
|
||||
txt = "Noone stands after the dust settles."
|
||||
self.msg(txt)
|
||||
return
|
||||
|
||||
if not allies or not enemies:
|
||||
still_standing = list_to_string(
|
||||
f"$You({comb.key})" for comb in allies + enemies if comb.hp > 0
|
||||
)
|
||||
self.msg(f"The combat is over. {still_standing} are still standing.")
|
||||
self.stop_combat()
|
||||
|
||||
def stop_combat(self):
|
||||
"""
|
||||
Stop combat immediately.
|
||||
"""
|
||||
self.queue_action({"key": "hold", "dt": 0}) # make sure ticker is killed
|
||||
self.delete()
|
||||
|
||||
|
||||
class _BaseTwitchCombatCommand(Command):
|
||||
"""
|
||||
Parent class for all twitch-combat commnads.
|
||||
|
||||
"""
|
||||
|
||||
def at_pre_command(self):
|
||||
"""
|
||||
Called before parsing.
|
||||
|
||||
"""
|
||||
if not self.caller.location or not self.caller.location.allow_combat:
|
||||
self.msg("Can't fight here!")
|
||||
raise InterruptCommand()
|
||||
|
||||
def parse(self):
|
||||
"""
|
||||
Handle parsing of all supported combat syntaxes.
|
||||
|
||||
<action> [<target>|<item>]
|
||||
or
|
||||
<action> <item> [on] <target>
|
||||
|
||||
Use 'on' to differentiate if names/items have spaces in the name.
|
||||
|
||||
"""
|
||||
args = self.args.strip()
|
||||
|
||||
if " on " in args:
|
||||
lhs, rhs = args.split(" on ", 1)
|
||||
else:
|
||||
lhs, *rhs = args.split(None, 1)
|
||||
rhs = " ".join(rhs)
|
||||
self.lhs, self.rhs = lhs.strip(), rhs.strip()
|
||||
|
||||
def get_or_create_combathandler(self, combathandler_name="combathandler"):
|
||||
"""
|
||||
Get or create the combathandler assigned to this combatant.
|
||||
|
||||
"""
|
||||
return EvAdventureCombatHandlerBase.get_or_create_combathandler(self.caller)
|
||||
|
||||
|
||||
class CmdAttack(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Attack a target. Will keep attacking the target until
|
||||
combat ends or another combat action is taken.
|
||||
|
||||
Usage:
|
||||
attack/hit <target>
|
||||
|
||||
"""
|
||||
|
||||
key = "attack"
|
||||
aliases = ["hit"]
|
||||
help_category = "combat"
|
||||
|
||||
def func(self):
|
||||
target = self.search(lhs)
|
||||
if not target:
|
||||
return
|
||||
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
# we use a fixed dt of 3 here, to mimic Diku style; one could also picture
|
||||
# attacking at a different rate, depending on skills/weapon etc.
|
||||
combathandler.queue_action({"key": "attack", "target": target, "dt": 3})
|
||||
combathandler.msg("$You() attacks $You(target.key)!", self.caller)
|
||||
|
||||
|
||||
class CmdLook(default_cmds.CmdLook):
|
||||
def func(self):
|
||||
# get regular look, followed by a combat summary
|
||||
super().func()
|
||||
if not self.args:
|
||||
combathandler = self.get_or_create_combathandler(self.caller.location)
|
||||
txt = str(combathandler.get_combat_summary(self.caller))
|
||||
maxwidth = max(display_len(line) for line in txt.strip().split("\n"))
|
||||
self.msg(f"|r{pad(' Combat Status ', width=maxwidth, fillchar='-')}|n\n{txt}")
|
||||
|
||||
|
||||
class CmdHold(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Hold back your blows, doing nothing.
|
||||
|
||||
Usage:
|
||||
hold
|
||||
|
||||
"""
|
||||
|
||||
key = "hold"
|
||||
|
||||
def func(self):
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
combathandler.queue_action({"key": "hold"})
|
||||
combathandler.msg("$You() $conj(hold) back, doing nothing.", self.caller)
|
||||
|
||||
|
||||
class CmdStunt(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Perform a combat stunt, that boosts an ally against a target, or
|
||||
foils an enemy, giving them disadvantage against an ally.
|
||||
|
||||
Usage:
|
||||
boost [ability] <recipient> <target>
|
||||
foil [ability] <recipient> <target>
|
||||
boost [ability] <target> (same as boost me <target>)
|
||||
foil [ability] <target> (same as foil <target> me)
|
||||
|
||||
Example:
|
||||
boost STR me Goblin
|
||||
boost DEX Goblin
|
||||
foil STR Goblin me
|
||||
foil INT Goblin
|
||||
boost INT Wizard Goblin
|
||||
|
||||
"""
|
||||
|
||||
key = "stunt"
|
||||
aliases = (
|
||||
"boost",
|
||||
"foil",
|
||||
)
|
||||
help_category = "combat"
|
||||
|
||||
def parse(self):
|
||||
super().parse()
|
||||
args = self.args
|
||||
|
||||
if not args or " " not in args:
|
||||
self.msg("Usage: <ability> <recipient> <target>")
|
||||
raise InterruptCommand()
|
||||
|
||||
advantage = self.cmdname != "foil"
|
||||
|
||||
# extract data from the input
|
||||
|
||||
stunt_type, recipient, target = None, None, None
|
||||
|
||||
stunt_type, *args = args.split(None, 1)
|
||||
args = args[0] if args else ""
|
||||
|
||||
recipient, *args = args.split(None, 1)
|
||||
target = args[0] if args else None
|
||||
|
||||
# validate input and try to guess if not given
|
||||
|
||||
# ability is requried
|
||||
if stunt_type.strip() not in ABILITY_REVERSE_MAP:
|
||||
self.msg("That's not a valid ability.")
|
||||
raise InterruptCommand()
|
||||
|
||||
if not recipient:
|
||||
self.msg("Must give at least a recipient or target.")
|
||||
raise InterruptCommand()
|
||||
|
||||
if not target:
|
||||
# something like `boost str target`
|
||||
target = recipient if advantage else "me"
|
||||
recipient = "me" if advantage else recipient
|
||||
|
||||
# if we still have None:s at this point, we can't continue
|
||||
if None in (stunt_type, recipient, target):
|
||||
self.msg("Both ability, recipient and target of stunt must be given.")
|
||||
raise InterruptCommand()
|
||||
|
||||
# save what we found so it can be accessed from func()
|
||||
self.advantage = advantage
|
||||
self.stunt_type = ABILITY_REVERSE_MAP[stunt_type.strip()]
|
||||
self.recipient = recipient.strip()
|
||||
self.target = target.strip()
|
||||
|
||||
def func(self):
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
|
||||
target = self.caller.search(self.target, candidates=combathandler.combatants.keys())
|
||||
if not target:
|
||||
return
|
||||
recipient = self.caller.search(self.recipient, candidates=combathandler.combatants.keys())
|
||||
if not recipient:
|
||||
return
|
||||
|
||||
combathandler.queue_action(
|
||||
self.caller,
|
||||
{
|
||||
"key": "stunt",
|
||||
"recipient": recipient,
|
||||
"target": target,
|
||||
"advantage": self.advantage,
|
||||
"stunt_type": self.stunt_type,
|
||||
"defense_type": self.stunt_type,
|
||||
},
|
||||
)
|
||||
combathandler.msg("$You() prepare a stunt!", self.caller)
|
||||
|
||||
|
||||
class CmdUseItem(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Use an item in combat. The item must be in your inventory to use.
|
||||
|
||||
Usage:
|
||||
use <item>
|
||||
use <item> [on] <target>
|
||||
|
||||
Examples:
|
||||
use potion
|
||||
use throwing knife on goblin
|
||||
use bomb goblin
|
||||
|
||||
"""
|
||||
|
||||
key = "use"
|
||||
help_category = "combat"
|
||||
|
||||
def parse(self):
|
||||
super().parse()
|
||||
args = self.args
|
||||
|
||||
if not args:
|
||||
self.msg("What do you want to use?")
|
||||
raise InterruptCommand()
|
||||
elif "on" in args:
|
||||
self.item, self.target = (part.strip() for part in args.split("on", 1))
|
||||
else:
|
||||
self.item, *target = args.split(None, 1)
|
||||
self.target = target[0] if target else "me"
|
||||
|
||||
def func(self):
|
||||
item = self.caller.search(
|
||||
self.item, candidates=self.caller.equipment.get_usable_objects_from_backpack()
|
||||
)
|
||||
if not item:
|
||||
self.msg("(You must carry the item to use it.)")
|
||||
return
|
||||
if self.target:
|
||||
target = self.caller.search(self.target)
|
||||
if not target:
|
||||
return
|
||||
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
combathandler.queue_action(self.caller, {"key": "use", "item": item, "target": self.target})
|
||||
combathandler.msg(
|
||||
f"$You() prepare to use {item.get_display_name(self.caller)}!", self.caller
|
||||
)
|
||||
|
||||
|
||||
class CmdWield(_CmdCombatBase):
|
||||
"""
|
||||
Wield a weapon or spell-rune. You will the wield the item, swapping with any other item(s) you
|
||||
were wielded before.
|
||||
|
||||
Usage:
|
||||
wield <weapon or spell>
|
||||
|
||||
Examples:
|
||||
wield sword
|
||||
wield shield
|
||||
wield fireball
|
||||
|
||||
Note that wielding a shield will not replace the sword in your hand, while wielding a two-handed
|
||||
weapon (or a spell-rune) will take two hands and swap out what you were carrying.
|
||||
|
||||
"""
|
||||
|
||||
key = "wield"
|
||||
help_category = "combat"
|
||||
|
||||
def parse(self):
|
||||
if not self.args:
|
||||
self.msg("What do you want to wield?")
|
||||
raise InterruptCommand()
|
||||
super().parse()
|
||||
|
||||
def func(self):
|
||||
item = self.caller.search(
|
||||
self.args, candidates=self.caller.equipment.get_wieldable_objects_from_backpack()
|
||||
)
|
||||
if not item:
|
||||
self.msg("(You must carry the item to wield it.)")
|
||||
return
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
combathandler.queue_action(self.caller, {"key": "wield", "item": item})
|
||||
combathandler.msg(
|
||||
f"$You() start wielding {item.get_display_name(self.caller)}!", self.caller
|
||||
)
|
||||
|
||||
|
||||
class TwitchAttackCmdSet(CmdSet):
|
||||
"""
|
||||
Add to character, to be able to attack others in a twitch-style way.
|
||||
"""
|
||||
|
||||
def at_cmdset_creation(self):
|
||||
self.add(CmdAttack())
|
||||
self.add(CmdLook())
|
||||
self.add(CmdHold())
|
||||
self.add(CmdStunt())
|
||||
self.add(CmdUseItem())
|
||||
self.add(CmdWield())
|
||||
@ -6,7 +6,6 @@ added to all game objects. You access it through the property
|
||||
|
||||
"""
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from evennia.scripts.models import ScriptDB
|
||||
from evennia.utils import create, logger
|
||||
|
||||
@ -112,6 +111,19 @@ class ScriptHandler(object):
|
||||
num += 1
|
||||
return num
|
||||
|
||||
def has(self, key):
|
||||
"""
|
||||
Determine if a given script exists on this object.
|
||||
|
||||
Args:
|
||||
key (str): Search criterion, the script's key or dbref.
|
||||
|
||||
Returns:
|
||||
bool: If the script exists or not.
|
||||
|
||||
"""
|
||||
return ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key).exists()
|
||||
|
||||
def get(self, key):
|
||||
"""
|
||||
Search scripts on this object.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user