McRogueFace/games/unwritten/systems/epilogue.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

217 lines
7 KiB
Python

"""UNWRITTEN - the epilogue: the Book read aloud. Owner: Agent C.
HOOKS["epilogue"]() renders data.epilogue.PAGES as full-screen INK pages with
centered PARCH text and a small GOLD ornament, one keypress per page, a soft
fade between them. The final card shows UNWRITTEN, then strikes it with a gold
line and stamps WRITTEN beneath. A last keypress returns to the title scene.
Page conditions use the dialogue req schema (None / ("flag",x) / ("flag_not",x)
/ ("points",n)); {light_name} is substituted from the moth_light_name_* flag
per data/epilogue.py's module docstring.
"""
import mcrfpy
from core import palette
from core import ui
from core import tween
from core.inputstack import InputStack
from systems import dialogue
from systems.state import GS
_LIGHT_NAME = {
"moth_light_name_dark": "for the ones walking in the dark",
"moth_light_name_lost": "for everything the vale lost waiting",
"moth_light_name_small": "for suppers, and mending, and company",
}
def _light_name():
for flag, phrase in _LIGHT_NAME.items():
if GS.has(flag):
return phrase
return None
def _resolve(text):
if "{light_name}" in text:
name = _light_name()
if name is None:
raise KeyError("epilogue page needs {light_name} but no "
"moth_light_name_* flag is set")
text = text.replace("{light_name}", name)
return text
def _collect_pages():
from data import epilogue as ep
out = []
for cond, text in ep.PAGES:
if dialogue.req_met(cond):
out.append(_resolve(text))
return out
class _Epilogue:
def __init__(self):
self.scene = mcrfpy.Scene("epilogue")
mcrfpy.current_scene = self.scene
self.ch = self.scene.children
self.ch.append(mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=palette.INK))
self.pages = _collect_pages()
self.index = -1
self.page_nodes = []
self.final = False
self.done = False
self.stack = InputStack(self.scene)
self.stack.push(self._on_key, "epilogue")
# ----------------------------------------------------------------- flow
def start(self):
self._advance()
def _on_key(self, key, state):
if state != mcrfpy.InputState.PRESSED:
return True
if self.done:
self._return_to_title()
return True
self._advance()
return True
def _advance(self):
# fade current page out, then show next
old = self.page_nodes
self.page_nodes = []
for n in old:
n.animate("opacity", 0.0, 0.22, mcrfpy.Easing.EASE_IN,
callback=self._remove_cb(n))
self.index += 1
if self.index >= len(self.pages):
self._final_card()
return
mcrfpy.Timer("epi_next", self._show_page, 240)
def _remove_cb(self, node):
def cb(*_a):
try:
self.ch.remove(node)
except Exception:
pass
return cb
def _show_page(self, timer=None, runtime=None):
if timer is not None:
timer.stop()
text = self.pages[self.index]
nodes = []
# centered multi-line body
body = mcrfpy.Caption(pos=(0, 0), text=text, font_size=palette.BODY_SIZE + 4,
fill_color=palette.PARCH)
body.x = 512 - body.size.x / 2
body.y = 384 - body.size.y / 2
body.opacity = 0.0
self.ch.append(body)
nodes.append(body)
# gold ornament: a thin rule with a diamond, above the text
rule = mcrfpy.Frame(pos=(512 - 90, body.y - 34), size=(180, 2),
fill_color=palette.GOLD)
rule.opacity = 0.0
self.ch.append(rule)
nodes.append(rule)
diamond = mcrfpy.Caption(pos=(0, 0), text="*", font_size=palette.NAME_SIZE,
fill_color=palette.GOLD)
diamond.x = 512 - diamond.size.x / 2
diamond.y = body.y - 44
diamond.opacity = 0.0
self.ch.append(diamond)
nodes.append(diamond)
# page count hint (DIM, small)
hint = mcrfpy.Caption(pos=(0, 712), text="press any key",
font_size=palette.SMALL_SIZE, fill_color=palette.DIM)
hint.x = 512 - hint.size.x / 2
hint.opacity = 0.0
self.ch.append(hint)
nodes.append(hint)
for n in nodes:
n.animate("opacity", 1.0, 0.4, mcrfpy.Easing.EASE_OUT)
self.page_nodes = nodes
# ----------------------------------------------------------------- final
def _final_card(self):
self.final = True
self.title = mcrfpy.Caption(pos=(0, 0), text="UNWRITTEN",
font_size=palette.TITLE_SIZE,
fill_color=palette.PARCH)
self.title.x = 512 - self.title.size.x / 2
self.title.y = 300
self.title.opacity = 0.0
self.title.outline = 2
self.title.outline_color = palette.INK
self.ch.append(self.title)
self.title.animate("opacity", 1.0, 0.6, mcrfpy.Easing.EASE_OUT)
mcrfpy.Timer("epi_stamp", self._stamp, 950)
def _stamp(self, timer=None, runtime=None):
if timer is not None:
timer.stop()
# gold strike across UNWRITTEN
y = int(self.title.y + self.title.size.y / 2)
strike = mcrfpy.Frame(pos=(int(self.title.x), y), size=(0, 5),
fill_color=palette.GOLD)
strike.z_index = 10
self.ch.append(strike)
strike.animate("w", self.title.size.x, 0.28, mcrfpy.Easing.EASE_OUT,
callback=self._stamp_word)
def _stamp_word(self, *_a):
written = mcrfpy.Caption(pos=(0, 0), text="WRITTEN",
font_size=palette.TITLE_SIZE,
fill_color=palette.GOLD)
written.x = 512 - written.size.x / 2
written.y = 410
written.opacity = 0.0
written.outline = 2
written.outline_color = palette.INK
written.z_index = 20
self.ch.append(written)
# thunk: fast opacity pop + a single shake
written.animate("opacity", 1.0, 0.12, mcrfpy.Easing.EASE_OUT)
tween.shake(written, mag=8, dur=0.25)
mcrfpy.Timer("epi_ready", self._ready, 700)
def _ready(self, timer=None, runtime=None):
if timer is not None:
timer.stop()
self.done = True
def _return_to_title(self):
self.stack.pop("epilogue")
import main
main.build_title()
# The live epilogue, if one is running (test seam: a scripted playthrough drives
# it by pressing keys on its scene and inspects .final/.done). None otherwise.
CURRENT = None
def start_epilogue():
global CURRENT
if not GS.has("nyx_joined"):
GS.add_flag("nyx_missed")
ep = _Epilogue()
CURRENT = ep
ep.start()
return ep
def register_hooks():
dialogue.HOOKS["epilogue"] = start_epilogue
register_hooks()