McRogueFace/games/unwritten/systems/party_menu.py
John McCardle 80908f771c UNWRITTEN: complete short-form RPG at games/unwritten/
A 30-45 minute turn-based RPG built overnight as an engine showcase:
six recruitable characters (party of three), four recurring NPCs,
choice-driven dialogue with visible-but-locked skill checks, shops,
loot, levels, two bosses with in-battle Talk options, the reverted
grey-town beat, and an epilogue assembled from the player's actual
choices. 33 dialogue scenes / 171 nodes. Terrain is ColorLayers only;
kenney_tinydungeon supplies characters, items, and props.

Creative direction, story, dialogue, maps, and systems design by
Fable; implementation by four Opus subagents (framework, battle,
overworld, integration). Verified by tests/playthrough.py: a scripted
headless run of all three acts, 20/20 beats passing, plus per-system
unit tests (script integrity, UI kit, 225-battle balance sim,
overworld).

Run: cd build && ./mcrogueface --exec ../games/unwritten/main.py

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vxWJ6GY384SmZiMVXgrF4
2026-07-03 10:11:12 -04:00

456 lines
16 KiB
Python

"""UNWRITTEN - pause / party menu. Owner: Agent C.
Opened by Esc/Tab in the overworld, or directly to the swap view via
HOOKS["swap_menu"] (which returns to whatever ran it - dialogue may continue).
Tabs: PARTY (swap active-3 ordering), EQUIP (weapon/trinket from inventory),
ITEMS (use consumables outside battle), STATS (detail), KEY (key items;
blank_book always first). Footer shows gold and Story Points ("The Book: N
entries"). NYX cannot be item-healed: the canon line "The tonic passes through
her." shows as a Toast. Esc closes (or backs out of a picker).
data.items is Agent B's (parallel); imported LAZILY. Expected interface:
data.items.ITEMS[id] = {"name","sprite","cost","kind"
("consumable"|"weapon"|"trinket"|"key"), "heal"(int), "sp"(int)}
data.items.EQUIP[id] = {stat: delta} # weapons + trinkets
"""
import mcrfpy
from core import palette
from core import assets
from core import ui
from core.inputstack import InputStack
from systems.state import GS, CHAR_TAGS
from systems import dialogue
_MENU = None
TABS = ["PARTY", "EQUIP", "ITEMS", "STATS", "KEY"]
KEY_ITEM_NAMES = {
"blank_book": "The Blank Book - heavier every day.",
"brass_key": "Brass Key",
"gatekeepers_tooth": "Gatekeeper's Tooth",
"the_wick": "The Wick",
}
def _items_tbl():
from data import items
tbl = getattr(items, "ITEMS", None)
if tbl is None:
raise RuntimeError("party menu needs data.items.ITEMS (see docstring)")
return tbl
def _item_name(iid):
try:
return _items_tbl()[iid]["name"]
except Exception:
return iid
def _consumable_desc(d):
"""One-line effect summary for a consumable record (data.items schema uses
effect/amount, not heal/sp keys)."""
effect = d.get("effect")
amount = d.get("amount", 0)
if effect in ("heal", "cleanse_heal"):
base = "heal %d" % int(amount)
return base + " + cleanse" if effect == "cleanse_heal" else base
if effect == "sp":
return "+%d SP" % int(amount)
if effect == "aoe_damage":
return "%d AoE (battle)" % int(amount)
if effect == "revive":
return "revive (battle)"
return ""
class _PartyMenu:
def __init__(self, scene, stack, on_close, view="PARTY"):
self.scene = scene
self.on_close = on_close
self.owns_stack = stack is None
self.stack = stack if stack is not None else InputStack(scene)
self.ch = scene.children
self.tab = TABS.index(view) if view in TABS else 0
self.sel = 0
self.char_idx = 0
self.swap_pick = None # cid selected for swap on the PARTY tab
self.picker = None # active secondary MenuList
self.nodes = [] # rebuildable drawables
self.panel = ui.Panel(self.ch, (40, 40), (944, 688),
outline_color=palette.GOLD, z_index=4000)
self._render()
self.stack.push(self._handle, "party")
# ----------------------------------------------------------------- render
def _clear(self):
for n in self.nodes:
try:
self.panel.children.remove(n)
except Exception:
pass
self.nodes = []
if self.picker is not None:
self.picker.destroy()
self.picker = None
def _lab(self, text, pos, color=None, size=palette.BODY_SIZE, center=False):
c = ui.Label(self.panel.children, text, pos, color=color, size=size,
center=center)
self.nodes.append(c)
return c
def _render(self):
self._clear()
# tab bar
for i, name in enumerate(TABS):
x = 40 + i * 150
self._lab(name, (x, 24),
color=palette.GOLD if i == self.tab else palette.DIM,
size=palette.NAME_SIZE)
# divider
div = mcrfpy.Frame(pos=(24, 56), size=(896, 2), fill_color=palette.OUTLINE)
self.panel.children.append(div)
self.nodes.append(div)
# footer
self._lab("%d cogs" % GS.gold, (32, 646), color=palette.GOLD,
size=palette.BODY_SIZE)
self._lab("The Book: %d entries" % GS.points, (944 - 32 - 220, 646),
color=palette.GOLD, size=palette.BODY_SIZE)
self._lab("Q/E tabs arrows move Enter select Esc close",
(472, 668), color=palette.DIM, size=palette.SMALL_SIZE,
center=True)
tab = TABS[self.tab]
if tab == "PARTY":
self._render_party()
elif tab == "EQUIP":
self._render_char_list(self._on_equip_char)
self._render_char_detail()
elif tab == "ITEMS":
self._render_items()
elif tab == "STATS":
self._render_char_list(None)
self._render_char_detail(full=True)
elif tab == "KEY":
self._render_key()
def _roster_ids(self):
# active party first (in order), then benched
active = [c for c in GS.party if c in GS.roster]
benched = [c for c in GS.roster if c not in active]
return active + benched
def _render_party(self):
self._lab("active party is marked *. Enter picks, Enter again swaps.",
(32, 70), color=palette.DIM, size=palette.SMALL_SIZE)
ids = self._roster_ids()
y = 100
for i, cid in enumerate(ids):
cs = GS.roster[cid]
mark = "*" if cid in GS.party else " "
sel = ">" if i == self.sel else " "
pick = " (picked)" if cid == self.swap_pick else ""
color = palette.GOLD if i == self.sel else palette.PARCH
row = self._lab("%s %s %-9s Lv%d%s" % (sel, mark, cid, cs.level, pick),
(40, y), color=color, size=palette.BODY_SIZE)
# portrait chip
chip = mcrfpy.Frame(pos=(300, y - 4), size=(30, 30),
fill_color=palette.INSET, outline=1,
outline_color=palette.OUTLINE)
self.panel.children.append(chip)
self.nodes.append(chip)
spr = assets.PORTRAITS.get(cid, 0) or 0
s = mcrfpy.Sprite(pos=(1, 1), texture=assets.TEX, sprite_index=spr)
s.scale = 1.75
chip.children.append(s)
hb = ui.Bar(self.panel.children, (350, y - 2), (150, 14),
palette.BLOOD, cur=cs.hp, maxv=cs.max_hp)
self.nodes.append(hb.bg)
sb = ui.Bar(self.panel.children, (520, y - 2), (120, 14),
palette.TEAL, cur=cs.sp, maxv=cs.max_sp)
self.nodes.append(sb.bg)
tag = CHAR_TAGS.get(cid, "")
self._lab(tag, (660, y), color=palette.DIM, size=palette.SMALL_SIZE)
y += 40
def _render_char_list(self, on_pick):
ids = self._roster_ids()
if self.char_idx >= len(ids):
self.char_idx = 0
y = 90
for i, cid in enumerate(ids):
cs = GS.roster[cid]
sel = ">" if i == self.char_idx else " "
color = palette.GOLD if i == self.char_idx else palette.PARCH
self._lab("%s %-9s Lv%d" % (sel, cid, cs.level), (40, y),
color=color, size=palette.BODY_SIZE)
y += 34
def _render_char_detail(self, full=False):
ids = self._roster_ids()
if not ids:
return
cid = ids[min(self.char_idx, len(ids) - 1)]
cs = GS.roster[cid]
x = 400
self._lab(cid, (x, 90), color=palette.GOLD, size=palette.NAME_SIZE)
try:
st = cs.stats()
except Exception:
st = {}
rows = [("Level", cs.level), ("HP", "%d/%d" % (cs.hp, cs.max_hp)),
("SP", "%d/%d" % (cs.sp, cs.max_sp))]
for k in ("ATK", "MAG", "DEF", "SPD"):
rows.append((k, st.get(k, "-")))
y = 126
for k, v in rows:
self._lab("%-6s %s" % (k, v), (x, y), color=palette.PARCH,
size=palette.BODY_SIZE)
y += 28
self._lab("Weapon: %s" % (_item_name(cs.weapon) if cs.weapon else "-"),
(x, y + 8), color=palette.DIM, size=palette.SMALL_SIZE)
self._lab("Trinket: %s" % (_item_name(cs.trinket) if cs.trinket else "-"),
(x, y + 30), color=palette.DIM, size=palette.SMALL_SIZE)
if TABS[self.tab] == "EQUIP":
self._lab("Enter: change equipment", (x, y + 64),
color=palette.GOLD, size=palette.SMALL_SIZE)
def _render_items(self):
tbl = _items_tbl()
rows = []
for iid, n in sorted(GS.inventory.items()):
d = tbl.get(iid)
if not d or d.get("kind") != "consumable":
continue
rows.append((iid, n, d))
self._lab("use a consumable, then choose who receives it.", (32, 70),
color=palette.DIM, size=palette.SMALL_SIZE)
y = 100
if not rows:
self._lab("(no consumables)", (40, y), color=palette.DIM)
return
for i, (iid, n, d) in enumerate(rows):
sel = ">" if i == self.sel else " "
color = palette.GOLD if i == self.sel else palette.PARCH
eff = _consumable_desc(d)
self._lab("%s %-14s x%d %s" % (sel, d["name"], n, eff),
(40, y), color=color, size=palette.BODY_SIZE)
y += 32
self._item_rows = rows
def _render_key(self):
self._lab("key items", (32, 70), color=palette.DIM,
size=palette.SMALL_SIZE)
keys = list(GS.key_items)
ordered = (["blank_book"] if "blank_book" in keys else [])
ordered += [k for k in keys if k != "blank_book"]
if "blank_book" not in keys:
ordered = ["blank_book"] + ordered # always listed first
y = 100
for k in ordered:
name = KEY_ITEM_NAMES.get(k, k)
self._lab("- %s" % name, (40, y), color=palette.PARCH,
size=palette.BODY_SIZE)
y += 30
# ----------------------------------------------------------------- pickers
def _on_equip_char(self):
ids = self._roster_ids()
cid = ids[self.char_idx]
tbl = _items_tbl()
opts = []
for slot in ("weapon", "trinket"):
for iid, n in sorted(GS.inventory.items()):
d = tbl.get(iid)
if d and d.get("kind") == slot:
opts.append(("%s: %s" % (slot, d["name"]), (slot, iid),
True, ""))
opts.append(("(remove weapon)", ("weapon", None), True, ""))
opts.append(("(remove trinket)", ("trinket", None), True, ""))
if not opts:
return
self.picker = ui.MenuList(self.panel.children, (400, 300), 320, opts,
on_pick=lambda v: self._do_equip(cid, v),
on_cancel=self._close_picker,
title="Equip %s" % cid)
def _do_equip(self, cid, val):
slot, iid = val
cs = GS.roster[cid]
if slot == "weapon":
cs.weapon = iid
else:
cs.trinket = iid
self._close_picker()
self._render()
def _open_item_target(self, iid, d):
opts = []
for cid in self._roster_ids():
opts.append((cid, cid, True, ""))
self.picker = ui.MenuList(self.panel.children, (400, 260), 300, opts,
on_pick=lambda v: self._use_item(iid, d, v),
on_cancel=self._close_picker,
title="Use %s on" % d["name"])
def _use_item(self, iid, d, cid):
cs = GS.roster[cid]
effect = d.get("effect")
amount = d.get("amount", 0)
heals = effect in ("heal", "cleanse_heal")
restores_sp = effect == "sp"
# NYX cannot be restored by items (SYSTEMS section 3); canon line.
if cid == "NYX" and (heals or restores_sp):
ui.Toast(self.ch, "The tonic passes through her.", color=palette.DIM)
self._close_picker()
return
if GS.inventory.get(iid, 0) <= 0:
self._close_picker()
return
if heals:
cs.hp = cs.hp + int(amount)
elif restores_sp:
cs.sp = cs.sp + int(amount)
else:
# revive / aoe_damage are battle-only; not usable from the menu
ui.Toast(self.ch, "Cannot use %s here." % d["name"], color=palette.DIM)
self._close_picker()
return
GS.take(iid, 1)
ui.Toast(self.ch, "Used %s" % d["name"], color=palette.GRASS)
self._close_picker()
self._render()
def _close_picker(self):
if self.picker is not None:
self.picker.destroy()
self.picker = None
# ----------------------------------------------------------------- swap
def _party_pick(self):
ids = self._roster_ids()
cid = ids[self.sel]
if self.swap_pick is None:
self.swap_pick = cid
else:
self._do_swap(self.swap_pick, cid)
self.swap_pick = None
self._render()
def _do_swap(self, a, b):
if a == b:
return
pa = GS.party.index(a) if a in GS.party else None
pb = GS.party.index(b) if b in GS.party else None
if pa is not None and pb is not None:
GS.party[pa], GS.party[pb] = GS.party[pb], GS.party[pa]
elif pa is not None and pb is None:
GS.party[pa] = b
elif pa is None and pb is not None:
GS.party[pb] = a
# else both benched: no-op
# ----------------------------------------------------------------- input
def _list_len(self):
tab = TABS[self.tab]
if tab in ("PARTY",):
return len(self._roster_ids())
if tab in ("EQUIP", "STATS"):
return len(self._roster_ids())
if tab == "ITEMS":
return len(getattr(self, "_item_rows", []))
return 0
def _handle(self, key, state):
if state != mcrfpy.InputState.PRESSED:
return True
K = mcrfpy.Key
if self.picker is not None:
if key == K.ESCAPE:
self._close_picker()
return True
self.picker.handle(key, state)
return True
if key == K.ESCAPE:
self._close()
return True
if key in (K.Q, K.LEFT):
self.tab = (self.tab - 1) % len(TABS)
self.sel = 0
self._render()
return True
if key in (K.E, K.RIGHT):
self.tab = (self.tab + 1) % len(TABS)
self.sel = 0
self._render()
return True
n = self._list_len()
tab = TABS[self.tab]
cur = self.char_idx if tab in ("EQUIP", "STATS") else self.sel
if key in (K.W, K.UP) and n:
cur = (cur - 1) % n
elif key in (K.S, K.DOWN) and n:
cur = (cur + 1) % n
elif key in (K.ENTER, K.SPACE):
self._activate()
return True
else:
return True
if tab in ("EQUIP", "STATS"):
self.char_idx = cur
else:
self.sel = cur
self._render()
return True
def _activate(self):
tab = TABS[self.tab]
if tab == "PARTY":
self._party_pick()
elif tab == "EQUIP":
self._on_equip_char()
elif tab == "ITEMS":
rows = getattr(self, "_item_rows", [])
if rows:
iid, n, d = rows[self.sel]
self._open_item_target(iid, d)
def _close(self):
self.destroy()
if self.on_close:
self.on_close()
def destroy(self):
self._close_picker()
self.stack.pop("party")
self.panel.destroy()
def open_menu(scene=None, stack=None, on_close=None, view="PARTY"):
global _MENU
scene = scene if scene is not None else mcrfpy.current_scene
_MENU = _PartyMenu(scene, stack, on_close, view=view)
return _MENU
def _swap_hook():
"""HOOKS['swap_menu']: open directly to the swap (PARTY) view on the active
overworld stack, so dialogue continuing beneath resumes when it closes."""
stack = None
try:
from systems import overworld
if overworld.WORLD is not None:
stack = overworld.WORLD.stack
except Exception:
stack = None
open_menu(mcrfpy.current_scene, stack, on_close=None, view="PARTY")
def register_hooks():
dialogue.HOOKS["swap_menu"] = _swap_hook
register_hooks()