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
149 lines
4.7 KiB
Python
149 lines
4.7 KiB
Python
"""UNWRITTEN - bootstrap and title screen. Owner: Agent A.
|
|
|
|
Run windowed:
|
|
cd build && ./mcrogueface --exec ../games/unwritten/main.py
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import mcrfpy
|
|
from core import palette, ui, tween
|
|
from core.inputstack import InputStack
|
|
from systems import dialogue
|
|
from systems.state import GS
|
|
|
|
# Boot wiring (Agent D): importing each system registers its dialogue HOOKS at
|
|
# import time. main.py used to import none of them, so goto_area/battle/shop/
|
|
# swap_menu/epilogue were never registered and _enter_world raised. Import them
|
|
# here, once, so the whole game is wired the moment main is loaded.
|
|
from systems import overworld # noqa: F401 registers HOOKS["goto_area"]
|
|
from systems import battle # noqa: F401 registers HOOKS["battle"]
|
|
from systems import shop # noqa: F401 registers HOOKS["shop"]
|
|
from systems import party_menu # noqa: F401 registers HOOKS["swap_menu"]
|
|
from systems import epilogue # noqa: F401 registers HOOKS["epilogue"]
|
|
|
|
|
|
# ----------------------------------------------------------------- chapter presets
|
|
def _preset_act1():
|
|
GS.reset()
|
|
GS.act = 1
|
|
GS.recruit("PIP", 1)
|
|
GS.gold = 30
|
|
GS.grant("bread", 2)
|
|
GS.grant("red_tonic", 1)
|
|
roster = GS.roster.get("PIP")
|
|
if roster is not None:
|
|
roster.weapon = "knife"
|
|
|
|
|
|
def _preset_act2():
|
|
GS.reset()
|
|
GS.act = 2
|
|
for f in ("door_open", "bramble_joined", "moth_joined", "quill_confident",
|
|
"griselda_friend", "vera_grudge", "shrine_chest_open"):
|
|
GS.add_flag(f)
|
|
GS.recruit("PIP", 3)
|
|
GS.recruit("BRAMBLE", 3)
|
|
GS.recruit("MOTH", 3)
|
|
GS.gold = 120
|
|
GS.grant("bread", 3)
|
|
GS.grant("red_tonic", 2)
|
|
GS.grant("blue_draught", 1)
|
|
GS.grant("brass_key", 1)
|
|
|
|
|
|
def _preset_act3():
|
|
_preset_act2()
|
|
GS.act = 3
|
|
for f in ("cantor_joined", "gatekeeper_slain", "vera_reconciled",
|
|
"asked_unwoken", "touched_sketch"):
|
|
GS.add_flag(f)
|
|
GS.recruit("CANTOR", 5)
|
|
for cid in ("PIP", "BRAMBLE", "MOTH", "CANTOR"):
|
|
c = GS.roster.get(cid)
|
|
if c is not None:
|
|
c.level = 5
|
|
c.xp = 10 * 5 * 4
|
|
c.full_heal()
|
|
GS.points = 8
|
|
GS.grant("gatekeepers_tooth", 1)
|
|
GS.add_key_item("gatekeepers_tooth")
|
|
|
|
|
|
# -------------------------------------------------------------------- title scene
|
|
def build_title():
|
|
scene = mcrfpy.Scene("title")
|
|
mcrfpy.current_scene = scene
|
|
ch = scene.children
|
|
|
|
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=palette.INK)
|
|
ch.append(bg)
|
|
|
|
# slow gold pulse behind the title
|
|
ring = mcrfpy.Circle(radius=90, center=(512, 250),
|
|
fill_color=palette.C(palette.GOLD_T, 18),
|
|
outline=1, outline_color=palette.C(palette.GOLD_T, 40))
|
|
ch.append(ring)
|
|
ring.animate("radius", 130, 2.6, mcrfpy.Easing.EASE_IN_OUT, loop=True)
|
|
|
|
title = ui.Label(ch, "UNWRITTEN", (512, 250), color=palette.PARCH,
|
|
size=palette.TITLE_SIZE, center=True, outline=2)
|
|
|
|
rule = mcrfpy.Frame(pos=(512 - 220, 316), size=(440, 3),
|
|
fill_color=palette.GOLD)
|
|
ch.append(rule)
|
|
|
|
ui.Label(ch, "the world is finished. the story is not.", (512, 350),
|
|
color=palette.DIM, size=palette.BODY_SIZE, center=True)
|
|
|
|
press = ui.Label(ch, "press ENTER", (512, 600), color=palette.DIM,
|
|
size=palette.BODY_SIZE, center=True)
|
|
press.animate("opacity", 0.15, 0.8, mcrfpy.Easing.EASE_IN_OUT, loop=True)
|
|
|
|
stack = InputStack(scene)
|
|
|
|
def on_key(key, state):
|
|
if state != mcrfpy.InputState.PRESSED:
|
|
return True
|
|
K = mcrfpy.Key
|
|
if key == K.ENTER:
|
|
start_new_game(stack)
|
|
elif key == K.NUM_1:
|
|
_preset_act1()
|
|
_enter_world("hollowbrook", "start")
|
|
elif key == K.NUM_2:
|
|
_preset_act2()
|
|
_enter_world("undercroft", "enter")
|
|
elif key == K.NUM_3:
|
|
_preset_act3()
|
|
_enter_world("hollowbrook", "from_ferry")
|
|
return True
|
|
|
|
stack.push(on_key, "title")
|
|
return scene
|
|
|
|
|
|
def start_new_game(stack):
|
|
_preset_act1()
|
|
# intro monologue, then hand off to the overworld
|
|
dialogue.run_scene("intro", on_done=lambda: _enter_world("hollowbrook", "start"),
|
|
stack=stack)
|
|
|
|
|
|
def _enter_world(area, spawn):
|
|
hook = dialogue.HOOKS.get("goto_area")
|
|
if hook is None:
|
|
raise RuntimeError(
|
|
"cannot enter world: systems.dialogue.HOOKS['goto_area'] is not "
|
|
"registered yet (Agent C's overworld must register it)")
|
|
hook(area, spawn)
|
|
|
|
|
|
def main():
|
|
build_title()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|