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
230 lines
7.8 KiB
Python
230 lines
7.8 KiB
Python
"""UNWRITTEN - Griselda's shop. Owner: Agent C.
|
|
|
|
HOOKS["shop"]() opens a two-column buy/sell UI over the current scene.
|
|
Stock per act, x1.25 prices under griselda_grudge, buyback at half, Chalk limited
|
|
to ONE ever (flag chalk_bought). Gold is called cogs. Esc leaves.
|
|
|
|
data.items is Agent B's (parallel); imported LAZILY. Expected interface:
|
|
data.items.ITEMS[item_id] = {
|
|
"name": str, "sprite": int, "cost": int,
|
|
"kind": "consumable"|"weapon"|"trinket"|"key",
|
|
"shop": bool, # sold by Griselda
|
|
"act": int, # earliest act it appears in stock (default 1)
|
|
}
|
|
"""
|
|
import math
|
|
|
|
import mcrfpy
|
|
|
|
from core import palette
|
|
from core import ui
|
|
from core.inputstack import InputStack
|
|
from systems import dialogue
|
|
from systems.state import GS
|
|
|
|
_MENU = None
|
|
|
|
|
|
def _items():
|
|
from data import items
|
|
tbl = getattr(items, "ITEMS", None)
|
|
if tbl is None:
|
|
raise RuntimeError("shop needs data.items.ITEMS (see module docstring)")
|
|
return tbl
|
|
|
|
|
|
def _price(item_id):
|
|
base = int(_items()[item_id]["cost"])
|
|
if GS.has("griselda_grudge"):
|
|
return int(math.ceil(base * 1.25))
|
|
return base
|
|
|
|
|
|
def _sell_price(item_id):
|
|
return int(_items()[item_id]["cost"]) // 2
|
|
|
|
|
|
class _Shop:
|
|
def __init__(self, scene, stack, on_close):
|
|
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.mode = "buy" # "buy" | "sell"
|
|
self.buy_menu = None
|
|
self.sell_menu = None
|
|
self.panel = None
|
|
self.gold_cap = None
|
|
self._build()
|
|
self.stack.push(self._handle, "shop")
|
|
|
|
# ----------------------------------------------------------------- build
|
|
def _build(self):
|
|
self.panel = ui.Panel(self.ch, (72, 60), (880, 640),
|
|
outline_color=palette.GOLD, z_index=4000)
|
|
p = self.panel.children
|
|
ui.Label(p, "GRISELDA'S", (32, 22), color=palette.GOLD,
|
|
size=palette.NAME_SIZE)
|
|
ui.Label(p, "forty years of sharpening. buy something.", (32, 48),
|
|
color=palette.DIM, size=palette.SMALL_SIZE)
|
|
self.gold_cap = ui.Label(p, "", (880 - 32, 22), color=palette.GOLD,
|
|
size=palette.NAME_SIZE)
|
|
self._refresh_gold()
|
|
ui.Label(p, "BUY", (170, 84), color=palette.PARCH, size=palette.BODY_SIZE,
|
|
center=True)
|
|
ui.Label(p, "SELL", (620, 84), color=palette.PARCH, size=palette.BODY_SIZE,
|
|
center=True)
|
|
ui.Label(p, "Tab: switch column Enter: confirm Esc: leave",
|
|
(440, 612), color=palette.DIM, size=palette.SMALL_SIZE, center=True)
|
|
self._rebuild_lists()
|
|
|
|
def _refresh_gold(self):
|
|
self.gold_cap.text = "%d cogs" % GS.gold
|
|
self.gold_cap.x = 880 - 32 - self.gold_cap.size.x
|
|
|
|
def _stock_ids(self):
|
|
# data.items exposes stock per act via SHOP_STOCK (act -> [ids]); the
|
|
# older per-item "shop"/"act" keys this shop was first coded against
|
|
# never existed. Use the real table (Agent D integration).
|
|
from data import items
|
|
stock = getattr(items, "SHOP_STOCK", None)
|
|
if stock is None:
|
|
raise RuntimeError("shop needs data.items.SHOP_STOCK (act -> [ids])")
|
|
tbl = _items()
|
|
act = GS.act if GS.act in stock else max(stock)
|
|
return [iid for iid in stock[act] if iid in tbl]
|
|
|
|
def _rebuild_lists(self):
|
|
p = self.panel.children
|
|
if self.buy_menu is not None:
|
|
self.buy_menu.destroy()
|
|
if self.sell_menu is not None:
|
|
self.sell_menu.destroy()
|
|
|
|
buy_items = []
|
|
for iid in self._stock_ids():
|
|
d = _items()[iid]
|
|
enabled, reason = True, ""
|
|
price = _price(iid)
|
|
if iid == "chalk" and GS.has("chalk_bought"):
|
|
enabled, reason = False, "sold out"
|
|
elif GS.gold < price:
|
|
enabled, reason = False, "need %d" % price
|
|
label = "%-14s %d" % (d["name"], price)
|
|
buy_items.append((label, iid, enabled, reason))
|
|
if not buy_items:
|
|
buy_items = [("(nothing in stock)", None, False, "")]
|
|
self.buy_menu = ui.MenuList(p, (40, 104), 360, buy_items,
|
|
on_pick=self._buy, on_cancel=self._leave)
|
|
|
|
sell_items = []
|
|
for iid, n in sorted(GS.inventory.items()):
|
|
if iid not in _items():
|
|
continue
|
|
d = _items()[iid]
|
|
if d.get("kind") == "key":
|
|
continue
|
|
label = "%-12s x%d %d" % (d["name"], n, _sell_price(iid))
|
|
sell_items.append((label, iid, True, ""))
|
|
if not sell_items:
|
|
sell_items = [("(nothing to sell)", None, False, "")]
|
|
self.sell_menu = ui.MenuList(p, (490, 104), 360, sell_items,
|
|
on_pick=self._sell, on_cancel=self._leave)
|
|
self._highlight()
|
|
|
|
def _highlight(self):
|
|
self.buy_menu.panel.frame.outline_color = (
|
|
palette.GOLD if self.mode == "buy" else palette.OUTLINE)
|
|
self.sell_menu.panel.frame.outline_color = (
|
|
palette.GOLD if self.mode == "sell" else palette.OUTLINE)
|
|
|
|
# ----------------------------------------------------------------- actions
|
|
def _buy(self, iid):
|
|
if iid is None:
|
|
return
|
|
price = _price(iid)
|
|
if not GS.spend_gold(price):
|
|
return
|
|
GS.grant(iid, 1)
|
|
if iid == "chalk":
|
|
GS.add_flag("chalk_bought")
|
|
ui.Toast(self.ch, "Bought %s" % _items()[iid]["name"], color=palette.GOLD)
|
|
self._refresh_gold()
|
|
self._rebuild_lists()
|
|
|
|
def _sell(self, iid):
|
|
if iid is None:
|
|
return
|
|
if GS.inventory.get(iid, 0) <= 0:
|
|
return
|
|
gain = _sell_price(iid)
|
|
GS.take(iid, 1)
|
|
GS.add_gold(gain)
|
|
ui.Toast(self.ch, "Sold %s (+%d)" % (_items()[iid]["name"], gain),
|
|
color=palette.GRASS)
|
|
self._refresh_gold()
|
|
self._rebuild_lists()
|
|
|
|
# ----------------------------------------------------------------- input
|
|
def _handle(self, key, state):
|
|
if state != mcrfpy.InputState.PRESSED:
|
|
return True
|
|
K = mcrfpy.Key
|
|
if key == K.ESCAPE:
|
|
self._leave()
|
|
return True
|
|
if key == K.TAB:
|
|
self.mode = "sell" if self.mode == "buy" else "buy"
|
|
self._highlight()
|
|
return True
|
|
active = self.buy_menu if self.mode == "buy" else self.sell_menu
|
|
active.handle(key, state)
|
|
return True
|
|
|
|
def _leave(self):
|
|
self.destroy()
|
|
if self.on_close:
|
|
self.on_close()
|
|
|
|
def destroy(self):
|
|
self.stack.pop("shop")
|
|
if self.buy_menu is not None:
|
|
self.buy_menu.destroy()
|
|
if self.sell_menu is not None:
|
|
self.sell_menu.destroy()
|
|
if self.panel is not None:
|
|
self.panel.destroy()
|
|
|
|
|
|
def open_shop(scene=None, stack=None, on_close=None):
|
|
global _MENU
|
|
scene = scene if scene is not None else mcrfpy.current_scene
|
|
_MENU = _Shop(scene, stack, on_close)
|
|
return _MENU
|
|
|
|
|
|
def _hook():
|
|
# opened from dialogue on the overworld: reuse the overworld's input stack
|
|
stack = None
|
|
try:
|
|
from systems import overworld
|
|
if overworld.WORLD is not None:
|
|
stack = overworld.WORLD.stack
|
|
|
|
def _reopen():
|
|
overworld.WORLD.frozen = False
|
|
overworld.WORLD.refresh_hud()
|
|
open_shop(mcrfpy.current_scene, stack, on_close=_reopen)
|
|
overworld.WORLD.frozen = True
|
|
return
|
|
except Exception:
|
|
pass
|
|
open_shop(mcrfpy.current_scene, stack, on_close=None)
|
|
|
|
|
|
def register_hooks():
|
|
dialogue.HOOKS["shop"] = _hook
|
|
|
|
|
|
register_hooks()
|