McRogueFace/games/unwritten/systems/battle.py

1742 lines
68 KiB
Python
Raw Normal View History

"""UNWRITTEN - turn engine + battle screen. Owner: Agent B.
Public surface:
start_battle(pack_id, on_victory=None, on_defeat=None)
Two layers, deliberately separable so the tests can run thousands of fights
with no UI:
BattleCore - pure mechanics (SYSTEMS sections 2-4, BIBLE section 4 bosses).
Touches no mcrfpy objects. Combatant.view is set by the screen
but never read by the core.
BattleScreen - the 1024x768 presentation that drives a BattleCore turn by
turn, animating each result the core hands back.
Registers systems.dialogue.HOOKS["battle"] = start_battle at import.
ASCII only. "Fail early": unknown packs/skills/items raise.
"""
import random
import mcrfpy
from core import palette, ui, tween, assets
from core.inputstack import InputStack
from systems.state import GS
from data import characters as chardata
from data import items as itemdata
from data import enemies as enemydata
from data.barks import BARKS
from data.maps import BATTLE_BACKDROPS
# =============================================================================
# constants / layout
# =============================================================================
FLOOR_Y = 430
SCREEN_W = 1024
SCREEN_H = 768
# enemy sprite-center x positions by count (left half of the screen)
_ENEMY_SLOTS = {
1: [300],
2: [180, 400],
3: [130, 300, 470],
}
# extra slots used when the Custodian / Gatekeeper summon reinforcements
_SUMMON_SLOTS = [120, 470, 60, 520, 240]
_MAX_ROUNDS = 60 # safety valve for the headless simulator
# =============================================================================
# barks
# =============================================================================
class Barker:
"""Picks a battle bark line, never repeating the immediately previous one."""
def __init__(self):
self.last = None
def line(self, char_id, event):
opts = BARKS.get(char_id, {}).get(event)
if not opts:
return None
pool = [o for o in opts if o != self.last] or list(opts)
pick = random.choice(pool)
self.last = pick
return pick
# =============================================================================
# combatant
# =============================================================================
class Combatant:
def __init__(self, side, key, name, base, max_hp, max_sp, labels,
crit_base, sprite_index, tex, scale, ai,
char_id=None, enemy_id=None, cstate=None):
self.side = side # "party" | "enemy"
self.key = key
self.name = name
self.base = dict(base) # ATK/MAG/DEF/SPD (and HP/SP nominal)
self._max_hp = int(max_hp)
self._max_sp = int(max_sp)
self.labels = set(labels or ())
self.crit_base = crit_base
self.sprite_index = sprite_index
self.tex = tex
self.scale = scale
self.ai = ai
self.char_id = char_id
self.enemy_id = enemy_id
self.cstate = cstate
self._hp = cstate.hp if cstate is not None else int(max_hp)
self._sp = cstate.sp if cstate is not None else int(max_sp)
self.mods = [] # [{"stat","add","mult","turns","tag"}]
self.guarding = False
self.stun_turns = 0
self.redact_turns = 0
self.guaranteed_crit = False
self.picked = set() # pickpocket: enemy keys already robbed
self.ai_counter = 0
self.view = None # BattleScreen sets/reads its own dict
# ---- pools -------------------------------------------------------------
@property
def max_hp(self):
return self._max_hp
@property
def max_sp(self):
return self._max_sp
@property
def hp(self):
return self._hp
@hp.setter
def hp(self, v):
self._hp = max(0, min(int(v), self._max_hp))
if self.cstate is not None:
self.cstate.hp = self._hp
@property
def sp(self):
return self._sp
@sp.setter
def sp(self, v):
self._sp = max(0, min(int(v), self._max_sp))
if self.cstate is not None:
self.cstate.sp = self._sp
@property
def alive(self):
return self._hp > 0
# ---- effective stats ---------------------------------------------------
def stat(self, k):
v = self.base.get(k, 0)
add = 0
mult = 1.0
for m in self.mods:
if m["stat"] == k:
add += m.get("add", 0)
mult += m.get("mult", 0.0)
return max(0, int(round((v + add) * mult)))
def add_mod(self, stat, mult=0.0, add=0, turns=None, tag=None):
self.mods.append({"stat": stat, "mult": mult, "add": add,
"turns": turns, "tag": tag})
def remove_mods_tagged(self, tag):
self.mods = [m for m in self.mods if m.get("tag") != tag]
def clear_debuffs(self):
self.mods = [m for m in self.mods if m.get("mult", 0.0) >= 0
and m.get("add", 0) >= 0]
# =============================================================================
# battle core (pure logic)
# =============================================================================
class BattleCore:
def __init__(self, pack_id):
self.pack_id = pack_id
self.is_boss = pack_id.startswith("boss_")
self.log = []
self.round = 0
self.order = []
self.turn_index = 0
self.deferred = []
self.result = None # "victory" | "defeat" | "flee"
# shieldwall
self.party_guard_active = False
self.party_guard_owner = None
# boss state
self.gk_inhale = False
self.cust_phase2 = False
self.cust_skip_next = False
self.revert_disabled = False
self.talk_used = set()
# custodian revert bookkeeping
self.actions_last_round = set()
self.actions_this_round = set()
self.cust_damage_this_round = 0
self.stolen_gold = 0
self._used_slots = []
self._initial_pack = len(enemydata.pack(pack_id))
self.party = self._build_party()
self.enemies = self._build_enemies()
# ---- construction ------------------------------------------------------
def _build_party(self):
out = []
for cid in GS.party[:3]:
cs = GS.roster.get(cid)
if cs is None:
raise KeyError("party member %r has no CharState" % (cid,))
st = cs.stats()
out.append(Combatant(
"party", "p_" + cid, cid, st, cs.max_hp, cs.max_sp,
labels=(), crit_base=chardata.CRIT_BASE.get(cid, 0.06),
sprite_index=chardata.CHAR_SPRITES[cid], tex=None,
scale=2.5, ai=None, char_id=cid, cstate=cs))
if not out:
raise RuntimeError("cannot start battle with an empty party")
return out
def _make_enemy(self, enemy_id, idx):
rec = enemydata.enemy(enemy_id)
c = Combatant(
"enemy", "e%d_%s" % (idx, enemy_id), rec["name"],
{"ATK": rec["ATK"], "MAG": rec["MAG"], "DEF": rec["DEF"],
"SPD": rec["SPD"]},
rec["HP"], 0, labels=rec["labels"], crit_base=0.04,
sprite_index=rec["sprite"], tex=rec["tex"], scale=rec["scale"],
ai=rec["ai"], enemy_id=enemy_id)
c.reward_xp = rec["XP"]
c.reward_gold = rec["gold"]
c.drop = rec["drop"]
return c
def _build_enemies(self):
out = []
ids = enemydata.pack(self.pack_id)
for i, eid in enumerate(ids):
c = self._make_enemy(eid, i)
out.append(c)
if eid == "gatekeeper":
c.add_mod("DEF", add=5, turns=None, tag="ward") # the Ward
self._enemy_seq = len(ids)
return out
def _spawn_enemy(self, enemy_id, result):
c = self._make_enemy(enemy_id, self._enemy_seq)
self._enemy_seq += 1
self.enemies.append(c)
result["fx"].append({"kind": "summon", "target": c})
return c
# ---- roster helpers ----------------------------------------------------
def all(self):
return self.party + self.enemies
def alive_party(self):
return [c for c in self.party if c.alive]
def alive_enemies(self):
return [c for c in self.enemies if c.alive]
def opponents(self, actor):
return self.alive_enemies() if actor.side == "party" else self.alive_party()
def allies(self, actor):
return self.alive_party() if actor.side == "party" else self.alive_enemies()
def _rand(self, lst):
return random.choice(lst) if lst else None
def _find_enemy(self, enemy_id):
for e in self.enemies:
if e.enemy_id == enemy_id:
return e
return None
def _active_boss(self):
if self.pack_id == "boss_gatekeeper":
return self._find_enemy("gatekeeper")
if self.pack_id == "boss_custodian":
return self._find_enemy("custodian")
return None
# ---- round flow --------------------------------------------------------
def begin_round(self):
self.round += 1
self.actions_last_round = self.actions_this_round
self.actions_this_round = set()
self.cust_damage_this_round = 0
if self.party_guard_owner is not None and not self.party_guard_owner.alive:
self.party_guard_active = False
self.party_guard_owner = None
alive = [c for c in self.all() if c.alive]
alive.sort(key=lambda c: (-c.stat("SPD"), random.random()))
self.order = alive
self.turn_index = 0
def next_actor(self):
"""Return (actor, forced_action) or None when the round is exhausted.
forced_action is None for a normal turn, {"type":"skip"} when stunned,
or a stored action for a deferred (Bass Drop) resolution."""
while self.turn_index < len(self.order):
c = self.order[self.turn_index]
self.turn_index += 1
if not c.alive:
continue
skip = self._turn_start(c)
if skip:
return (c, {"type": "skip", "reason": skip})
return (c, None)
while self.deferred:
c, act = self.deferred.pop(0)
if c.alive:
return (c, act)
return None
def _turn_start(self, c):
c.guarding = False
if self.party_guard_owner is c:
self.party_guard_active = False
self.party_guard_owner = None
if c.stun_turns > 0:
c.stun_turns -= 1
return "stun"
if c.enemy_id == "custodian" and self.cust_skip_next:
self.cust_skip_next = False
return "book"
return None
def end_round(self):
"""Custodian Revert + duration ticks. Returns a result-like dict."""
result = {"actor": None, "log": [], "fx": []}
self._custodian_revert(result)
for c in self.all():
self._tick_durations(c)
return result
def _tick_durations(self, c):
keep = []
for m in c.mods:
if m.get("turns") is None:
keep.append(m)
else:
m["turns"] -= 1
if m["turns"] > 0:
keep.append(m)
c.mods = keep
# ---- end detection -----------------------------------------------------
def check_end(self):
if not self.alive_enemies():
self.result = "victory"
return True
if not self.alive_party():
self.result = "defeat"
return True
return False
# =====================================================================
# action resolution
# =====================================================================
def resolve(self, actor, action):
result = {"actor": actor, "log": [], "fx": [], "deferred": False,
"bark": None, "flee": False, "swap": None, "end_turn": True}
t = action.get("type")
if t == "skip":
if action.get("reason") == "book":
pass # the talk option already narrated the skip
elif action.get("reason") == "stun":
result["log"].append("%s cannot move." % actor.name)
else:
result["log"].append("%s flutters past." % actor.name)
elif t == "attack":
self._do_attack(actor, action, result)
elif t == "skill":
self._do_skill(actor, action, result)
elif t == "item":
self._do_item(actor, action, result)
elif t == "guard":
self._do_guard(actor, result)
elif t == "eweb":
self._do_eweb(actor, action, result)
elif t == "swap":
self._do_swap(actor, action, result)
elif t == "talk":
self._do_talk(actor, action, result)
elif t == "flee":
self._do_flee(actor, result)
elif t == "boss":
self._do_boss_move(actor, action, result)
else:
raise ValueError("unknown battle action type %r" % (t,))
# custodian revert bookkeeping: record party action tokens
if actor.side == "party" and not result["deferred"]:
tok = self._action_token(t, action)
if tok:
self.actions_this_round.add(tok)
self._maybe_phase2(result)
# end-of-turn duration bookkeeping for the actor
if not result["deferred"]:
if actor.redact_turns > 0:
actor.redact_turns -= 1
return result
def _action_token(self, t, action):
if t == "attack":
return "attack"
if t == "skill":
return "skill:" + action["skill"]["id"]
if t == "item":
return "item:" + action["item"]
if t == "guard":
return "guard"
return None
# ---- damage primitives -------------------------------------------------
def _strike(self, attacker, target, mult, magical, crit_chance, result,
force_crit=False):
if not target.alive:
return 0
# Nyx evasion (incoming)
if target.char_id == "NYX" and random.random() < chardata.NYX_EVASION:
result["fx"].append({"kind": "miss", "target": target})
result["log"].append("Nyx is not where the blow lands.")
return 0
# Nyx guaranteed-crit consumption
if attacker.char_id == "NYX" and attacker.guaranteed_crit:
force_crit = True
attacker.guaranteed_crit = False
atk = attacker.stat("MAG" if magical else "ATK")
dfn = target.stat("DEF")
if magical:
dfn = dfn // 2
var = random.uniform(0.85, 1.15)
dmg = max(1, int(round((atk * mult - dfn) * var)))
crit = force_crit or (random.random() < crit_chance)
if crit:
dmg = int(round(dmg * 1.5))
# guard + shieldwall mitigation
if target.guarding:
dmg = max(1, dmg // 2)
if target.side == "party" and self.party_guard_active:
dmg = max(1, dmg // 2)
target.hp = target.hp - dmg
result["fx"].append({"kind": "hit", "target": target, "amount": dmg,
"crit": crit, "magical": magical})
if attacker.side == "party" and target.enemy_id == "custodian":
self.cust_damage_this_round += dmg
if not target.alive:
result["fx"].append({"kind": "death", "target": target})
# Hedge Slime splits once at death in packs of <= 2 (SYSTEMS section 4)
if (target.enemy_id == "hedge_slime"
and self._initial_pack <= 2
and not getattr(target, "_split_done", False)):
target._split_done = True
self._spawn_enemy("slimelet", result)
result["log"].append("The Hedge Slime splits.")
return dmg
def _heal(self, target, amount, result):
amount = int(amount)
before = target.hp
target.hp = target.hp + amount
gained = target.hp - before
result["fx"].append({"kind": "heal", "target": target, "amount": gained})
return gained
# ---- basic attack ------------------------------------------------------
def _do_attack(self, actor, action, result):
target = action.get("target")
if target is None or not target.alive:
opp = self.opponents(actor)
target = self._rand(opp)
if target is None:
return
magical = action.get("magical", False)
crit = actor.crit_base
result["fx"].append({"kind": "lunge", "actor": actor, "target": target})
self._strike(actor, target, 1.0, magical, crit, result)
# ---- skills ------------------------------------------------------------
def _do_skill(self, actor, action, result):
skill = action["skill"]
special = skill["special"]
# Bass Drop defers to the end of the round the first time it is chosen.
if special == "bass_drop" and not action.get("_deferred"):
actor.sp = actor.sp - skill["sp"]
self.deferred.append((actor, dict(action, _deferred=True)))
result["deferred"] = True
result["log"].append("Cantor holds the note. It will land last.")
return
if not action.get("_deferred"):
actor.sp = actor.sp - skill["sp"]
if actor.side == "party":
evt = "skill_a" if skill["unlock"] == 1 else "skill_b"
result["bark"] = (actor.char_id, evt)
if special == "twinstrike":
target = self._pick_enemy_target(actor, action)
result["fx"].append({"kind": "lunge", "actor": actor, "target": target})
for _ in range(skill["hits"]):
if target.alive:
self._strike(actor, target, skill["mult"], False,
actor.crit_base, result)
elif special == "pickpocket":
target = self._pick_enemy_target(actor, action)
result["fx"].append({"kind": "lunge", "actor": actor, "target": target})
self._strike(actor, target, skill["mult"], False, actor.crit_base,
result)
if target.key not in actor.picked and target.alive:
actor.picked.add(target.key)
loot = random.randint(8, 20)
self.stolen_gold += loot
GS.add_gold(loot)
result["log"].append("Pip lifts %d cogs." % loot)
elif special == "shieldwall":
self.party_guard_active = True
self.party_guard_owner = actor
result["log"].append("Bramble braces. The party takes half damage.")
elif special == "vow_strike":
target = self._pick_enemy_target(actor, action)
missing = 1.0 - (actor.hp / float(actor.max_hp))
mult = min(2.0, 1.0 + 0.1 * int(missing * 10))
result["fx"].append({"kind": "lunge", "actor": actor, "target": target})
self._strike(actor, target, mult, False, actor.crit_base, result)
elif special == "kindle":
target = action.get("target") or self._lowest_ally(actor)
amt = 24 + 2 * actor.stat("MAG")
self._heal(target, amt, result)
result["log"].append("Moth kindles %s." % target.name)
elif special == "moonflare":
for e in self.alive_enemies():
self._strike(actor, e, skill["mult"], True, actor.crit_base,
result)
result["fx"].append({"kind": "shake"})
elif special == "acid_flask":
for e in self.alive_enemies():
self._strike(actor, e, skill["mult"], True, actor.crit_base,
result)
d = skill["debuff"]
e.add_mod(d["stat"], mult=d["mult"], turns=d["turns"], tag="acid")
result["fx"].append({"kind": "debuff", "target": e})
elif special == "tonic_toss":
target = action.get("target") or self._lowest_ally(actor)
if target.char_id == "NYX":
result["log"].append("The tonic passes through her.")
else:
amt = 16 + actor.stat("MAG")
target.clear_debuffs()
self._heal(target, amt, result)
result["log"].append("Vera tosses a tonic to %s." % target.name)
elif special == "resonate":
target = self._pick_enemy_target(actor, action)
result["fx"].append({"kind": "lunge", "actor": actor, "target": target})
self._strike(actor, target, skill["mult"], True, actor.crit_base,
result)
if target.alive and target.labels & {"construct", "undead"}:
target.stun_turns = max(target.stun_turns, 1)
result["log"].append("%s is stunned by the chord." % target.name)
elif special == "bass_drop":
target = self._pick_enemy_target(actor, action)
result["fx"].append({"kind": "lunge", "actor": actor, "target": target})
self._strike(actor, target, skill["mult"], False, actor.crit_base,
result)
result["fx"].append({"kind": "shake"})
elif special == "grave_chill":
target = self._pick_enemy_target(actor, action)
self._strike(actor, target, skill["mult"], True, actor.crit_base,
result)
if target.alive:
d = skill["debuff"]
target.add_mod(d["stat"], mult=d["mult"], turns=d["turns"],
tag="chill")
result["fx"].append({"kind": "debuff", "target": target})
elif special == "second_shadow":
actor.guaranteed_crit = True
result["log"].append("Nyx steps into her own shadow.")
else:
raise ValueError("unhandled skill special %r" % (special,))
def _pick_enemy_target(self, actor, action):
target = action.get("target")
if target is None or not target.alive:
target = self._rand(self.opponents(actor))
return target
def _lowest_ally(self, actor):
allies = self.allies(actor)
return min(allies, key=lambda c: c.hp / float(c.max_hp)) if allies else actor
# ---- items -------------------------------------------------------------
def _do_item(self, actor, action, result):
iid = action["item"]
rec = itemdata.item(iid)
eff = rec["effect"]
target = action.get("target")
if eff == "aoe_damage":
GS.take(iid, 1)
for e in self.alive_enemies():
dmg = max(1, rec["amount"])
if e.guarding:
dmg = max(1, dmg // 2)
e.hp = e.hp - dmg
result["fx"].append({"kind": "hit", "target": e, "amount": dmg,
"crit": False, "magical": True})
if e.enemy_id == "custodian":
self.cust_damage_this_round += dmg
if not e.alive:
result["fx"].append({"kind": "death", "target": e})
result["fx"].append({"kind": "shake"})
result["log"].append("%s throws a Bomb Flask." % actor.name)
return
if target is None:
target = self._lowest_ally(actor)
# Nyx cannot be restored by items (HP-restoring effects only).
if target.char_id == "NYX" and eff in ("heal", "cleanse_heal", "revive"):
result["log"].append("The tonic passes through her.")
return
GS.take(iid, 1)
if eff == "heal":
self._heal(target, rec["amount"], result)
result["log"].append("%s uses %s." % (actor.name, rec["name"]))
elif eff == "sp":
target.sp = target.sp + rec["amount"]
result["fx"].append({"kind": "sp", "target": target,
"amount": rec["amount"]})
result["log"].append("%s restores SP with %s." % (actor.name, rec["name"]))
elif eff == "cleanse_heal":
target.clear_debuffs()
self._heal(target, rec["amount"], result)
result["log"].append("%s uses %s." % (actor.name, rec["name"]))
elif eff == "revive":
if target.alive:
result["log"].append("%s is already standing." % target.name)
else:
target.hp = int(target.max_hp * rec["amount"])
result["fx"].append({"kind": "revive", "target": target})
result["log"].append("%s is written back with Chalk." % target.name)
else:
raise ValueError("unhandled item effect %r" % (eff,))
# ---- guard / swap / eweb ----------------------------------------------
def _do_guard(self, actor, result):
actor.guarding = True
actor.sp = actor.sp + 2
result["fx"].append({"kind": "guard", "target": actor})
result["log"].append("%s guards." % actor.name)
def _do_eweb(self, actor, action, result):
target = action.get("target") or self._rand(self.alive_party())
if target is None:
return
target.add_mod("SPD", mult=-0.30, turns=2, tag="web")
result["fx"].append({"kind": "debuff", "target": target})
result["log"].append("The Loom Spider webs %s. It slows." % target.name)
def _do_swap(self, actor, action, result):
new_id = action["swap_to"]
cs = GS.roster.get(new_id)
if cs is None:
raise KeyError("cannot swap to %r: not in roster" % (new_id,))
st = cs.stats()
newc = Combatant(
"party", "p_" + new_id, new_id, st, cs.max_hp, cs.max_sp,
labels=(), crit_base=chardata.CRIT_BASE.get(new_id, 0.06),
sprite_index=chardata.CHAR_SPRITES[new_id], tex=None,
scale=2.5, ai=None, char_id=new_id, cstate=cs)
idx = self.party.index(actor)
self.party[idx] = newc
# update the active party ordering in GS
if actor.char_id in GS.party:
gi = GS.party.index(actor.char_id)
GS.party[gi] = new_id
result["swap"] = (actor, newc)
result["log"].append("%s steps back; %s steps in." % (actor.name, newc.name))
def _do_flee(self, actor, result):
if self.is_boss:
result["log"].append("There is no fleeing this.")
return
if random.random() < 0.70:
result["flee"] = True
result["log"].append("The party slips away.")
else:
result["log"].append("Couldn't get away!")
# =====================================================================
# enemy / boss AI
# =====================================================================
def ai_action(self, actor):
ai = actor.ai
if ai == "gatekeeper":
return self._gk_action(actor)
if ai == "custodian":
return self._cust_action(actor)
if ai == "bat" and random.random() < 0.25:
return {"type": "skip", "reason": "flutter"}
if ai == "scarab" and actor.hp < actor.max_hp * 0.5:
return {"type": "guard"}
if ai == "spider":
actor.ai_counter += 1
if actor.ai_counter % 3 == 0:
return {"type": "eweb", "target": self._rand(self.alive_party())}
magical = (ai == "echo")
return {"type": "attack", "target": self._rand(self.alive_party()),
"magical": magical}
def _gk_action(self, actor):
if self.gk_inhale:
self.gk_inhale = False
return {"type": "boss", "move": "toll"}
alive_imps = [e for e in self.enemies
if e.enemy_id == "chime_imp" and e.alive]
if len(alive_imps) < 2 and random.random() < 0.45:
return {"type": "boss", "move": "summon"}
r = random.random()
if r < 0.35:
self.gk_inhale = True
return {"type": "boss", "move": "inhale"}
return {"type": "boss", "move": "stone_stare"}
def _cust_action(self, actor):
r = random.random()
if r < 0.45:
return {"type": "boss", "move": "sweep"}
if r < 0.75:
return {"type": "boss", "move": "redact"}
if actor.hp < actor.max_hp:
return {"type": "boss", "move": "polish"}
return {"type": "boss", "move": "sweep"}
def _do_boss_move(self, actor, action, result):
move = action["move"]
if move == "stone_stare":
target = self._rand(self.alive_party())
if target:
result["fx"].append({"kind": "lunge", "actor": actor,
"target": target})
self._strike(actor, target, 1.6, False, 0.05, result)
result["log"].append(
"The Gatekeeper's stare files %s under HURT." % target.name)
elif move == "toll":
result["log"].append("THE TOLL. The whole room rings.")
for p in self.alive_party():
self._strike(actor, p, 1.2, False, 0.03, result)
result["fx"].append({"kind": "shake"})
elif move == "inhale":
result["log"].append("The Gatekeeper inhales.")
elif move == "summon":
self._spawn_enemy("chime_imp", result)
result["log"].append("The Gatekeeper summons a Chime-Imp to its side.")
elif move == "sweep":
result["log"].append(
"The Custodian sweeps. Everything is briefly, painfully, neat.")
for p in self.alive_party():
self._strike(actor, p, 1.0, True, 0.03, result)
result["fx"].append({"kind": "shake"})
elif move == "redact":
candidates = [p for p in self.alive_party() if p.redact_turns == 0]
target = self._rand(candidates or self.alive_party())
if target:
target.redact_turns = 2
result["fx"].append({"kind": "debuff", "target": target})
result["log"].append(
"%s is Redacted. 'You are simpler now.'" % target.name)
elif move == "polish":
self._heal(actor, 12, result)
result["log"].append(
"The Custodian polishes itself. Small scratches vanish.")
else:
raise ValueError("unknown boss move %r" % (move,))
# =====================================================================
# boss reactions
# =====================================================================
def _maybe_phase2(self, result):
cust = self._find_enemy("custodian")
if (cust is None or not cust.alive or self.cust_phase2
or cust.hp > cust.max_hp * 0.5):
return
self.cust_phase2 = True
result["log"].append(
"The Custodian opens. Inside: filed silence. Bell is summoned to "
"its side - and a grey echo of Pip.")
self._spawn_enemy("bell", result)
self._spawn_enemy("grey_echo", result)
def _custodian_revert(self, result):
cust = self._find_enemy("custodian")
if (cust is None or not cust.alive or not self.cust_phase2
or self.revert_disabled):
return
if self.cust_damage_this_round <= 0:
return
new_types = self.actions_this_round - self.actions_last_round
if new_types:
result["log"].append(
"The Custodian reaches to smooth the page - and hesitates. "
"It has never seen THAT before.")
else:
healed = self.cust_damage_this_round
cust.hp = cust.hp + healed
result["fx"].append({"kind": "heal", "target": cust, "amount": healed})
result["log"].append(
"The Custodian smooths the page flat. What happened, un-happens.")
# =====================================================================
# boss talk options
# =====================================================================
def boss_talk_options(self):
"""List of {"id","label","enabled","reason"} for the active boss, or []."""
boss = self._active_boss()
if boss is None or not boss.alive:
return []
opts = []
if boss.enemy_id == "gatekeeper":
if "oath_relieve" not in self.talk_used:
en = GS.in_party("BRAMBLE")
opts.append(("oath_relieve",
"[OATH] Relieve the Gatekeeper of its post",
en, "" if en else "needs Bramble"))
elif boss.enemy_id == "custodian":
if "liturgy_name" not in self.talk_used:
en = GS.in_party("MOTH")
opts.append(("liturgy_name",
"[LITURGY] Name the light at it",
en, "" if en else "needs Moth"))
if "resonance_bell" not in self.talk_used:
bell = self._find_enemy("bell")
en = (GS.in_party("CANTOR") and GS.points >= 6
and self.cust_phase2 and bell is not None and bell.alive)
opts.append(("resonance_bell",
"[RESONANCE] Ring Bell backward",
en, "" if en else "needs Cantor, 6 pts, Bell"))
if "book_open" not in self.talk_used:
en = GS.points >= 8
opts.append(("book_open",
"Hold up the Book, open",
en, "" if en else "needs 8 points"))
return [{"id": o[0], "label": o[1], "enabled": o[2], "reason": o[3]}
for o in opts]
def _do_talk(self, actor, action, result):
tid = action["talk"]
self.talk_used.add(tid)
if tid == "oath_relieve":
gk = self._find_enemy("gatekeeper")
if gk:
gk.remove_mods_tagged("ward")
GS.add_flag("gatekeeper_relieved")
GS.add_points(1)
result["log"].append(
"Bramble: By the order that posted you: your watch is "
"COMPLETE. Stand down, soldier.")
result["log"].append(
"The Gatekeeper's Ward crumbles. Its face, for the first "
"time, rests.")
elif tid == "liturgy_name":
cust = self._find_enemy("custodian")
if cust:
cust.add_mod("MAG", mult=-0.30, turns=None, tag="liturgy")
result["log"].append(
"Moth names the light. The Custodian's edges blur, as if "
"embarrassed.")
elif tid == "resonance_bell":
bell = self._find_enemy("bell")
if bell and bell.alive:
bell.hp = 0
result["fx"].append({"kind": "death", "target": bell})
self.revert_disabled = True
GS.add_flag("bell_defected")
GS.add_points(1)
result["log"].append(
"Bell rings BACKWARD. The note un-announces the Custodian. "
"'I have SUCH a backlog,' Bell says, and stands with YOU.")
elif tid == "book_open":
self.cust_skip_next = True
result["log"].append(
"You hold up the Book, open. The Custodian reads. Margins do "
"not usually get to read.")
else:
raise ValueError("unknown talk option %r" % (tid,))
# =====================================================================
# rewards
# =====================================================================
def collect_rewards(self):
xp = 0
gold = 0
drops = []
for e in self.enemies:
if e.alive:
continue
xp += getattr(e, "reward_xp", 0)
gold += getattr(e, "reward_gold", 0)
drop = getattr(e, "drop", None)
if drop and random.random() < drop[1]:
drops.append(drop[0])
return xp, gold, drops
# =============================================================================
# battle screen (presentation)
# =============================================================================
VICTORY_SCENES = {
"uc_mimic": "mimic_post",
"boss_gatekeeper": "gatekeeper_post",
"boss_custodian": "custodian_post",
}
_CMD = ["Attack", "Skill", "Item", "Guard", "Talk", "Swap", "Flee"]
class BattleScreen:
def __init__(self, pack_id, prev_scene, on_victory=None, on_defeat=None):
self.core = BattleCore(pack_id)
self.pack_id = pack_id
self.prev_scene = prev_scene
self.on_victory = on_victory
self.on_defeat = on_defeat
self.barker = Barker()
self.area = getattr(GS, "current_area", "hollowbrook")
if self.area not in BATTLE_BACKDROPS:
self.area = "hollowbrook"
self.timers = []
self.log_lines = []
self.menus = [] # live modal widgets to destroy on teardown
self.state = "boot"
self.cur_actor = None
self.cur_forced = None
self._ended = False
self._auto = None # test seam: policy(screen, actor)->action dict
self.scene = mcrfpy.Scene("battle")
self.ch = self.scene.children
mcrfpy.current_scene = self.scene
self.stack = InputStack(self.scene)
self._build_backdrop()
self._build_enemies()
self._build_party_cards()
self._build_ribbon()
self._build_log_panel()
# kick off
self.core.begin_round()
self._schedule(self._advance, 350)
# ---- timer helper ------------------------------------------------------
def _schedule(self, fn, ms):
name = "bat_%d" % (len(self.timers) + random.randint(0, 1 << 20))
def cb(timer, runtime):
timer.stop()
if self._ended and fn not in (self._teardown,):
return
fn()
t = mcrfpy.Timer(name, cb, ms)
self.timers.append(t)
return t
def _stop_timers(self):
for t in self.timers:
try:
t.stop()
except Exception:
pass
self.timers = []
# =====================================================================
# scene construction
# =====================================================================
def _build_backdrop(self):
bands = BATTLE_BACKDROPS[self.area]
band_h = SCREEN_H / len(bands)
for i, col in enumerate(bands):
f = mcrfpy.Frame(pos=(0, int(i * band_h)),
size=(SCREEN_W, int(band_h) + 1),
fill_color=palette.C(col))
f.z_index = -100
self.ch.append(f)
floor = mcrfpy.Frame(pos=(0, FLOOR_Y), size=(SCREEN_W, 3),
fill_color=palette.C((20, 20, 26)))
floor.z_index = -90
self.ch.append(floor)
def _enemy_base_x(self, n):
return list(_ENEMY_SLOTS.get(n, _ENEMY_SLOTS[3]))
def _build_enemies(self):
n = len(self.core.enemies)
xs = self._enemy_base_x(n)
self.core._used_slots = list(xs)
for i, e in enumerate(self.core.enemies):
cx = xs[i] if i < len(xs) else _SUMMON_SLOTS[i % len(_SUMMON_SLOTS)]
self._make_enemy_view(e, cx)
def _make_enemy_view(self, e, cx):
tex = e.tex if e.tex is not None else assets.TEX
px = int(cx - 8 * e.scale)
top = int(FLOOR_Y - 16 * e.scale)
halos = []
if e.enemy_id == "custodian":
halos = self._custodian_halos(cx, top + int(8 * e.scale))
spr = mcrfpy.Sprite(pos=(px, top), texture=tex,
sprite_index=e.sprite_index)
spr.scale = e.scale
spr.z_index = 10
self.ch.append(spr)
name = ui.Label(self.ch, e.name, (cx, top - 30), color=palette.PARCH,
size=palette.SMALL_SIZE, center=True, outline=1)
bar = ui.Bar(self.ch, (int(cx - 42), top - 16), (84, 10),
palette.BLOOD, e.hp, e.max_hp, show_text=False)
e.view = {"sprite": spr, "name": name, "bar": bar, "cx": cx,
"halos": halos}
def _custodian_halos(self, cx, cy):
out = []
for i, r in enumerate((90, 64)):
h = mcrfpy.Frame(pos=(int(cx - r), int(cy - r)), size=(2 * r, 2 * r),
fill_color=palette.C((0, 0, 0, 0)),
outline=3, outline_color=palette.C(palette.GOLD_T, 150))
h.origin = (r, r)
h.z_index = 5
self.ch.append(h)
h.animate("rotation", 360.0, 9.0 + 3 * i, mcrfpy.Easing.LINEAR,
loop=True)
out.append(h)
return out
def _build_party_cards(self):
self.card_y = [84, 246, 408]
for i in range(3):
self._make_card(i)
self._refresh_cards()
def _make_card(self, i):
if i >= len(self.core.party):
self.core.__dict__.setdefault("_cardslots", None)
y = self.card_y[i]
panel = ui.Panel(self.ch, (724, y), (284, 150))
return panel
def _refresh_cards(self):
# rebuild party card contents from scratch (simple + robust)
if hasattr(self, "_cards"):
for c in self._cards:
c.destroy()
self._cards = []
for i in range(3):
y = self.card_y[i]
outline = palette.GOLD if (i < len(self.core.party) and
self.core.party[i] is self.cur_actor) \
else palette.OUTLINE
panel = ui.Panel(self.ch, (724, y), (284, 150),
outline_color=outline)
self._cards.append(panel)
if i >= len(self.core.party):
ui.Label(panel.children, "(empty)", (142, 70),
color=palette.DIM, size=palette.SMALL_SIZE, center=True)
continue
c = self.core.party[i]
chip = mcrfpy.Frame(pos=(12, 12), size=(56, 56),
fill_color=palette.INSET, outline=2,
outline_color=palette.OUTLINE)
panel.children.append(chip)
tex = assets.TEX
spr = mcrfpy.Sprite(pos=(8, 8), texture=tex,
sprite_index=c.sprite_index)
spr.scale = 2.5
chip.children.append(spr)
ui.Label(panel.children, c.name, (80, 14), color=palette.PARCH,
size=palette.NAME_SIZE)
ui.Label(panel.children, "Lv %d" % (c.cstate.level if c.cstate else 1),
(228, 14), color=palette.GOLD, size=palette.SMALL_SIZE)
hp = ui.Bar(panel.children, (80, 46), (192, 18), palette.BLOOD,
c.hp, c.max_hp)
sp = ui.Bar(panel.children, (80, 70), (192, 16), palette.TEAL,
c.sp, c.max_sp)
status = ""
if not c.alive:
status = "DOWN"
elif c.redact_turns > 0:
status = "Redacted"
elif c.guarding:
status = "Guarding"
ui.Label(panel.children, status, (12, 118), color=palette.DIM,
size=palette.SMALL_SIZE)
c.view = {"panel": panel, "hp": hp, "sp": sp, "spr": spr}
def _build_ribbon(self):
self.ribbon_frame = mcrfpy.Frame(pos=(16, 8), size=(690, 52),
fill_color=palette.C(palette.PANEL_T, 180),
outline=2, outline_color=palette.OUTLINE)
self.ribbon_frame.z_index = 2000
self.ch.append(self.ribbon_frame)
self.ribbon_chips = []
self._refresh_ribbon()
def _refresh_ribbon(self):
for c in self.ribbon_chips:
try:
self.ribbon_frame.children.remove(c)
except Exception:
pass
self.ribbon_chips = []
order = self.core.order if self.core.order else \
sorted([c for c in self.core.all() if c.alive],
key=lambda c: -c.stat("SPD"))
x = 8
tex = assets.TEX
for c in order:
if not c.alive:
continue
hot = (c is self.cur_actor)
chip = mcrfpy.Frame(pos=(x, 6), size=(40, 40),
fill_color=palette.INSET, outline=2,
outline_color=palette.GOLD if hot else palette.OUTLINE)
self.ribbon_frame.children.append(chip)
self.ribbon_chips.append(chip)
t = c.tex if (c.side == "enemy" and c.tex is not None) else tex
spr = mcrfpy.Sprite(pos=(4, 4), texture=t, sprite_index=c.sprite_index)
spr.scale = 2.0
chip.children.append(spr)
x += 46
if x > 660:
break
def _build_log_panel(self):
self.log_panel = ui.Panel(self.ch, (16, 500), (690, 52))
self.log_caps = []
for i in range(3):
cap = ui.Label(self.log_panel.children, "", (12, 6 + i * 15),
color=palette.DIM, size=palette.SMALL_SIZE)
self.log_caps.append(cap)
# ---- log ---------------------------------------------------------------
def push_log(self, line):
if not line:
return
self.log_lines.append(line)
self.log_lines = self.log_lines[-3:]
for i, cap in enumerate(self.log_caps):
cap.text = self.log_lines[i] if i < len(self.log_lines) else ""
# =====================================================================
# turn loop
# =====================================================================
def _advance(self):
if self._ended:
return
na = self.core.next_actor()
if na is None:
end_res = self.core.end_round()
for ln in end_res["log"]:
self.push_log(ln)
self._play_fx(end_res["fx"])
if self.core.check_end():
self._schedule(self._on_battle_end, 500)
return
self.core.begin_round()
self._refresh_ribbon()
self._schedule(self._advance, 300)
return
self.cur_actor, self.cur_forced = na
self._refresh_ribbon()
self._refresh_cards()
if self.cur_forced is not None:
self._resolve(self.cur_actor, self.cur_forced)
elif self.cur_actor.side == "enemy":
action = self.core.ai_action(self.cur_actor)
self._resolve(self.cur_actor, action)
else:
self._open_command_menu(self.cur_actor)
def _resolve(self, actor, action):
result = self.core.resolve(actor, action)
for ln in result["log"]:
self.push_log(ln)
if result["bark"]:
bl = self.barker.line(*result["bark"])
if bl:
self.push_log("%s: %s" % (result["bark"][0].title(), bl))
if result["swap"]:
self._refresh_cards()
self._play_fx(result["fx"])
if result["flee"]:
self.core.result = "flee"
self._schedule(self._on_battle_end, 400)
return
# low-HP barks
for p in self.core.alive_party():
if p.hp <= p.max_hp * 0.30 and not getattr(p, "_barked_low", False):
p._barked_low = True
bl = self.barker.line(p.char_id, "hurt_low")
if bl:
self.push_log("%s: %s" % (p.char_id.title(), bl))
elif p.hp > p.max_hp * 0.30:
p._barked_low = False
if self.core.check_end():
self._schedule(self._on_battle_end, 550)
return
self._schedule(self._advance, 500)
# ---- fx player ---------------------------------------------------------
def _play_fx(self, fx):
for e in fx:
k = e["kind"]
if k == "lunge":
self._lunge(e["actor"], e["target"])
elif k == "hit":
self._show_hit(e["target"], e["amount"], e["crit"])
elif k == "heal":
self._show_heal(e["target"], e["amount"])
elif k == "sp":
self._update_target_bars(e["target"])
elif k == "miss":
self._float_over(e["target"], "miss", palette.DIM)
elif k == "debuff":
self._float_over(e["target"], "-", palette.TEAL)
elif k == "guard":
self._update_target_bars(e["target"])
elif k == "death":
self._death(e["target"])
elif k == "revive":
self._refresh_cards()
elif k == "summon":
self._summon_view(e["target"])
elif k == "shake":
self._screen_shake()
self._update_all_bars()
def _center_of(self, c):
if c.side == "enemy" and c.view:
top = int(FLOOR_Y - 16 * c.scale)
return (c.view["cx"], top + int(8 * c.scale))
if c.side == "party" and c.view:
i = self.core.party.index(c) if c in self.core.party else 0
return (760, self.card_y[i] + 40)
return (300, 300)
def _lunge(self, actor, target):
if actor.side == "enemy" and actor.view:
spr = actor.view["sprite"]
ox = spr.x
spr.animate("x", ox + 24, 0.12, mcrfpy.Easing.EASE_OUT,
callback=lambda *_a: spr.animate("x", ox, 0.12,
mcrfpy.Easing.EASE_IN))
elif actor.side == "party" and actor.view:
spr = actor.view["spr"]
ox = spr.x
spr.animate("x", ox - 24, 0.12, mcrfpy.Easing.EASE_OUT,
callback=lambda *_a: spr.animate("x", ox, 0.12,
mcrfpy.Easing.EASE_IN))
def _show_hit(self, target, amount, crit):
cx, cy = self._center_of(target)
color = palette.PARCH if target.side == "enemy" else palette.BLOOD
tween.float_text(self.ch, str(amount), (cx - 10, cy), color, size=24)
if crit:
tween.float_text(self.ch, "CRIT!", (cx - 20, cy - 26), palette.GOLD,
size=20)
def _show_heal(self, target, amount):
cx, cy = self._center_of(target)
tween.float_text(self.ch, "+%d" % amount, (cx - 8, cy), palette.GRASS,
size=22)
def _float_over(self, target, text, color):
cx, cy = self._center_of(target)
tween.float_text(self.ch, text, (cx, cy), color, size=18)
def _death(self, target):
if target.side == "enemy" and target.view:
spr = target.view["sprite"]
spr.animate("opacity", 0.0, 0.45, mcrfpy.Easing.EASE_IN)
for key in ("name",):
try:
self.ch.remove(target.view[key])
except Exception:
pass
target.view["bar"].destroy()
def rm(*_a):
try:
self.ch.remove(spr)
except Exception:
pass
self._schedule(rm, 480)
else:
self._refresh_cards()
def _summon_view(self, e):
cx = None
used = getattr(self.core, "_used_slots", [])
for s in _SUMMON_SLOTS:
if s not in used:
cx = s
break
if cx is None:
cx = _SUMMON_SLOTS[len(used) % len(_SUMMON_SLOTS)]
used.append(cx)
self.core._used_slots = used
self._make_enemy_view(e, cx)
self._refresh_ribbon()
def _screen_shake(self):
pass # backdrop is full-bleed; shake individual party cards instead
for c in self.core.alive_party():
if c.view:
tween.shake(c.view["panel"].frame, mag=6, dur=0.25)
def _update_target_bars(self, target):
self._update_all_bars()
def _update_all_bars(self):
for e in self.core.enemies:
if e.view and e.alive:
e.view["bar"].set(e.hp, e.max_hp)
for c in self.core.party:
if c.view:
c.view["hp"].set(c.hp, c.max_hp)
c.view["sp"].set(c.sp, c.max_sp)
# =====================================================================
# command menu + submenus
# =====================================================================
def _clear_menus(self):
for m in self.menus:
try:
m.destroy()
except Exception:
pass
self.menus = []
for nm in ("cmd", "sub", "target"):
self.stack.pop(nm)
if hasattr(self, "_picker") and self._picker:
self._picker.destroy()
self._picker = None
def set_autopilot(self, policy):
"""Test seam: install policy(screen, actor)->action-dict. On each player
turn the command is taken from the policy and driven through the REAL
_commit/_resolve path instead of opening the on-screen command menu.
Returning None falls through to the interactive menu."""
self._auto = policy
def _open_command_menu(self, actor):
if self._auto is not None:
action = self._auto(self, actor)
if action is not None:
self._commit(actor, action)
return
self._clear_menus()
self.state = "command"
has_skills = bool(chardata.learned_skills(actor.char_id,
actor.cstate.level)) and actor.redact_turns == 0
has_items = any(itemdata.is_consumable(i) and n > 0
for i, n in GS.inventory.items())
talk_opts = self.core.boss_talk_options() if self.core.is_boss else []
bench = self._bench()
items = [
("Attack", "attack", True, ""),
("Skill", "skill", has_skills,
"redacted" if actor.redact_turns > 0 else "no SP/skills"),
("Item", "item", has_items, "none"),
("Guard", "guard", True, ""),
]
if talk_opts:
items.append(("Talk", "talk", True, ""))
if bench:
items.append(("Swap", "swap", True, ""))
items.append(("Flee", "flee", not self.core.is_boss, "no escape"))
menu = ui.MenuList(self.ch, (16, 560), 240, items,
on_pick=lambda v: self._cmd_pick(actor, v),
on_cancel=None, title=actor.name)
self.menus.append(menu)
self.stack.push(menu.handle, "cmd")
def _bench(self):
in_battle = {c.char_id for c in self.core.party}
return [cid for cid in GS.roster
if cid not in in_battle and GS.roster[cid].alive]
def _cmd_pick(self, actor, v):
if v == "attack":
self._pick_target(actor, self.core.alive_enemies(),
lambda tgt: self._commit(actor,
{"type": "attack", "target": tgt}))
elif v == "guard":
self._commit(actor, {"type": "guard"})
elif v == "flee":
self._commit(actor, {"type": "flee"})
elif v == "skill":
self._open_skill_menu(actor)
elif v == "item":
self._open_item_menu(actor)
elif v == "talk":
self._open_talk_menu(actor)
elif v == "swap":
self._open_swap_menu(actor)
def _open_skill_menu(self, actor):
self.stack.pop("cmd")
skills = chardata.learned_skills(actor.char_id, actor.cstate.level)
rows = []
for s in skills:
afford = actor.sp >= s["sp"]
label = "%s (%d SP)" % (s["name"], s["sp"])
rows.append((label, s, afford, "low SP"))
menu = ui.MenuList(self.ch, (16, 470), 300, rows,
on_pick=lambda s: self._skill_chosen(actor, s),
on_cancel=lambda: self._back_to_command(actor),
title="Skill")
self.menus.append(menu)
self.stack.push(menu.handle, "sub")
def _skill_chosen(self, actor, skill):
tgt = skill["target"]
if tgt == "enemy":
self._pick_target(actor, self.core.alive_enemies(),
lambda t: self._commit(actor,
{"type": "skill", "skill": skill, "target": t}))
elif tgt == "ally":
self._pick_target(actor, self.core.alive_party(),
lambda t: self._commit(actor,
{"type": "skill", "skill": skill, "target": t}))
else: # all_enemies / party / self
self._commit(actor, {"type": "skill", "skill": skill, "target": None})
def _open_item_menu(self, actor):
self.stack.pop("cmd")
rows = []
for iid, n in GS.inventory.items():
if not itemdata.is_consumable(iid) or n <= 0:
continue
rec = itemdata.item(iid)
rows.append(("%s x%d" % (rec["name"], n), iid, True, ""))
if not rows:
rows = [("(no items)", None, False, "")]
menu = ui.MenuList(self.ch, (16, 470), 280, rows,
on_pick=lambda iid: self._item_chosen(actor, iid),
on_cancel=lambda: self._back_to_command(actor),
title="Item")
self.menus.append(menu)
self.stack.push(menu.handle, "sub")
def _item_chosen(self, actor, iid):
if iid is None:
return
rec = itemdata.item(iid)
if rec["effect"] == "aoe_damage":
self._commit(actor, {"type": "item", "item": iid, "target": None})
else:
self._pick_target(actor, self.core.party, # allow reviving the downed
lambda t: self._commit(actor,
{"type": "item", "item": iid, "target": t}))
def _open_talk_menu(self, actor):
self.stack.pop("cmd")
opts = self.core.boss_talk_options()
rows = [(o["label"], o["id"], o["enabled"], o["reason"]) for o in opts]
menu = ui.MenuList(self.ch, (16, 430), 470, rows,
on_pick=lambda tid: self._commit(actor,
{"type": "talk", "talk": tid}),
on_cancel=lambda: self._back_to_command(actor),
title="Talk")
self.menus.append(menu)
self.stack.push(menu.handle, "sub")
def _open_swap_menu(self, actor):
self.stack.pop("cmd")
bench = self._bench()
rows = [(cid.title(), cid, True, "") for cid in bench]
menu = ui.MenuList(self.ch, (16, 470), 240, rows,
on_pick=lambda cid: self._commit(actor,
{"type": "swap", "swap_to": cid}),
on_cancel=lambda: self._back_to_command(actor),
title="Swap in")
self.menus.append(menu)
self.stack.push(menu.handle, "sub")
def _back_to_command(self, actor):
self._clear_menus()
self._open_command_menu(actor)
# ---- target picker -----------------------------------------------------
def _pick_target(self, actor, targets, on_done):
targets = [t for t in targets if t.alive] or list(targets)
if not targets:
return
self.stack.pop("sub")
self.stack.pop("cmd")
self._picker = _TargetPicker(self, targets, on_done,
lambda: self._back_to_command(actor))
self.stack.push(self._picker.handle, "target")
def _commit(self, actor, action):
self._clear_menus()
self.state = "resolving"
self._resolve(actor, action)
# =====================================================================
# end of battle
# =====================================================================
def _on_battle_end(self):
self._clear_menus()
res = self.core.result
if res == "victory":
self._victory()
elif res == "flee":
self._teardown_and_restore()
else:
self._defeat()
def _victory(self):
xp, gold, drops = self.core.collect_rewards()
events = GS.xp_gain(xp)
GS.add_gold(gold)
for d in drops:
GS.grant(d, 1)
# panel
self._clear_menus()
panel = ui.Panel(self.ch, (312, 220), (400, 300),
outline_color=palette.GOLD, z_index=7000)
self.menus.append(panel)
ui.Label(panel.children, "VICTORY", (200, 30), color=palette.GOLD,
size=palette.BANNER_SIZE, center=True)
y = 90
ui.Label(panel.children, "XP +%d" % xp, (40, y), color=palette.GRASS,
size=palette.BODY_SIZE)
ui.Label(panel.children, "Cogs +%d" % gold, (40, y + 26),
color=palette.GOLD, size=palette.BODY_SIZE)
yy = y + 56
for d in drops:
ui.Label(panel.children, "Found %s" % itemdata.item(d)["name"],
(40, yy), color=palette.PARCH, size=palette.SMALL_SIZE)
yy += 22
for cid, lvl in events:
ui.Toast(self.ch, "%s reached Lv %d" % (cid.title(), lvl),
color=palette.GRASS)
if lvl == 4:
ui.Toast(self.ch, "%s: Skill learned!" % cid.title(),
color=palette.GOLD)
ui.Label(panel.children, "press ENTER", (200, 262), color=palette.DIM,
size=palette.SMALL_SIZE, center=True)
self.stack.clear()
self.stack.push(self._victory_key, "victory")
def _victory_key(self, key, state):
if state != mcrfpy.InputState.PRESSED:
return True
if key in (mcrfpy.Key.ENTER, mcrfpy.Key.SPACE):
self._finish_victory()
return True
def _finish_victory(self):
self._teardown()
if self.prev_scene is not None:
mcrfpy.current_scene = self.prev_scene
if self.pack_id in VICTORY_SCENES:
self._run_return_scene(VICTORY_SCENES[self.pack_id])
elif self.on_victory:
self.on_victory()
def _run_return_scene(self, scene_id):
"""Run a post-battle dialogue on the restored scene. If we returned to
the live overworld, reuse ITS input stack - creating a fresh InputStack
would reassign scene.on_key and orphan overworld movement - and keep the
overworld frozen for the scene's duration."""
from systems import dialogue, overworld
w = overworld.WORLD
if w is not None and self.prev_scene is w.scene:
w.frozen = True
w.held = []
def _after():
if overworld.WORLD is w:
w.frozen = False
w.refresh_theme()
w.refresh_hud()
dialogue.run_scene(scene_id, on_done=_after, stack=w.stack)
else:
dialogue.run_scene(scene_id)
def _defeat(self):
self._clear_menus()
cover = mcrfpy.Frame(pos=(0, 0), size=(SCREEN_W, SCREEN_H),
fill_color=palette.C(palette.INK_T, 0))
cover.z_index = 8000
self.ch.append(cover)
cover.animate("opacity", 1.0, 0.6, mcrfpy.Easing.EASE_IN_OUT)
panel = ui.Panel(self.ch, (262, 300), (500, 160),
outline_color=palette.BLOOD, z_index=8100)
self.menus.append(panel)
ui.Label(panel.children, "The vale goes back to waiting.", (250, 60),
color=palette.PARCH, size=palette.BODY_SIZE, center=True)
ui.Label(panel.children, "press ENTER", (250, 120), color=palette.DIM,
size=palette.SMALL_SIZE, center=True)
self.stack.clear()
self.stack.push(self._defeat_key, "defeat")
def _defeat_key(self, key, state):
if state != mcrfpy.InputState.PRESSED:
return True
if key in (mcrfpy.Key.ENTER, mcrfpy.Key.SPACE):
self._teardown()
if self.on_defeat:
self.on_defeat()
else:
import main
main.build_title()
return True
def _teardown_and_restore(self):
self._teardown()
if self.prev_scene is not None:
mcrfpy.current_scene = self.prev_scene
def _teardown(self):
self._ended = True
self._stop_timers()
self._clear_menus()
self.stack.clear()
# =============================================================================
# target picker (modal reticle over a candidate target)
# =============================================================================
class _TargetPicker:
"""A modal input handler pushed on the battle InputStack while the player
chooses a target. Left/Right (or W/S/A/D) cycle candidates, Enter/Space
confirm (on_done(target)), Esc cancels (on_cancel()). Draws a gold reticle
over the current candidate. This was referenced by BattleScreen._pick_target
but never defined - implemented here (Agent D integration)."""
def __init__(self, screen, targets, on_done, on_cancel):
self.screen = screen
self.targets = [t for t in targets if t.alive] or list(targets)
self.on_done = on_done
self.on_cancel = on_cancel
self.idx = 0
self.reticle = mcrfpy.Frame(pos=(0, 0), size=(10, 10),
fill_color=palette.C((0, 0, 0, 0)),
outline=3, outline_color=palette.GOLD)
self.reticle.z_index = 6800
screen.ch.append(self.reticle)
self._place()
def _place(self):
if not self.targets:
return
t = self.targets[self.idx]
cx, cy = self.screen._center_of(t)
if t.side == "enemy":
half = int(10 * getattr(t, "scale", 4.0))
else:
half = 90
self.reticle.w = 2 * half
self.reticle.h = 2 * half
self.reticle.x = int(cx - half)
self.reticle.y = int(cy - half)
def _cycle(self, delta):
alive = [t for t in self.targets if t.alive]
if not alive:
return
# keep idx pointing at a live target, then step
cur = self.targets[self.idx] if self.idx < len(self.targets) else None
if cur not in alive:
self.targets = alive
self.idx = 0
else:
self.targets = alive
self.idx = self.targets.index(cur)
self.idx = (self.idx + delta) % len(self.targets)
self._place()
def handle(self, key, state):
if state != mcrfpy.InputState.PRESSED:
return True
K = mcrfpy.Key
if key in (K.A, K.LEFT, K.W, K.UP):
self._cycle(-1)
return True
if key in (K.D, K.RIGHT, K.S, K.DOWN):
self._cycle(1)
return True
if key in (K.ENTER, K.SPACE):
alive = [t for t in self.targets if t.alive]
if alive:
tgt = self.targets[self.idx] if self.targets[self.idx].alive \
else alive[0]
self.on_done(tgt)
return True
if key == K.ESCAPE:
if self.on_cancel:
self.on_cancel()
return True
return True
def destroy(self):
try:
self.screen.ch.remove(self.reticle)
except Exception:
pass
# =============================================================================
# public entry + hook registration
# =============================================================================
# The most recently created BattleScreen (test seam: a scripted playthrough
# needs a handle on a battle that a dialogue action started internally, since
# the dialogue runner discards start_battle's return value).
_LAST_SCREEN = None
# Test seam: if set, every battle is created with this autopilot already
# installed, so a battle a *dialogue* action starts internally is auto-driven
# from turn one (there is no chance to call set_autopilot before its first
# player turn opens the command menu). None in normal play.
DEFAULT_AUTOPILOT = None
def start_battle(pack_id, on_victory=None, on_defeat=None):
"""Build a battle scene for `pack_id`, run it, restore the previous scene
on completion. Returns the BattleScreen (mostly useful for tests)."""
global _LAST_SCREEN
prev = mcrfpy.current_scene
_LAST_SCREEN = BattleScreen(pack_id, prev, on_victory, on_defeat)
if DEFAULT_AUTOPILOT is not None:
_LAST_SCREEN.set_autopilot(DEFAULT_AUTOPILOT)
return _LAST_SCREEN
# register the dialogue hook (dialogue calls HOOKS["battle"](pack_id))
from systems import dialogue as _dialogue
_dialogue.HOOKS["battle"] = start_battle