301 lines
8.6 KiB
ReStructuredText
301 lines
8.6 KiB
ReStructuredText
==============================
|
|
combat system — moves as data
|
|
==============================
|
|
|
|
combat in this MUD is designed around the principle that moves are content,
|
|
not engine code. the engine provides a state machine and timing system. moves
|
|
are data files that define telegraphs, windows, and counters. this keeps the
|
|
combat system extensible without touching python.
|
|
|
|
|
|
core design principles
|
|
======================
|
|
|
|
1. combat moves are CONTENT (TOML files in content/combat/)
|
|
2. the engine is a STATE MACHINE that processes timing and resolution
|
|
3. direction matters — punch right is countered by dodge left
|
|
4. no "fight" command — first attack initiates combat
|
|
5. after combat starts, target is implicit until encounter ends
|
|
|
|
|
|
command structure
|
|
=================
|
|
|
|
commands are always a single word. the dispatcher splits input on the first
|
|
space: first word is the command, the rest is args.
|
|
|
|
some moves have directional variants. the direction is an argument to the
|
|
command, not part of the command name::
|
|
|
|
punch right frank → command "punch", args "right frank"
|
|
punch left → command "punch", args "left"
|
|
roundhouse frank → command "roundhouse", args "frank"
|
|
sweep → command "sweep", args ""
|
|
|
|
variant moves (punch, dodge, parry) require a direction. simple moves
|
|
(roundhouse, sweep, duck, jump) don't.
|
|
|
|
each variant has its own short alias registered as a standalone command::
|
|
|
|
pr frank → "punch right frank" (pr is a direct command)
|
|
dl → "dodge left"
|
|
f → "parry high"
|
|
|
|
aliases are defined in the TOML file and registered at startup. they bypass
|
|
the variant parsing entirely — the handler already knows which move it is.
|
|
|
|
|
|
the state machine
|
|
=================
|
|
|
|
::
|
|
|
|
IDLE → PENDING → RESOLVE → IDLE
|
|
|
|
IDLE:
|
|
no active move. attacker can initiate attack. defender can do nothing
|
|
combat-specific.
|
|
|
|
PENDING:
|
|
attacker has declared a move. telegraph message sent to defender.
|
|
the move is now in flight — defender can queue a defense any time
|
|
during this phase. duration is the attack's hit_time_ms.
|
|
|
|
RESOLVE:
|
|
hit_time_ms elapsed. check if defender's active defense counters the
|
|
attack. calculate damage, apply stamina costs, check for knockouts
|
|
or exhaustion. return to IDLE.
|
|
|
|
|
|
entity stats
|
|
============
|
|
|
|
each Entity (player, mob, NPC) has::
|
|
|
|
pl: float = 100.0
|
|
power level. acts as both health AND damage multiplier.
|
|
when PL hits 0, entity is knocked out.
|
|
|
|
stamina: float = 100.0
|
|
current stamina. each move costs stamina.
|
|
when stamina hits 0, entity passes out.
|
|
|
|
max_stamina: float = 100.0
|
|
stamina ceiling. can be affected by training, items, etc (future).
|
|
|
|
|
|
the move set
|
|
============
|
|
|
|
ATTACKS:
|
|
punch right/left (pr/pl)
|
|
basic hooks. fast, low cost.
|
|
|
|
roundhouse (rh)
|
|
big spinning kick. higher damage, tighter parry window.
|
|
|
|
sweep (sw)
|
|
leg sweep. knocks down if not countered.
|
|
|
|
DEFENSES:
|
|
dodge right/left (dr/dl)
|
|
sidestep. counters opposite-side attacks.
|
|
|
|
parry high/low (f/v)
|
|
block attacks. tighter timing window than dodge.
|
|
|
|
duck
|
|
crouch under high attacks. counters roundhouse.
|
|
|
|
jump
|
|
leap over low attacks. counters sweep.
|
|
|
|
|
|
counter relationships
|
|
=====================
|
|
|
|
defined in each move's TOML file via the "countered_by" field.
|
|
|
|
::
|
|
|
|
punch right → countered_by: [dodge left, parry high]
|
|
punch left → countered_by: [dodge right, parry high]
|
|
roundhouse → countered_by: [duck, parry high, parry low]
|
|
sweep → countered_by: [jump, parry low]
|
|
|
|
the engine doesn't know these relationships. it just checks:
|
|
"is defender's move in attacker's move.countered_by list?"
|
|
|
|
|
|
damage formula
|
|
==============
|
|
|
|
if defense succeeds (is in countered_by list)::
|
|
|
|
damage = 0
|
|
defender gets "you countered the attack!" message
|
|
|
|
if defense fails (wrong move or no move)::
|
|
|
|
damage = attacker.pl * move.damage_pct
|
|
defender.pl -= damage
|
|
|
|
if no defense was attempted::
|
|
|
|
damage = attacker.pl * move.damage_pct * 1.5
|
|
defender.pl -= damage
|
|
defender gets "you took the hit full force!" message
|
|
|
|
stamina costs are always applied, regardless of outcome::
|
|
|
|
attacker.stamina -= move.stamina_cost
|
|
if defender attempted defense:
|
|
defender.stamina -= defense_move.stamina_cost
|
|
|
|
|
|
starting combat
|
|
===============
|
|
|
|
NO explicit "fight" command. instead::
|
|
|
|
punch right goku
|
|
if player is not in combat: starts encounter with goku, pushes "combat" mode
|
|
if player is already in combat: error, already fighting someone else
|
|
|
|
after combat starts, target is implicit::
|
|
|
|
punch left
|
|
dodge right
|
|
roundhouse
|
|
|
|
all commands assume the current opponent.
|
|
|
|
|
|
exiting combat
|
|
==============
|
|
|
|
combat ends when:
|
|
|
|
- one combatant's PL reaches 0 (knockout)
|
|
- one combatant's stamina reaches 0 (exhaustion)
|
|
- one combatant flees (future: flee command)
|
|
- one combatant is killed (future: lethal combat toggle)
|
|
|
|
when combat ends, both entities' mode stacks pop back to "normal".
|
|
|
|
|
|
TOML move definitions
|
|
=====================
|
|
|
|
moves live in content/combat/. two formats: simple and variant.
|
|
|
|
**simple move** (one file, one command)::
|
|
|
|
# content/combat/roundhouse.toml
|
|
name = "roundhouse"
|
|
aliases = ["rh"]
|
|
move_type = "attack"
|
|
stamina_cost = 8.0
|
|
telegraph = "{attacker} spins into a roundhouse kick!"
|
|
hit_time_ms = 600
|
|
damage_pct = 0.25
|
|
countered_by = ["duck", "parry high", "parry low"]
|
|
|
|
**variant move** (one file, directional variants)::
|
|
|
|
# content/combat/punch.toml
|
|
name = "punch"
|
|
move_type = "attack"
|
|
stamina_cost = 5.0
|
|
hit_time_ms = 800
|
|
damage_pct = 0.15
|
|
|
|
[variants.left]
|
|
aliases = ["pl"]
|
|
telegraph = "{attacker} winds up a left hook!"
|
|
countered_by = ["dodge right", "parry high"]
|
|
|
|
[variants.right]
|
|
aliases = ["pr"]
|
|
telegraph = "{attacker} winds up a right hook!"
|
|
countered_by = ["dodge left", "parry high"]
|
|
|
|
shared properties (stamina_cost, hit_time_ms, damage_pct) are defined at
|
|
the top level. variants inherit these and can override them. variant-specific
|
|
properties (telegraph, countered_by, aliases) live under [variants.<key>].
|
|
|
|
each variant produces a CombatMove with a qualified name like "punch left".
|
|
the ``command`` field tracks the base command ("punch") and ``variant`` tracks
|
|
the key ("left"). simple moves have command=name and variant="".
|
|
|
|
**defense move** (directional)::
|
|
|
|
# content/combat/dodge.toml
|
|
name = "dodge"
|
|
move_type = "defense"
|
|
stamina_cost = 3.0
|
|
active_ms = 800
|
|
recovery_ms = 2700
|
|
|
|
[variants.left]
|
|
|
|
[variants.right]
|
|
|
|
TOML field reference:
|
|
|
|
- name: the command name (simple) or base command (variant)
|
|
- move_type: "attack" or "defense"
|
|
- stamina_cost: stamina consumed when using this move
|
|
- hit_time_ms: (attacks) time in ms from initiation to impact
|
|
- active_ms: (defenses) how long defense blocks once activated, in ms
|
|
- recovery_ms: (defenses) lockout after active window ends, in ms
|
|
- telegraph: shown to defender during PENDING phase. {attacker} replaced
|
|
- damage_pct: fraction of attacker's PL dealt as damage
|
|
- countered_by: list of qualified move names that counter this move
|
|
- aliases: short command aliases registered as standalone commands
|
|
|
|
|
|
handler architecture
|
|
====================
|
|
|
|
three types of command handlers, created at registration time:
|
|
|
|
**direct handler** — for simple moves (roundhouse, sweep) and variant aliases
|
|
(pl, pr). the move is bound at registration via closure. args = target name.
|
|
|
|
**variant handler** — for base variant commands (punch, dodge, parry). parses
|
|
first arg as direction, looks up the variant, passes remaining args as target.
|
|
|
|
**core functions** — do_attack() and do_defend() contain the actual combat
|
|
logic. they take (player, target_args, move) and handle encounter creation,
|
|
stamina checks, telegraph sending, etc. handlers call into these.
|
|
|
|
this means adding a new move = add a TOML file. the registration code groups
|
|
variants automatically, creates the right handler type, and registers aliases.
|
|
|
|
|
|
future features (NOT YET IMPLEMENTED)
|
|
======================================
|
|
|
|
stuns:
|
|
successful parry stuns opponent for 1 tick.
|
|
during stun, engine shows combo sequence in shorthand:
|
|
"you can follow up with: pr/pl/sw or pr/pl/rh"
|
|
player has narrow window to input the sequence.
|
|
|
|
combos:
|
|
if player executes shown sequence during stun window, bonus damage applied.
|
|
after successful combo, chance to chain (diminishing probability).
|
|
longer chains = exponentially harder timing.
|
|
|
|
special moves:
|
|
moves that require conditions (opponent stunned, player at high PL, etc).
|
|
defined via "requires" field in TOML.
|
|
|
|
lethal combat:
|
|
toggle that makes PL=0 mean death, not knockout.
|
|
affects NPC encounters in dangerous zones.
|
|
|
|
multi-combatant:
|
|
extend encounter to support more than 1v1.
|
|
each attacker/defender pair still follows same state machine.
|