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
582 lines
21 KiB
Python
582 lines
21 KiB
Python
"""UNWRITTEN - the widget kit. Owner: Agent A.
|
|
|
|
Panel, Label, MenuList, Bar, DialogueBox, Toast, TitleBanner. Every widget
|
|
takes a parent UICollection (scene.children or frame.children) and absolute
|
|
pixel positions, and follows the palette exactly. See ARCHITECTURE section 3.
|
|
"""
|
|
import mcrfpy
|
|
from core import palette
|
|
from core import assets
|
|
from core import tween
|
|
|
|
|
|
# ---------------------------------------------------------------- text wrapping
|
|
def wrap_lines(meas, text, max_px, font_size):
|
|
"""Word-wrap `text` to fit within max_px, measuring with the hidden Caption
|
|
`meas`. Honors any explicit newlines already in the text. Returns a list of
|
|
line strings."""
|
|
meas.font_size = font_size
|
|
out = []
|
|
for para in text.split("\n"):
|
|
words = para.split(" ")
|
|
cur = ""
|
|
for w in words:
|
|
trial = w if not cur else cur + " " + w
|
|
meas.text = trial
|
|
if meas.size.x <= max_px or not cur:
|
|
cur = trial
|
|
else:
|
|
out.append(cur)
|
|
cur = w
|
|
out.append(cur)
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------------------ Panel
|
|
class Panel:
|
|
"""A Frame with PANEL fill and a 2px outline. .frame is the Frame,
|
|
.children is its child collection."""
|
|
|
|
def __init__(self, parent, pos, size, fill=None, outline_color=None,
|
|
outline=2, z_index=None):
|
|
self._parent = parent
|
|
self.frame = mcrfpy.Frame(
|
|
pos=(pos[0], pos[1]), size=(size[0], size[1]),
|
|
fill_color=palette.to_color(fill) if fill is not None else palette.PANEL,
|
|
outline=outline,
|
|
outline_color=palette.to_color(outline_color) if outline_color is not None
|
|
else palette.OUTLINE)
|
|
if z_index is not None:
|
|
self.frame.z_index = z_index
|
|
parent.append(self.frame)
|
|
self.children = self.frame.children
|
|
|
|
def destroy(self):
|
|
try:
|
|
self._parent.remove(self.frame)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ------------------------------------------------------------------------ Label
|
|
def Label(parent, text, pos, color=None, size=palette.BODY_SIZE, center=False,
|
|
outline=0, z_index=None):
|
|
"""Caption factory with palette defaults. If center=True, pos is treated as
|
|
the center point."""
|
|
cap = mcrfpy.Caption(pos=(pos[0], pos[1]), text=str(text), font_size=size,
|
|
fill_color=palette.to_color(color) if color is not None
|
|
else palette.PARCH)
|
|
if outline:
|
|
cap.outline = outline
|
|
cap.outline_color = palette.INK
|
|
if z_index is not None:
|
|
cap.z_index = z_index
|
|
parent.append(cap)
|
|
if center:
|
|
cap.x = pos[0] - cap.size.x / 2
|
|
cap.y = pos[1] - cap.size.y / 2
|
|
return cap
|
|
|
|
|
|
# --------------------------------------------------------------------- MenuList
|
|
class MenuList:
|
|
"""Vertical keyboard menu. items = list of (label, value, enabled) or
|
|
(label, value, enabled, lock_reason). Gold '>' cursor; W/S or Up/Down move;
|
|
Enter/Space pick an ENABLED row; Esc cancels. Disabled rows render DIM with
|
|
their lock reason. Push .handle onto an InputStack (or wire it yourself)."""
|
|
|
|
ROW_H = 30
|
|
|
|
def __init__(self, parent, pos, width, items, on_pick=None, on_cancel=None,
|
|
title=None, size=palette.CHOICE_SIZE):
|
|
self._parent = parent
|
|
self.items = [self._norm(i) for i in items]
|
|
self.on_pick = on_pick
|
|
self.on_cancel = on_cancel
|
|
self.index = self._first_enabled()
|
|
|
|
title_h = 30 if title else 8
|
|
h = title_h + self.ROW_H * len(self.items) + 8
|
|
self.panel = Panel(parent, pos, (width, h),
|
|
outline_color=palette.GOLD)
|
|
self._x = pos[0]
|
|
self._y = pos[1]
|
|
self._top = title_h
|
|
self._size = size
|
|
|
|
if title:
|
|
Label(self.panel.children, title, (14, 7),
|
|
color=palette.GOLD, size=palette.NAME_SIZE)
|
|
|
|
self.cursor = Label(self.panel.children, ">", (10, self._top),
|
|
color=palette.GOLD, size=size)
|
|
self.rows = []
|
|
for i, (label, value, enabled, reason) in enumerate(self.items):
|
|
txt = label
|
|
if not enabled and reason:
|
|
txt = "%s (%s)" % (label, reason)
|
|
row = Label(self.panel.children, txt, (30, self._top + i * self.ROW_H),
|
|
color=palette.PARCH if enabled else palette.DIM, size=size)
|
|
self.rows.append(row)
|
|
self._place_cursor()
|
|
|
|
def _norm(self, item):
|
|
label = item[0]
|
|
value = item[1] if len(item) > 1 else item[0]
|
|
enabled = item[2] if len(item) > 2 else True
|
|
reason = item[3] if len(item) > 3 else ""
|
|
return (label, value, bool(enabled), reason)
|
|
|
|
def _first_enabled(self):
|
|
for i, it in enumerate(self.items):
|
|
if it[2]:
|
|
return i
|
|
return 0
|
|
|
|
def _place_cursor(self):
|
|
self.cursor.y = self._top + self.index * self.ROW_H
|
|
|
|
def move(self, delta):
|
|
n = len(self.items)
|
|
self.index = (self.index + delta) % n
|
|
self._place_cursor()
|
|
|
|
def handle(self, key, state):
|
|
if state != mcrfpy.InputState.PRESSED:
|
|
return False
|
|
K = mcrfpy.Key
|
|
if key in (K.W, K.UP):
|
|
self.move(-1)
|
|
return True
|
|
if key in (K.S, K.DOWN):
|
|
self.move(1)
|
|
return True
|
|
if key in (K.ENTER, K.SPACE):
|
|
label, value, enabled, reason = self.items[self.index]
|
|
if enabled:
|
|
if self.on_pick:
|
|
self.on_pick(value)
|
|
else:
|
|
tween.shake(self.cursor, mag=5, dur=0.2)
|
|
return True
|
|
if key == K.ESCAPE:
|
|
if self.on_cancel:
|
|
self.on_cancel()
|
|
return True
|
|
return False
|
|
|
|
def destroy(self):
|
|
self.panel.destroy()
|
|
|
|
|
|
# -------------------------------------------------------------------------- Bar
|
|
class Bar:
|
|
"""Stat bar: PANEL background + colored fill + optional "cur/max" caption.
|
|
.set(cur, max) updates fill width and text."""
|
|
|
|
def __init__(self, parent, pos, size, color, cur=1, maxv=1,
|
|
show_text=True):
|
|
self._parent = parent
|
|
self.w = size[0]
|
|
self.h = size[1]
|
|
self.bg = mcrfpy.Frame(pos=(pos[0], pos[1]), size=(self.w, self.h),
|
|
fill_color=palette.INSET, outline=2,
|
|
outline_color=palette.OUTLINE)
|
|
parent.append(self.bg)
|
|
self._fill_w = self.w - 4
|
|
self.fill = mcrfpy.Frame(pos=(2, 2), size=(self._fill_w, self.h - 4),
|
|
fill_color=palette.to_color(color))
|
|
self.bg.children.append(self.fill)
|
|
self.cap = None
|
|
if show_text:
|
|
self.cap = mcrfpy.Caption(pos=(0, 0), text="", font_size=palette.SMALL_SIZE,
|
|
fill_color=palette.PARCH)
|
|
self.cap.outline = 1
|
|
self.cap.outline_color = palette.INK
|
|
self.bg.children.append(self.cap)
|
|
self.set(cur, maxv)
|
|
|
|
def set(self, cur, maxv):
|
|
frac = 0.0 if maxv <= 0 else max(0.0, min(1.0, float(cur) / float(maxv)))
|
|
self.fill.w = max(0, self._fill_w * frac)
|
|
if self.cap is not None:
|
|
self.cap.text = "%d/%d" % (int(cur), int(maxv))
|
|
self.cap.x = (self.w - self.cap.size.x) / 2
|
|
self.cap.y = (self.h - self.cap.size.y) / 2
|
|
|
|
def destroy(self):
|
|
try:
|
|
self._parent.remove(self.bg)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ------------------------------------------------------------------------ Toast
|
|
class Toast:
|
|
"""Top-right slide-in note that auto-dismisses after 2.2s. Stacks
|
|
downward as multiple toasts appear."""
|
|
|
|
_live = [] # currently visible toasts, for stacking
|
|
_seq = [0]
|
|
|
|
W = 268
|
|
H = 44
|
|
MARGIN = 16
|
|
|
|
def __init__(self, parent, text, color=None):
|
|
self._parent = parent
|
|
Toast._seq[0] += 1
|
|
self._id = Toast._seq[0]
|
|
slot = len(Toast._live)
|
|
y = self.MARGIN + slot * (self.H + 8)
|
|
final_x = 1024 - self.W - self.MARGIN
|
|
self.frame = mcrfpy.Frame(pos=(final_x + 26, y), size=(self.W, self.H),
|
|
fill_color=palette.PANEL, outline=2,
|
|
outline_color=palette.GOLD)
|
|
self.frame.z_index = 6000
|
|
parent.append(self.frame)
|
|
Label(self.frame.children, text, (self.W / 2, self.H / 2),
|
|
color=palette.to_color(color) if color is not None else palette.PARCH,
|
|
size=palette.SMALL_SIZE, center=True)
|
|
Toast._live.append(self)
|
|
self.frame.animate("x", final_x, 0.28, mcrfpy.Easing.EASE_OUT)
|
|
|
|
self._timer = mcrfpy.Timer("toast_%d" % self._id, self._dismiss, 2200)
|
|
|
|
def _dismiss(self, timer, runtime):
|
|
timer.stop()
|
|
self.frame.animate("x", 1024 + 10, 0.28, mcrfpy.Easing.EASE_IN,
|
|
callback=self._remove)
|
|
|
|
def _remove(self, *_a):
|
|
try:
|
|
self._parent.remove(self.frame)
|
|
except Exception:
|
|
pass
|
|
if self in Toast._live:
|
|
Toast._live.remove(self)
|
|
|
|
def destroy(self):
|
|
try:
|
|
self._timer.stop()
|
|
except Exception:
|
|
pass
|
|
self._remove()
|
|
|
|
|
|
# ------------------------------------------------------------------ TitleBanner
|
|
class TitleBanner:
|
|
"""Area-entry banner: big centered caption that fades in, holds, fades out
|
|
(1.8s total) then removes itself."""
|
|
|
|
def __init__(self, parent, text, cx=512, cy=120, size=palette.BANNER_SIZE,
|
|
color=None, total=1.8):
|
|
self._parent = parent
|
|
self.cap = Label(parent, text, (cx, cy),
|
|
color=palette.to_color(color) if color is not None
|
|
else palette.GOLD,
|
|
size=size, center=True, outline=2, z_index=6500)
|
|
self.cap.opacity = 0.0
|
|
fade = total * 0.25
|
|
hold = total - 2 * fade
|
|
self.cap.animate("opacity", 1.0, fade, mcrfpy.Easing.EASE_OUT)
|
|
self._timer = mcrfpy.Timer("banner_%d" % id(self), self._out,
|
|
int((fade + hold) * 1000))
|
|
|
|
def _out(self, timer, runtime):
|
|
timer.stop()
|
|
self.cap.animate("opacity", 0.0, 0.45, mcrfpy.Easing.EASE_IN,
|
|
callback=self._remove)
|
|
|
|
def _remove(self, *_a):
|
|
try:
|
|
self._parent.remove(self.cap)
|
|
except Exception:
|
|
pass
|
|
|
|
def destroy(self):
|
|
try:
|
|
self._timer.stop()
|
|
except Exception:
|
|
pass
|
|
self._remove()
|
|
|
|
|
|
# ------------------------------------------------------------------ DialogueBox
|
|
class DialogueBox:
|
|
"""The flagship. Bottom box 940x190 at (42, 556): portrait chip (sprite
|
|
scale 5.0 inside a 96px inner frame), gold name plate on the rim, typewriter
|
|
body at 45 chars/sec, blinking down-indicator when waiting, choice mode with
|
|
gold '>' cursor and DIM-but-visible disabled choices.
|
|
|
|
Content-agnostic: show_node(speaker, text, choices=None, on_advance=,
|
|
on_choice=). `choices` is a list of (label, enabled) tuples. This lets the
|
|
dialogue runner AND battle Talk menus reuse the same widget.
|
|
"""
|
|
|
|
BOX_X, BOX_Y = 42, 556
|
|
BOX_W, BOX_H = 940, 190
|
|
CPS = 45.0 # typewriter characters per second
|
|
|
|
# portrait chip
|
|
CHIP_X, CHIP_Y, CHIP = 16, 47, 96
|
|
# text columns (box-relative)
|
|
BODY_X_PORTRAIT = 130
|
|
BODY_X_NARRATOR = 64
|
|
BODY_Y = 20
|
|
WRAP_PORTRAIT = 772
|
|
WRAP_NARRATOR = 800
|
|
|
|
def __init__(self, parent):
|
|
self._parent = parent
|
|
|
|
# book-hum ring lives behind the box, on the top rim
|
|
self._hum_center = (self.BOX_X + self.BOX_W / 2, self.BOX_Y)
|
|
|
|
self.frame = mcrfpy.Frame(
|
|
pos=(self.BOX_X, self.BOX_Y), size=(self.BOX_W, self.BOX_H),
|
|
fill_color=palette.PANEL, outline=2, outline_color=palette.GOLD)
|
|
self.frame.z_index = 3000
|
|
parent.append(self.frame)
|
|
kids = self.frame.children
|
|
|
|
# portrait chip
|
|
self.chip = mcrfpy.Frame(pos=(self.CHIP_X, self.CHIP_Y),
|
|
size=(self.CHIP, self.CHIP),
|
|
fill_color=palette.INSET, outline=2,
|
|
outline_color=palette.GOLD)
|
|
kids.append(self.chip)
|
|
self.portrait = mcrfpy.Sprite(pos=(8, 8), texture=assets.TEX,
|
|
sprite_index=0)
|
|
self.portrait.scale = 5.0
|
|
self.chip.children.append(self.portrait)
|
|
|
|
# name plate (sits on the top rim of the box)
|
|
self.plate = mcrfpy.Frame(pos=(self.BODY_X_PORTRAIT - 2, -16),
|
|
size=(120, 30), fill_color=palette.GOLD,
|
|
outline=2, outline_color=palette.INK)
|
|
kids.append(self.plate)
|
|
self.name_cap = mcrfpy.Caption(pos=(10, 5), text="", font_size=palette.NAME_SIZE,
|
|
fill_color=palette.INK)
|
|
self.plate.children.append(self.name_cap)
|
|
|
|
# body caption (typewriter target)
|
|
self.body = mcrfpy.Caption(pos=(self.BODY_X_PORTRAIT, self.BODY_Y),
|
|
text="", font_size=palette.BODY_SIZE,
|
|
fill_color=palette.PARCH)
|
|
kids.append(self.body)
|
|
|
|
# hidden measuring caption (never shown)
|
|
self._meas = mcrfpy.Caption(pos=(0, 0), text="", font_size=palette.BODY_SIZE,
|
|
fill_color=palette.PARCH)
|
|
self._meas.visible = False
|
|
kids.append(self._meas)
|
|
|
|
# down indicator
|
|
self.indicator = mcrfpy.Caption(pos=(self.BOX_W - 40, self.BOX_H - 34),
|
|
text="v", font_size=22,
|
|
fill_color=palette.GOLD)
|
|
self.indicator.visible = False
|
|
kids.append(self.indicator)
|
|
|
|
# choice captions (rebuilt each choice node)
|
|
self.choice_caps = []
|
|
self.choice_cursor = mcrfpy.Caption(pos=(0, 0), text=">",
|
|
font_size=palette.CHOICE_SIZE,
|
|
fill_color=palette.GOLD)
|
|
self.choice_cursor.visible = False
|
|
kids.append(self.choice_cursor)
|
|
|
|
# state
|
|
self._full = ""
|
|
self._typing = False
|
|
self._t0 = None
|
|
self._on_advance = None
|
|
self._on_choice = None
|
|
self._choices = None # list of (enabled, first_line_y)
|
|
self._sel = 0
|
|
|
|
# entrance: slide up + fade in
|
|
self.frame.opacity = 0.0
|
|
self.frame.y = self.BOX_Y + 12
|
|
self.frame.animate("y", self.BOX_Y, 0.22, mcrfpy.Easing.EASE_OUT)
|
|
self.frame.animate("opacity", 1.0, 0.22, mcrfpy.Easing.EASE_OUT)
|
|
|
|
self._timer = mcrfpy.Timer("dlg_type", self._tick, 16)
|
|
|
|
# ------------------------------------------------------------------ display
|
|
def show_node(self, speaker, text, choices=None, on_advance=None,
|
|
on_choice=None):
|
|
self._on_advance = on_advance
|
|
self._on_choice = on_choice
|
|
self._clear_choices()
|
|
self.indicator.visible = False
|
|
|
|
is_narrator = (speaker == "NARRATOR" or speaker is None or
|
|
assets.PORTRAITS.get(speaker) is None)
|
|
|
|
if is_narrator:
|
|
self.chip.visible = False
|
|
self.portrait.visible = False
|
|
self.plate.visible = False
|
|
self.name_cap.visible = False
|
|
self.body.x = self.BODY_X_NARRATOR
|
|
self.body.fill_color = palette.DIM
|
|
wrap = self.WRAP_NARRATOR
|
|
else:
|
|
self.chip.visible = True
|
|
self.portrait.visible = True
|
|
self.portrait.sprite_index = assets.portrait_index(speaker)
|
|
self.plate.visible = True
|
|
self.name_cap.visible = True
|
|
self.name_cap.text = speaker
|
|
self.plate.w = self.name_cap.size.x + 22
|
|
self.body.x = self.BODY_X_PORTRAIT
|
|
self.body.fill_color = palette.PARCH
|
|
wrap = self.WRAP_PORTRAIT
|
|
|
|
self.body.y = self.BODY_Y
|
|
lines = wrap_lines(self._meas, text, wrap, palette.BODY_SIZE) if text else [""]
|
|
self._full = "\n".join(lines)
|
|
self._raw_choices = choices
|
|
|
|
if self._full.strip() == "":
|
|
# empty text node: reveal instantly, go straight to choices/advance
|
|
self.body.text = ""
|
|
self._typing = False
|
|
self._on_reveal_done()
|
|
else:
|
|
self.body.text = ""
|
|
self._typing = True
|
|
self._t0 = None
|
|
|
|
def _tick(self, timer, runtime):
|
|
if not self._typing:
|
|
return
|
|
if self._t0 is None:
|
|
self._t0 = runtime
|
|
elapsed = runtime - self._t0
|
|
n = int(elapsed / 1000.0 * self.CPS)
|
|
if n >= len(self._full):
|
|
self.body.text = self._full
|
|
self._typing = False
|
|
self._on_reveal_done()
|
|
else:
|
|
self.body.text = self._full[:n]
|
|
|
|
def _skip(self):
|
|
self.body.text = self._full
|
|
self._typing = False
|
|
self._on_reveal_done()
|
|
|
|
def _on_reveal_done(self):
|
|
if self._raw_choices:
|
|
self._build_choices(self._raw_choices)
|
|
else:
|
|
self.indicator.visible = True
|
|
self._blink_indicator()
|
|
|
|
def _blink_indicator(self):
|
|
self.indicator.opacity = 1.0
|
|
self.indicator.animate("opacity", 0.15, 0.55, mcrfpy.Easing.EASE_IN_OUT,
|
|
loop=True)
|
|
|
|
# ------------------------------------------------------------------ choices
|
|
def _clear_choices(self):
|
|
for c in self.choice_caps:
|
|
try:
|
|
self.frame.children.remove(c)
|
|
except Exception:
|
|
pass
|
|
self.choice_caps = []
|
|
self._choices = None
|
|
self.choice_cursor.visible = False
|
|
|
|
def _build_choices(self, choices):
|
|
# choices: list of (label, enabled)
|
|
body_bottom = self.body.y + self.body.size.y
|
|
y = body_bottom + 12
|
|
wrap = (self.WRAP_PORTRAIT if self.body.x == self.BODY_X_PORTRAIT
|
|
else self.WRAP_NARRATOR) - 24
|
|
x = self.body.x
|
|
self._choices = []
|
|
for label, enabled in choices:
|
|
lines = wrap_lines(self._meas, label, wrap, palette.CHOICE_SIZE)
|
|
cap = mcrfpy.Caption(pos=(x + 22, y), text="\n".join(lines),
|
|
font_size=palette.CHOICE_SIZE,
|
|
fill_color=palette.PARCH if enabled else palette.DIM)
|
|
self.frame.children.append(cap)
|
|
self.choice_caps.append(cap)
|
|
self._choices.append([bool(enabled), y])
|
|
y += cap.size.y + 8
|
|
# select first enabled
|
|
self._sel = 0
|
|
for i, (en, _yy) in enumerate(self._choices):
|
|
if en:
|
|
self._sel = i
|
|
break
|
|
self._place_choice_cursor()
|
|
self.choice_cursor.visible = True
|
|
|
|
def _place_choice_cursor(self):
|
|
if not self._choices:
|
|
return
|
|
_en, yy = self._choices[self._sel]
|
|
self.choice_cursor.x = self.body.x
|
|
self.choice_cursor.y = yy
|
|
self.choice_cursor.fill_color = palette.GOLD
|
|
|
|
def _move_choice(self, delta):
|
|
n = len(self._choices)
|
|
self._sel = (self._sel + delta) % n
|
|
self._place_choice_cursor()
|
|
|
|
# -------------------------------------------------------------------- input
|
|
def handle(self, key, state):
|
|
if state != mcrfpy.InputState.PRESSED:
|
|
return False
|
|
K = mcrfpy.Key
|
|
# typing: any advance key skips to full
|
|
if self._typing:
|
|
if key in (K.SPACE, K.ENTER):
|
|
self._skip()
|
|
return True
|
|
# choice mode
|
|
if self._choices:
|
|
if key in (K.W, K.UP):
|
|
self._move_choice(-1)
|
|
return True
|
|
if key in (K.S, K.DOWN):
|
|
self._move_choice(1)
|
|
return True
|
|
if key in (K.ENTER, K.SPACE):
|
|
enabled, _yy = self._choices[self._sel]
|
|
if enabled:
|
|
idx = self._sel
|
|
if self._on_choice:
|
|
self._on_choice(idx)
|
|
else:
|
|
tween.shake(self.choice_cursor, mag=5, dur=0.2)
|
|
return True
|
|
return True
|
|
# text node waiting to advance
|
|
if key in (K.SPACE, K.ENTER):
|
|
if self._on_advance:
|
|
self._on_advance()
|
|
return True
|
|
return True
|
|
|
|
# ---------------------------------------------------------------- book hum
|
|
def hum(self):
|
|
tween.gold_ring(self._parent, self._hum_center, max_r=64, dur=0.5)
|
|
|
|
# ----------------------------------------------------------------- teardown
|
|
def destroy(self):
|
|
try:
|
|
self._timer.stop()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self._parent.remove(self.frame)
|
|
except Exception:
|
|
pass
|