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
This commit is contained in:
parent
d94a25706a
commit
80908f771c
31 changed files with 8847 additions and 0 deletions
63
games/unwritten/README.md
Normal file
63
games/unwritten/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# UNWRITTEN
|
||||||
|
|
||||||
|
*The world is finished. The story is not.*
|
||||||
|
|
||||||
|
A complete short-form RPG (30-45 minutes) built on McRogueFace: turn-based
|
||||||
|
battle-screen combat, a six-character roster with an active party of three,
|
||||||
|
choice-driven dialogue with skill checks, shops, loot, levels, two bosses,
|
||||||
|
and an epilogue assembled from the choices you actually made.
|
||||||
|
|
||||||
|
The Maker built a perfect vale, sat down to write what should happen in it,
|
||||||
|
and couldn't think of a single thing. Three hundred years later, someone
|
||||||
|
wakes up holding a blank book.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd build
|
||||||
|
./mcrogueface --exec ../games/unwritten/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- **WASD / arrows** - move (bump into people to talk, into enemies to fight)
|
||||||
|
- **Enter / Space** - advance dialogue, confirm menus (Space also skips the typewriter)
|
||||||
|
- **Esc** - back out of menus / open the party menu from the overworld
|
||||||
|
- **E / Enter** - interact (camps, props)
|
||||||
|
- **Tab** - party menu (swap, equip, items, stats, key items)
|
||||||
|
- Title screen: **1 / 2 / 3** jump to Act 1/2/3 with appropriate preset state (debug)
|
||||||
|
|
||||||
|
## What to look for
|
||||||
|
|
||||||
|
- Every dialogue choice is remembered; several are gated by WHO is in your
|
||||||
|
active party (each character carries a dialogue tag) or by what you chose
|
||||||
|
hours earlier. Locked options stay visible - that "what would that have
|
||||||
|
been?" itch is intentional.
|
||||||
|
- Both bosses can be TALKED to mid-battle if you brought the right people.
|
||||||
|
- After the first boss, go home. The way the town comes back is the game's
|
||||||
|
favorite trick.
|
||||||
|
- Whether you find the sixth party member at all depends on questions you
|
||||||
|
did or didn't ask in Act 1.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `design/` - creative bible, systems spec, architecture contracts
|
||||||
|
- `data/` - authored content: dialogue scripts (33 scenes / 171 nodes),
|
||||||
|
maps, items, enemies, epilogue pages, battle barks
|
||||||
|
- `core/` - UI kit (dialogue box, menus, bars, toasts), palette, input stack
|
||||||
|
- `systems/` - state, dialogue runner, overworld, battle, shop, menus, epilogue
|
||||||
|
- `tests/` - unit tests per system + `playthrough.py`, a scripted headless
|
||||||
|
run of the entire game (20 beats, title card to WRITTEN stamp):
|
||||||
|
`cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/playthrough.py`
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Made overnight, on request, as an answer to a question.
|
||||||
|
|
||||||
|
- Creative direction, story, dialogue, systems & encounter design: **Fable** (Claude)
|
||||||
|
- Implementation: Claude Opus subagents (framework / battle / overworld / integration)
|
||||||
|
- Engine: **McRogueFace** by John McCardle
|
||||||
|
- Tileset: Kenney's Tiny Dungeon (characters and items only - the terrain
|
||||||
|
is nothing but colored cells, on purpose)
|
||||||
|
|
||||||
|
The Maker built the vale. You were what happened.
|
||||||
38
games/unwritten/core/assets.py
Normal file
38
games/unwritten/core/assets.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""UNWRITTEN - shared assets. Owner: Agent A.
|
||||||
|
|
||||||
|
The ONE Texture for the whole game and the speaker->portrait sprite map.
|
||||||
|
Constructed once here; import TEX everywhere else (never re-create the Texture).
|
||||||
|
Asset path is relative to the build/ working directory (see ARCHITECTURE section 0).
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
|
||||||
|
TEX = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
|
|
||||||
|
# Speaker id -> sprite index (BIBLE sections 2/3/4). NARRATOR has no portrait.
|
||||||
|
PORTRAITS = {
|
||||||
|
"PIP": 85,
|
||||||
|
"BRAMBLE": 96,
|
||||||
|
"MOTH": 84,
|
||||||
|
"VERA": 111,
|
||||||
|
"CANTOR": 109,
|
||||||
|
"NYX": 121,
|
||||||
|
"QUILL": 100,
|
||||||
|
"GRISELDA": 88,
|
||||||
|
"ODD": 87,
|
||||||
|
"BELL": 110,
|
||||||
|
"GATEKEEPER": 20,
|
||||||
|
"CUSTODIAN": 41,
|
||||||
|
"NARRATOR": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def portrait_index(speaker):
|
||||||
|
"""Return the sprite index for a speaker id, or None for NARRATOR.
|
||||||
|
|
||||||
|
Fail early: an unknown speaker id is a content/authoring error, not a
|
||||||
|
thing to paper over with a placeholder sprite.
|
||||||
|
"""
|
||||||
|
if speaker not in PORTRAITS:
|
||||||
|
raise KeyError(
|
||||||
|
"unknown speaker id %r - add it to core.assets.PORTRAITS" % (speaker,))
|
||||||
|
return PORTRAITS[speaker]
|
||||||
46
games/unwritten/core/inputstack.py
Normal file
46
games/unwritten/core/inputstack.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""UNWRITTEN - modal input dispatch. Owner: Agent A.
|
||||||
|
|
||||||
|
One InputStack per scene owns scene.on_key and dispatches to a stack of
|
||||||
|
handlers, top-most first. A handler is fn(key, state) -> bool; returning True
|
||||||
|
consumes the event (lower handlers do not see it). Overworld pushes its
|
||||||
|
movement handler; a DialogueBox / menu pushes itself while open and pops on
|
||||||
|
close, freezing everything beneath it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class InputStack:
|
||||||
|
def __init__(self, scene):
|
||||||
|
self.scene = scene
|
||||||
|
self.stack = [] # list of (name, handler)
|
||||||
|
scene.on_key = self._dispatch
|
||||||
|
|
||||||
|
def push(self, handler, name=""):
|
||||||
|
self.stack.append((name, handler))
|
||||||
|
|
||||||
|
def pop(self, name=""):
|
||||||
|
"""Pop the top handler, or the top-most handler matching name."""
|
||||||
|
if name:
|
||||||
|
for i in range(len(self.stack) - 1, -1, -1):
|
||||||
|
if self.stack[i][0] == name:
|
||||||
|
return self.stack.pop(i)
|
||||||
|
return None
|
||||||
|
if self.stack:
|
||||||
|
return self.stack.pop()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def replace(self, handler, name=""):
|
||||||
|
"""Replace the top handler with a new one."""
|
||||||
|
self.pop()
|
||||||
|
self.push(handler, name)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.stack = []
|
||||||
|
|
||||||
|
def has(self, name):
|
||||||
|
return any(n == name for n, _ in self.stack)
|
||||||
|
|
||||||
|
def _dispatch(self, key, state):
|
||||||
|
for i in range(len(self.stack) - 1, -1, -1):
|
||||||
|
handler = self.stack[i][1]
|
||||||
|
if handler(key, state):
|
||||||
|
return
|
||||||
79
games/unwritten/core/palette.py
Normal file
79
games/unwritten/core/palette.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""UNWRITTEN - palette and font constants. Owner: Agent A.
|
||||||
|
|
||||||
|
Colors from BIBLE section 6, defined EXACTLY. Each color is provided as both a
|
||||||
|
mcrfpy.Color constant (for constructor kwargs) and a raw (r,g,b) tuple with a
|
||||||
|
_T suffix (for lerping / passing to color math at call sites).
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- raw tuples
|
||||||
|
INK_T = (11, 12, 16) # global background
|
||||||
|
PARCH_T = (232, 220, 192) # primary text
|
||||||
|
GOLD_T = (212, 169, 78) # accents, selection, the Book
|
||||||
|
DIM_T = (138, 132, 118) # secondary text, locked options
|
||||||
|
BLOOD_T = (205, 66, 57) # damage, HP low
|
||||||
|
TEAL_T = (80, 190, 180) # SP, magic
|
||||||
|
GRASS_T = (96, 160, 92) # heals, XP
|
||||||
|
PANEL_T = (22, 24, 32) # UI panel fill
|
||||||
|
OUTLINE_T = (52, 60, 80) # panel outline (2px)
|
||||||
|
GREY_T = (58, 58, 62) # Act 3 grey-world wash target
|
||||||
|
|
||||||
|
# a slightly darker fill used behind portrait chips / insets
|
||||||
|
INSET_T = (14, 15, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def C(t, a=255):
|
||||||
|
"""Build a mcrfpy.Color from a 3-tuple (plus optional alpha)."""
|
||||||
|
return mcrfpy.Color(t[0], t[1], t[2], a)
|
||||||
|
|
||||||
|
|
||||||
|
def to_color(c, a=None):
|
||||||
|
"""Coerce a Color OR (r,g,b[,a]) tuple/list into a fresh mcrfpy.Color.
|
||||||
|
|
||||||
|
Some call sites (lerps, animations) hand us tuples; others hand Colors.
|
||||||
|
Never blind-cast; always produce a new Color so shared constants are safe.
|
||||||
|
"""
|
||||||
|
if isinstance(c, mcrfpy.Color):
|
||||||
|
if a is None:
|
||||||
|
return mcrfpy.Color(c.r, c.g, c.b, c.a)
|
||||||
|
return mcrfpy.Color(c.r, c.g, c.b, a)
|
||||||
|
r, g, b = c[0], c[1], c[2]
|
||||||
|
if a is not None:
|
||||||
|
alpha = a
|
||||||
|
elif len(c) > 3:
|
||||||
|
alpha = c[3]
|
||||||
|
else:
|
||||||
|
alpha = 255
|
||||||
|
return mcrfpy.Color(int(r), int(g), int(b), int(alpha))
|
||||||
|
|
||||||
|
|
||||||
|
def lerp_t(a, b, t):
|
||||||
|
"""Linear interpolation between two raw (r,g,b) tuples -> (r,g,b) tuple."""
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
return (int(a[0] + (b[0] - a[0]) * t),
|
||||||
|
int(a[1] + (b[1] - a[1]) * t),
|
||||||
|
int(a[2] + (b[2] - a[2]) * t))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- Color consts
|
||||||
|
INK = C(INK_T)
|
||||||
|
PARCH = C(PARCH_T)
|
||||||
|
GOLD = C(GOLD_T)
|
||||||
|
DIM = C(DIM_T)
|
||||||
|
BLOOD = C(BLOOD_T)
|
||||||
|
TEAL = C(TEAL_T)
|
||||||
|
GRASS = C(GRASS_T)
|
||||||
|
PANEL = C(PANEL_T)
|
||||||
|
OUTLINE = C(OUTLINE_T)
|
||||||
|
GREY = C(GREY_T)
|
||||||
|
INSET = C(INSET_T)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- fonts/sizes
|
||||||
|
FONT = mcrfpy.default_font
|
||||||
|
|
||||||
|
TITLE_SIZE = 72
|
||||||
|
BANNER_SIZE = 44
|
||||||
|
NAME_SIZE = 17
|
||||||
|
BODY_SIZE = 16
|
||||||
|
CHOICE_SIZE = 15
|
||||||
|
SMALL_SIZE = 14
|
||||||
100
games/unwritten/core/tween.py
Normal file
100
games/unwritten/core/tween.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""UNWRITTEN - motion helpers. Owner: Agent A.
|
||||||
|
|
||||||
|
Thin wrappers over obj.animate(...) so call sites read as intentions, not
|
||||||
|
easing bookkeeping. Nothing here teleports; every transition is a tween
|
||||||
|
(BIBLE section 6: Motion).
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
from core import palette
|
||||||
|
|
||||||
|
_uid = [0]
|
||||||
|
|
||||||
|
|
||||||
|
def _next_id():
|
||||||
|
_uid[0] += 1
|
||||||
|
return _uid[0]
|
||||||
|
|
||||||
|
|
||||||
|
def fade_scene(scene, on_done=None, dur=0.25):
|
||||||
|
"""Cover the scene with an INK frame fading 0 -> 1 over dur, then call
|
||||||
|
on_done (which typically activates/builds the next scene). Returns the
|
||||||
|
cover frame so the caller can fade it back out on the new scene."""
|
||||||
|
cover = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
|
||||||
|
fill_color=palette.C(palette.INK_T, 0))
|
||||||
|
cover.z_index = 100000
|
||||||
|
scene.children.append(cover)
|
||||||
|
|
||||||
|
def done(*_a):
|
||||||
|
if on_done:
|
||||||
|
on_done()
|
||||||
|
cover.animate("opacity", 1.0, dur, mcrfpy.Easing.EASE_IN_OUT, callback=done)
|
||||||
|
return cover
|
||||||
|
|
||||||
|
|
||||||
|
def uncover(scene, cover, dur=0.25):
|
||||||
|
"""Fade an INK cover frame back out and remove it from the scene."""
|
||||||
|
def done(*_a):
|
||||||
|
try:
|
||||||
|
scene.children.remove(cover)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cover.animate("opacity", 0.0, dur, mcrfpy.Easing.EASE_IN_OUT, callback=done)
|
||||||
|
|
||||||
|
|
||||||
|
def float_text(parent, text, pos, color, rise=42, dur=0.85, size=20):
|
||||||
|
"""Rising, fading caption. `parent` is a UICollection (scene.children or
|
||||||
|
frame.children). Used for damage numbers, +1 Story Point, loot notes."""
|
||||||
|
cap = mcrfpy.Caption(pos=(pos[0], pos[1]), text=str(text), font_size=size,
|
||||||
|
fill_color=palette.to_color(color))
|
||||||
|
cap.outline = 1
|
||||||
|
cap.outline_color = palette.INK
|
||||||
|
cap.z_index = 5000
|
||||||
|
parent.append(cap)
|
||||||
|
cap.animate("y", pos[1] - rise, dur, mcrfpy.Easing.EASE_OUT)
|
||||||
|
|
||||||
|
def done(*_a):
|
||||||
|
try:
|
||||||
|
parent.remove(cap)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cap.animate("opacity", 0.0, dur, mcrfpy.Easing.EASE_OUT, callback=done)
|
||||||
|
return cap
|
||||||
|
|
||||||
|
|
||||||
|
def shake(drawable, mag=6, dur=0.3):
|
||||||
|
"""Small horizontal shake that returns to the original x."""
|
||||||
|
ox = drawable.x
|
||||||
|
seq = [ox + mag, ox - mag, ox + mag * 0.5, ox]
|
||||||
|
step_dur = max(0.02, dur / len(seq))
|
||||||
|
|
||||||
|
def go(i):
|
||||||
|
if i >= len(seq):
|
||||||
|
return
|
||||||
|
drawable.animate("x", seq[i], step_dur,
|
||||||
|
callback=lambda *_a: go(i + 1))
|
||||||
|
go(0)
|
||||||
|
|
||||||
|
|
||||||
|
def pulse(drawable, prop, hi, dur=1.0, loop=True, easing=None):
|
||||||
|
"""Animate a property toward hi (looping by default) for idle throbs."""
|
||||||
|
drawable.animate(prop, hi, dur,
|
||||||
|
easing if easing is not None else mcrfpy.Easing.EASE_IN_OUT,
|
||||||
|
loop=loop)
|
||||||
|
|
||||||
|
|
||||||
|
def gold_ring(parent, center, max_r=70, dur=0.5):
|
||||||
|
"""A single gold circle that rings outward and fades. The Book hum."""
|
||||||
|
circ = mcrfpy.Circle(radius=2, center=(center[0], center[1]),
|
||||||
|
fill_color=palette.C(palette.GOLD_T, 0),
|
||||||
|
outline=2, outline_color=palette.C(palette.GOLD_T, 200))
|
||||||
|
circ.z_index = 4000
|
||||||
|
parent.append(circ)
|
||||||
|
circ.animate("radius", max_r, dur, mcrfpy.Easing.EASE_OUT)
|
||||||
|
|
||||||
|
def done(*_a):
|
||||||
|
try:
|
||||||
|
parent.remove(circ)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
circ.animate("opacity", 0.0, dur, mcrfpy.Easing.EASE_OUT, callback=done)
|
||||||
|
return circ
|
||||||
582
games/unwritten/core/ui.py
Normal file
582
games/unwritten/core/ui.py
Normal file
|
|
@ -0,0 +1,582 @@
|
||||||
|
"""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
|
||||||
51
games/unwritten/data/barks.py
Normal file
51
games/unwritten/data/barks.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""UNWRITTEN - battle barks. Authored by Fable. One-liners shown in the
|
||||||
|
battle log. Keys: char id -> {event: [lines]}. Pick randomly, never twice in
|
||||||
|
a row. Events: skill_a, skill_b, hurt_low (under 30% HP), victory, ko."""
|
||||||
|
|
||||||
|
BARKS = {
|
||||||
|
"PIP": {
|
||||||
|
"skill_a": ["Two for flinching.", "Quick hands, quicker feet."],
|
||||||
|
"skill_b": ["Yoink.", "Finders keepers is a LAW now. I decided."],
|
||||||
|
"hurt_low": ["Ow. Noted. OW."],
|
||||||
|
"victory": ["Put it in the Book."],
|
||||||
|
"ko": ["...page... torn..."],
|
||||||
|
},
|
||||||
|
"BRAMBLE": {
|
||||||
|
"skill_a": ["The post has legs. STAND BEHIND THE POST.",
|
||||||
|
"Shieldwall! As rehearsed! I rehearsed alone!"],
|
||||||
|
"skill_b": ["Forty years of standing still, arriving all at once!"],
|
||||||
|
"hurt_low": ["Merely denting the paperwork."],
|
||||||
|
"victory": ["Watch concluded. Nothing got past me."],
|
||||||
|
"ko": ["...post... unmanned..."],
|
||||||
|
},
|
||||||
|
"MOTH": {
|
||||||
|
"skill_a": ["Light does mending too.", "Steady. The wick knows."],
|
||||||
|
"skill_b": ["This one's named. DUCK."],
|
||||||
|
"hurt_low": ["Guttering... not out."],
|
||||||
|
"victory": ["All lamps accounted for."],
|
||||||
|
"ko": ["...keep it... lit..."],
|
||||||
|
},
|
||||||
|
"VERA": {
|
||||||
|
"skill_a": ["Artisanal!", "Shake well before throwing!"],
|
||||||
|
"skill_b": ["Possibility, resolving NOW."],
|
||||||
|
"hurt_low": ["I'm marking THIS down as a cost of business."],
|
||||||
|
"victory": ["Invoice to follow."],
|
||||||
|
"ko": ["...inventory... early again..."],
|
||||||
|
},
|
||||||
|
"CANTOR": {
|
||||||
|
"skill_a": ["A note, held long enough, is a wall.",
|
||||||
|
"Tuning... found you."],
|
||||||
|
"skill_b": ["THE FANFARE ARRIVES IN THE MIDDLE."],
|
||||||
|
"hurt_low": ["Reeds cracked. Song intact."],
|
||||||
|
"victory": ["Rest, everyone. That is also part of the music."],
|
||||||
|
"ko": ["...the note... someone hold... the note..."],
|
||||||
|
},
|
||||||
|
"NYX": {
|
||||||
|
"skill_a": ["Cold spot. You're standing in it.",
|
||||||
|
"The margin bites back."],
|
||||||
|
"skill_b": ["Watch the empty spot. WATCH IT."],
|
||||||
|
"hurt_low": ["Still here. STILL here."],
|
||||||
|
"victory": ["Sketch that."],
|
||||||
|
"ko": ["...later... always later..."],
|
||||||
|
},
|
||||||
|
}
|
||||||
153
games/unwritten/data/characters.py
Normal file
153
games/unwritten/data/characters.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""UNWRITTEN - party data tables. Owner: Agent B.
|
||||||
|
|
||||||
|
BASE (level-1 stats), GROWTH (per-level growth dicts, index i applied when a
|
||||||
|
character REACHES level i+2, so 7 entries cover levels 2..8), SKILLS (per-char
|
||||||
|
[skillA, skillB]) and CHAR_SPRITES. Numbers are canon from SYSTEMS sections 1
|
||||||
|
and 3. ASCII only.
|
||||||
|
|
||||||
|
state.CharState reads BASE[cid] and GROWTH[cid]; battle.py reads SKILLS and
|
||||||
|
CHAR_SPRITES. "Fail early": unknown ids raise KeyError at the call site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- sprite indices
|
||||||
|
CHAR_SPRITES = {
|
||||||
|
"PIP": 85,
|
||||||
|
"BRAMBLE": 96,
|
||||||
|
"MOTH": 84,
|
||||||
|
"VERA": 111,
|
||||||
|
"CANTOR": 109,
|
||||||
|
"NYX": 121,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- base stats (L1)
|
||||||
|
BASE = {
|
||||||
|
"PIP": {"HP": 34, "SP": 10, "ATK": 7, "MAG": 3, "DEF": 4, "SPD": 9},
|
||||||
|
"BRAMBLE": {"HP": 46, "SP": 8, "ATK": 6, "MAG": 2, "DEF": 7, "SPD": 4},
|
||||||
|
"MOTH": {"HP": 28, "SP": 16, "ATK": 3, "MAG": 8, "DEF": 3, "SPD": 6},
|
||||||
|
"VERA": {"HP": 31, "SP": 12, "ATK": 5, "MAG": 6, "DEF": 4, "SPD": 6},
|
||||||
|
"CANTOR": {"HP": 40, "SP": 12, "ATK": 8, "MAG": 6, "DEF": 6, "SPD": 2},
|
||||||
|
"NYX": {"HP": 22, "SP": 12, "ATK": 9, "MAG": 5, "DEF": 2, "SPD": 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _const(g):
|
||||||
|
return [dict(g) for _ in range(7)]
|
||||||
|
|
||||||
|
|
||||||
|
def _alt(base, extra):
|
||||||
|
"""7 growth dicts. Even reached-levels (2,4,6,8 -> index 0,2,4,6) get the
|
||||||
|
`extra` +1s on top of `base`; odd levels get only `base`."""
|
||||||
|
out = []
|
||||||
|
for i in range(7):
|
||||||
|
d = dict(base)
|
||||||
|
if i % 2 == 0:
|
||||||
|
for k, v in extra.items():
|
||||||
|
d[k] = d.get(k, 0) + v
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- per-level growth
|
||||||
|
GROWTH = {
|
||||||
|
"PIP": _const({"HP": 6, "SP": 2, "ATK": 2, "DEF": 1, "SPD": 1}),
|
||||||
|
"BRAMBLE": _const({"HP": 9, "SP": 1, "ATK": 1, "DEF": 2}),
|
||||||
|
"MOTH": _alt({"HP": 4, "SP": 4, "MAG": 2}, {"DEF": 1, "SPD": 1}),
|
||||||
|
"VERA": _alt({"HP": 5, "SP": 3}, {"ATK": 1, "MAG": 1, "DEF": 1, "SPD": 1}),
|
||||||
|
"CANTOR": _const({"HP": 7, "SP": 2, "ATK": 2, "MAG": 1, "DEF": 1}),
|
||||||
|
"NYX": _const({"HP": 3, "SP": 3, "ATK": 3, "SPD": 1}),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- crit base (%/100)
|
||||||
|
CRIT_BASE = {
|
||||||
|
"PIP": 0.06, "BRAMBLE": 0.06, "MOTH": 0.06,
|
||||||
|
"VERA": 0.06, "CANTOR": 0.06, "NYX": 0.18,
|
||||||
|
}
|
||||||
|
|
||||||
|
# NYX passive evasion (SYSTEMS section 3)
|
||||||
|
NYX_EVASION = 0.20
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ SKILLS
|
||||||
|
# Each skill: id, name, sp, unlock (skill A = 1, skill B = 4), desc for the
|
||||||
|
# submenu, and mechanical fields the battle engine dispatches on via "special".
|
||||||
|
# kind: phys | magic | heal | buff | selfbuff
|
||||||
|
# target: enemy | all_enemies | ally | party | self
|
||||||
|
# mult: damage/heal multiplier (semantics per special)
|
||||||
|
# hits: number of strikes (phys)
|
||||||
|
# debuff: {"stat","mult","turns"} applied to target(s)
|
||||||
|
SKILLS = {
|
||||||
|
"PIP": [
|
||||||
|
{"id": "twinstrike", "name": "Twinstrike", "sp": 3, "unlock": 1,
|
||||||
|
"kind": "phys", "target": "enemy", "mult": 0.7, "hits": 2,
|
||||||
|
"special": "twinstrike",
|
||||||
|
"desc": "Two quick hits at 0.7x each."},
|
||||||
|
{"id": "pickpocket", "name": "Pickpocket", "sp": 2, "unlock": 4,
|
||||||
|
"kind": "phys", "target": "enemy", "mult": 0.6, "hits": 1,
|
||||||
|
"special": "pickpocket",
|
||||||
|
"desc": "0.6x hit; steal 8-20 cogs, once per foe."},
|
||||||
|
],
|
||||||
|
"BRAMBLE": [
|
||||||
|
{"id": "shieldwall", "name": "Shieldwall", "sp": 3, "unlock": 1,
|
||||||
|
"kind": "buff", "target": "party", "special": "shieldwall",
|
||||||
|
"desc": "Party takes half damage until Bramble's next turn."},
|
||||||
|
{"id": "vow_strike", "name": "Vow Strike", "sp": 4, "unlock": 4,
|
||||||
|
"kind": "phys", "target": "enemy", "mult": 1.0, "hits": 1,
|
||||||
|
"special": "vow_strike",
|
||||||
|
"desc": "Grows stronger the more HP Bramble is missing."},
|
||||||
|
],
|
||||||
|
"MOTH": [
|
||||||
|
{"id": "kindle", "name": "Kindle", "sp": 4, "unlock": 1,
|
||||||
|
"kind": "heal", "target": "ally", "special": "kindle",
|
||||||
|
"heals_nyx": True,
|
||||||
|
"desc": "Heal one ally 24 + 2*MAG. Works on Nyx."},
|
||||||
|
{"id": "moonflare", "name": "Moonflare", "sp": 6, "unlock": 4,
|
||||||
|
"kind": "magic", "target": "all_enemies", "mult": 1.1,
|
||||||
|
"special": "moonflare",
|
||||||
|
"desc": "Radiant AoE, 1.1x magic to all foes."},
|
||||||
|
],
|
||||||
|
"VERA": [
|
||||||
|
{"id": "acid_flask", "name": "Acid Flask", "sp": 4, "unlock": 1,
|
||||||
|
"kind": "magic", "target": "all_enemies", "mult": 0.7,
|
||||||
|
"special": "acid_flask",
|
||||||
|
"debuff": {"stat": "DEF", "mult": -0.25, "turns": 2},
|
||||||
|
"desc": "AoE 0.7x and DEF -25% (2 turns)."},
|
||||||
|
{"id": "tonic_toss", "name": "Tonic Toss", "sp": 3, "unlock": 4,
|
||||||
|
"kind": "heal", "target": "ally", "special": "tonic_toss",
|
||||||
|
"heals_nyx": False,
|
||||||
|
"desc": "Heal one 16 + MAG, clear debuffs. Not Nyx."},
|
||||||
|
],
|
||||||
|
"CANTOR": [
|
||||||
|
{"id": "resonate", "name": "Resonate", "sp": 4, "unlock": 1,
|
||||||
|
"kind": "magic", "target": "enemy", "mult": 1.2,
|
||||||
|
"special": "resonate",
|
||||||
|
"desc": "1.2x magic; stuns constructs/undead 1 turn."},
|
||||||
|
{"id": "bass_drop", "name": "Bass Drop", "sp": 7, "unlock": 4,
|
||||||
|
"kind": "phys", "target": "enemy", "mult": 2.2, "hits": 1,
|
||||||
|
"special": "bass_drop",
|
||||||
|
"desc": "Massive 2.2x single hit; acts last this round."},
|
||||||
|
],
|
||||||
|
"NYX": [
|
||||||
|
{"id": "grave_chill", "name": "Grave Chill", "sp": 3, "unlock": 1,
|
||||||
|
"kind": "magic", "target": "enemy", "mult": 1.0,
|
||||||
|
"special": "grave_chill",
|
||||||
|
"debuff": {"stat": "SPD", "mult": -0.30, "turns": 2},
|
||||||
|
"desc": "1.0x magic and SPD -30% (2 turns)."},
|
||||||
|
{"id": "second_shadow", "name": "Second Shadow", "sp": 5, "unlock": 4,
|
||||||
|
"kind": "selfbuff", "target": "self", "special": "second_shadow",
|
||||||
|
"desc": "Her next attack is a guaranteed crit."},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def learned_skills(char_id, level):
|
||||||
|
"""Skills available to char_id at `level` (unlock <= level)."""
|
||||||
|
if char_id not in SKILLS:
|
||||||
|
raise KeyError("no skills for %r in data.characters.SKILLS" % (char_id,))
|
||||||
|
return [s for s in SKILLS[char_id] if level >= s["unlock"]]
|
||||||
|
|
||||||
|
|
||||||
|
def skill_by_id(skill_id):
|
||||||
|
for lst in SKILLS.values():
|
||||||
|
for s in lst:
|
||||||
|
if s["id"] == skill_id:
|
||||||
|
return s
|
||||||
|
raise KeyError("unknown skill id %r" % (skill_id,))
|
||||||
101
games/unwritten/data/enemies.py
Normal file
101
games/unwritten/data/enemies.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""UNWRITTEN - enemy tables and encounter packs. Owner: Agent B.
|
||||||
|
|
||||||
|
Numbers canon from SYSTEMS section 4; boss behavior from BIBLE section 4.
|
||||||
|
Area/variant textures (Pale Echo, Chime-Imp, Grey Echo) are hue/sat-shifted
|
||||||
|
copies of the shared atlas, built ONCE here at import via TEX.hsl_shift.
|
||||||
|
|
||||||
|
ENEMIES : id -> record used by systems.battle to build combatants.
|
||||||
|
PACKS : pack_id -> list of enemy ids (order = left-to-right on screen).
|
||||||
|
ASCII only. "Fail early": unknown ids raise.
|
||||||
|
"""
|
||||||
|
from core.assets import TEX
|
||||||
|
|
||||||
|
# ------------------------------------------------- variant textures (built once)
|
||||||
|
# Pale Echo: sprite 121 hue -40 deg (hsl_shift takes [0,360) so -40 -> 320).
|
||||||
|
PALE_ECHO_TEX = TEX.hsl_shift(320.0, 0.0, 0.0)
|
||||||
|
# Chime-Imp: sprite 110 hue +160 deg (teal).
|
||||||
|
CHIME_TEX = TEX.hsl_shift(160.0, 0.0, 0.0)
|
||||||
|
# Grey Echo (Custodian summon): starter sprite 85, drained of color.
|
||||||
|
GREY_ECHO_TEX = TEX.hsl_shift(0.0, -0.85, 0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def _e(name, sprite, hp, atk, mag, dfn, spd, xp, gold, ai="basic",
|
||||||
|
labels=None, tex=None, scale=4.0, drop=None):
|
||||||
|
return {
|
||||||
|
"name": name, "sprite": sprite,
|
||||||
|
"HP": hp, "ATK": atk, "MAG": mag, "DEF": dfn, "SPD": spd,
|
||||||
|
"XP": xp, "gold": gold, "ai": ai,
|
||||||
|
"labels": set(labels or ()), "tex": tex, "scale": scale, "drop": drop,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ENEMIES = {
|
||||||
|
"gloom_bat": _e("Gloom Bat", 120, 14, 5, 0, 1, 8, 6, 4, ai="bat"),
|
||||||
|
"hedge_slime": _e("Hedge Slime", 108, 20, 4, 0, 3, 2, 7, 5, ai="slime"),
|
||||||
|
"slimelet": _e("Slimelet", 108, 8, 3, 0, 1, 3, 0, 0, ai="basic",
|
||||||
|
scale=3.0),
|
||||||
|
"loom_spider": _e("Loom Spider", 122, 16, 6, 0, 2, 6, 8, 6, ai="spider"),
|
||||||
|
"waxwing_scarab": _e("Waxwing Scarab", 123, 24, 6, 0, 5, 3, 10, 8,
|
||||||
|
ai="scarab"),
|
||||||
|
"unwoken_skeleton": _e("Unwoken Skeleton", 86, 26, 8, 0, 3, 5, 13, 10,
|
||||||
|
ai="skeleton", labels=("undead",)),
|
||||||
|
"pale_echo": _e("Pale Echo", 121, 18, 0, 8, 1, 7, 12, 0, ai="echo",
|
||||||
|
tex=PALE_ECHO_TEX,
|
||||||
|
drop=("chalk", 0.40)),
|
||||||
|
"chime_imp": _e("Chime-Imp", 110, 22, 7, 4, 3, 7, 14, 12, ai="chime",
|
||||||
|
labels=("construct",), tex=CHIME_TEX),
|
||||||
|
"mimic": _e("Mimic", 92, 45, 10, 0, 6, 1, 30, 60, ai="mimic"),
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- bosses
|
||||||
|
"gatekeeper": _e("The Gatekeeper", 20, 150, 11, 6, 8, 3, 80, 100,
|
||||||
|
ai="gatekeeper", labels=("construct",), scale=7.0),
|
||||||
|
"custodian": _e("The Custodian", 41, 210, 9, 12, 7, 6, 0, 0,
|
||||||
|
ai="custodian", labels=("construct",), scale=8.0),
|
||||||
|
|
||||||
|
# -------------------------------------------------------- boss summons/misc
|
||||||
|
# Bell: Chime-Imp stats but the base (red) sprite, unshifted.
|
||||||
|
"bell": _e("Bell", 110, 22, 7, 4, 3, 7, 0, 0, ai="chime",
|
||||||
|
labels=("construct",)),
|
||||||
|
# Grey Echo: Pale Echo stats +50%, desaturated starter sprite.
|
||||||
|
"grey_echo": _e("Grey Echo", 85, 27, 0, 12, 2, 7, 0, 0, ai="echo",
|
||||||
|
labels=("construct",), tex=GREY_ECHO_TEX),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ packs
|
||||||
|
PACKS = {
|
||||||
|
# Gearwood (Act 1): soft encounters. gw_slimes is size 2 so slimes split.
|
||||||
|
"gw_bats": ["gloom_bat", "gloom_bat", "gloom_bat"],
|
||||||
|
"gw_mixed": ["gloom_bat", "hedge_slime", "loom_spider"],
|
||||||
|
"gw_mixed2": ["loom_spider", "gloom_bat", "hedge_slime"],
|
||||||
|
"gw_scarabs": ["waxwing_scarab", "waxwing_scarab"],
|
||||||
|
"gw_slimes": ["hedge_slime", "hedge_slime"],
|
||||||
|
|
||||||
|
# Undercroft (Act 2): proper dungeon. uc_mimic is solo.
|
||||||
|
"uc_bones": ["unwoken_skeleton", "unwoken_skeleton"],
|
||||||
|
"uc_bones2": ["unwoken_skeleton", "unwoken_skeleton", "waxwing_scarab"],
|
||||||
|
"uc_chimes": ["chime_imp", "chime_imp"],
|
||||||
|
"uc_echoes": ["pale_echo", "pale_echo", "unwoken_skeleton"],
|
||||||
|
"uc_mixed": ["unwoken_skeleton", "pale_echo", "waxwing_scarab"],
|
||||||
|
"uc_mimic": ["mimic"],
|
||||||
|
|
||||||
|
# Act 3 Study approach: pale echoes only (the world is running out).
|
||||||
|
"st_echoes": ["pale_echo", "pale_echo"],
|
||||||
|
"st_echoes2": ["pale_echo", "pale_echo", "pale_echo"],
|
||||||
|
|
||||||
|
# Bosses (BIBLE section 4). Gatekeeper flanked by two Chime-Imps.
|
||||||
|
"boss_gatekeeper": ["chime_imp", "gatekeeper", "chime_imp"],
|
||||||
|
"boss_custodian": ["custodian"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pack(pack_id):
|
||||||
|
if pack_id not in PACKS:
|
||||||
|
raise KeyError("unknown battle pack %r in data.enemies.PACKS" % (pack_id,))
|
||||||
|
return list(PACKS[pack_id])
|
||||||
|
|
||||||
|
|
||||||
|
def enemy(enemy_id):
|
||||||
|
if enemy_id not in ENEMIES:
|
||||||
|
raise KeyError("unknown enemy id %r in data.enemies.ENEMIES" % (enemy_id,))
|
||||||
|
return ENEMIES[enemy_id]
|
||||||
93
games/unwritten/data/epilogue.py
Normal file
93
games/unwritten/data/epilogue.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""UNWRITTEN - epilogue pages. Authored by Fable.
|
||||||
|
|
||||||
|
The epilogue is the Book read aloud. Full-screen INK panels, centered PARCH
|
||||||
|
text, GOLD page ornament, one keypress per page. PAGES is ordered; each entry
|
||||||
|
is (condition, text). condition: None = always shown, or a req tuple in the
|
||||||
|
dialogue schema (("flag", x) / ("flag_not", x) / ("points", n)). The renderer
|
||||||
|
shows every page whose condition passes. {light_name} is substituted from the
|
||||||
|
moth_light_name_* flag: dark -> "for the ones walking in the dark",
|
||||||
|
lost -> "for everything the vale lost waiting", small -> "for suppers, and
|
||||||
|
mending, and company"."""
|
||||||
|
|
||||||
|
PAGES = [
|
||||||
|
(None,
|
||||||
|
"This is the story of the vale,\nas it actually happened."),
|
||||||
|
|
||||||
|
(None,
|
||||||
|
"There was a door that was only ever a wall\nwith better manners. "
|
||||||
|
"Someone decided\nit was for going through.\nSo it was."),
|
||||||
|
|
||||||
|
(("flag", "quill_confident"),
|
||||||
|
"The mayor of Hollowbrook said what a door was for,\nout loud, in his "
|
||||||
|
"own words.\nHe has scheduled the Founding Festival.\nThe speech runs "
|
||||||
|
"long. Nobody minds."),
|
||||||
|
|
||||||
|
(("flag", "quill_dependent"),
|
||||||
|
"The mayor of Hollowbrook waits, still,\nfor someone to say when. "
|
||||||
|
"You say when.\nHe beams every time.\nThat is also a way to be a "
|
||||||
|
"mayor,\nand the town loves him anyway."),
|
||||||
|
|
||||||
|
(("flag", "vera_joined_early"),
|
||||||
|
"A merchant sold possibility\nin a town that had none.\nShe was not "
|
||||||
|
"wrong. She was early.\nThe difference was one believer."),
|
||||||
|
|
||||||
|
(("flag", "vera_reconciled"),
|
||||||
|
"Two shopkeepers quarreled for forty years\nabout whether hope keeps.\n"
|
||||||
|
"It keeps. It kept.\nThe ledger had a column waiting all along."),
|
||||||
|
|
||||||
|
(("flag", "vera_grudge"),
|
||||||
|
"Somewhere a merchant still waits\nto collect an apology with "
|
||||||
|
"interest.\nThe vale has time now. Real time,\nthe kind that goes "
|
||||||
|
"somewhere.\nShe'll collect."),
|
||||||
|
|
||||||
|
(("flag", "moth_joined"),
|
||||||
|
"A brazier that waited three hundred years\nwas lit at last -\n"
|
||||||
|
"{light_name}.\nIt has not gone out.\nIt is not going to."),
|
||||||
|
|
||||||
|
(("flag", "cantor_joined"),
|
||||||
|
"A fanfare built for the beginning\nplayed in the middle instead.\n"
|
||||||
|
"The middle, it turns out,\nis a strong place for brass."),
|
||||||
|
|
||||||
|
(("flag", "cantor_quiet"),
|
||||||
|
"He was woken without a name,\nby a knock instead of a word.\nHe "
|
||||||
|
"chose his own, later,\nand tells nobody.\nIt is a very good name."),
|
||||||
|
|
||||||
|
(("flag", "gatekeeper_relieved"),
|
||||||
|
"An old guard was formally relieved of duty\nby a knight who knew "
|
||||||
|
"what the words cost.\nIt sleeps now, off schedule,\nand the deep "
|
||||||
|
"vale breathes."),
|
||||||
|
|
||||||
|
(("flag", "rewoke_quill"),
|
||||||
|
"When the world was smoothed flat,\nthe people were woken\nnot by "
|
||||||
|
"magic, but by particulars:\na thing only they had said,\na debt "
|
||||||
|
"only they remembered,\na note only they were built to hold."),
|
||||||
|
|
||||||
|
(("flag", "odd_promised"),
|
||||||
|
"The ferryman has a second route now.\nIt is written on the hull\n"
|
||||||
|
"in a hand that is not the Maker's,\nand the water - he reports -\n"
|
||||||
|
"is showing signs of interest."),
|
||||||
|
|
||||||
|
(("flag", "bell_defected"),
|
||||||
|
"A herald spent three centuries\nannouncing silence,\nthen rang "
|
||||||
|
"itself backward\nand chose a side.\nIt has SUCH a backlog."),
|
||||||
|
|
||||||
|
(("flag", "nyx_joined"),
|
||||||
|
"And there was someone\nwho was never anyone's later.\nShe stepped "
|
||||||
|
"off the empty plinth\nand the plinth is empty on purpose now,\n"
|
||||||
|
"which is a different thing entirely."),
|
||||||
|
|
||||||
|
(("flag", "nyx_missed"),
|
||||||
|
"There was someone else, almost.\nLook for her, next time.\nShe "
|
||||||
|
"waits by the fountain,\nat the empty spot,\nand now you know to "
|
||||||
|
"look."),
|
||||||
|
|
||||||
|
(("flag", "book_read_custodian"),
|
||||||
|
"The margin read the pages\nand chose to be a bookmark instead."),
|
||||||
|
|
||||||
|
(None,
|
||||||
|
"The Maker built the vale.\nYou were what happened."),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Final card: the title UNWRITTEN, then the word UNWRITTEN gets struck
|
||||||
|
# through / replaced by WRITTEN stamped in GOLD. Hold on it. Roll nothing.
|
||||||
|
# There are no credits. The Book just closes.
|
||||||
100
games/unwritten/data/items.py
Normal file
100
games/unwritten/data/items.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""UNWRITTEN - items and equipment. Owner: Agent B.
|
||||||
|
|
||||||
|
Numbers canon from SYSTEMS section 5. Three views over one source of truth:
|
||||||
|
ITEMS : id -> full record (name, sprite, cost, kind, effect/equip fields)
|
||||||
|
EQUIP : id -> stat-delta dict (read by systems.state.CharState)
|
||||||
|
SHOP_STOCK : act -> list of item ids Griselda stocks
|
||||||
|
|
||||||
|
Gold is called "cogs" in prose (BIBLE). "Fail early": unknown ids raise.
|
||||||
|
ASCII only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# kind values: "consumable", "weapon", "trinket", "key"
|
||||||
|
|
||||||
|
ITEMS = {
|
||||||
|
# --------------------------------------------------------- consumables
|
||||||
|
"bread": {"name": "Bread", "sprite": 66, "cost": 8, "kind": "consumable",
|
||||||
|
"effect": "heal", "amount": 12,
|
||||||
|
"desc": "Restores 12 HP."},
|
||||||
|
"red_tonic": {"name": "Red Tonic", "sprite": 115, "cost": 20,
|
||||||
|
"kind": "consumable", "effect": "heal", "amount": 30,
|
||||||
|
"desc": "Restores 30 HP."},
|
||||||
|
"green_salve": {"name": "Green Salve", "sprite": 114, "cost": 25,
|
||||||
|
"kind": "consumable", "effect": "cleanse_heal", "amount": 10,
|
||||||
|
"desc": "Clears debuffs and restores 10 HP."},
|
||||||
|
"blue_draught": {"name": "Blue Draught", "sprite": 116, "cost": 30,
|
||||||
|
"kind": "consumable", "effect": "sp", "amount": 15,
|
||||||
|
"desc": "Restores 15 SP."},
|
||||||
|
"bomb_flask": {"name": "Bomb Flask", "sprite": 113, "cost": 35,
|
||||||
|
"kind": "consumable", "effect": "aoe_damage", "amount": 22,
|
||||||
|
"target": "all_enemies",
|
||||||
|
"desc": "22 damage to all foes. Anyone can throw it."},
|
||||||
|
"chalk": {"name": "Chalk", "sprite": 102, "cost": 90, "kind": "consumable",
|
||||||
|
"effect": "revive", "amount": 0.40, "shop_limit": 1,
|
||||||
|
"desc": "Revive a fallen ally at 40% HP."},
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- weapons
|
||||||
|
"knife": {"name": "Knife", "sprite": 105, "cost": 15, "kind": "weapon",
|
||||||
|
"equip": {"ATK": 2}, "desc": "+2 ATK. Starter tier."},
|
||||||
|
"short_sword": {"name": "Short Sword", "sprite": 103, "cost": 40,
|
||||||
|
"kind": "weapon", "equip": {"ATK": 4}, "desc": "+4 ATK."},
|
||||||
|
"broad_blade": {"name": "Broad Blade", "sprite": 106, "cost": 90,
|
||||||
|
"kind": "weapon", "equip": {"ATK": 7},
|
||||||
|
"desc": "+7 ATK. Act 2 unlock."},
|
||||||
|
"worn_cudgel": {"name": "Worn Cudgel", "sprite": 117, "cost": 15,
|
||||||
|
"kind": "weapon", "equip": {"ATK": 2},
|
||||||
|
"desc": "+2 ATK. Bramble/Cantor flavor."},
|
||||||
|
"twin_axe": {"name": "Twin Axe", "sprite": 119, "cost": 90, "kind": "weapon",
|
||||||
|
"equip": {"ATK": 6, "SPD": 2}, "desc": "+6 ATK, +2 SPD."},
|
||||||
|
"wick_staff": {"name": "Wick Staff", "sprite": 129, "cost": 45,
|
||||||
|
"kind": "weapon", "equip": {"MAG": 4}, "desc": "+4 MAG."},
|
||||||
|
"tide_rod": {"name": "Tide Rod", "sprite": 130, "cost": 95, "kind": "weapon",
|
||||||
|
"equip": {"MAG": 7}, "desc": "+7 MAG. Act 2 unlock."},
|
||||||
|
"toothed_regret": {"name": "Toothed Regret", "sprite": 118, "cost": 0,
|
||||||
|
"kind": "weapon", "equip": {"ATK": 10, "MAG": 6},
|
||||||
|
"desc": "+10 ATK, +6 MAG. Griselda's reforge."},
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- trinkets
|
||||||
|
"river_stone": {"name": "River Stone", "sprite": 101, "cost": 25,
|
||||||
|
"kind": "trinket", "equip": {"DEF": 2}, "desc": "+2 DEF."},
|
||||||
|
"waxed_charm": {"name": "Waxed Charm", "sprite": 56, "cost": 60,
|
||||||
|
"kind": "trinket", "equip": {"DEF": 3, "HP": 5},
|
||||||
|
"desc": "+3 DEF, +5 max HP."},
|
||||||
|
"second_trinket": {"name": "Second Trinket", "sprite": 91, "cost": 0,
|
||||||
|
"kind": "trinket",
|
||||||
|
"equip": {"HP": 2, "SP": 2, "ATK": 2, "MAG": 2,
|
||||||
|
"DEF": 2, "SPD": 2},
|
||||||
|
"desc": "+2 to all stats. The name is the joke."},
|
||||||
|
"gatekeepers_tooth": {"name": "Gatekeeper's Tooth", "sprite": 107,
|
||||||
|
"cost": 0, "kind": "trinket", "equip": {"ATK": 3},
|
||||||
|
"desc": "+3 ATK until reforged."},
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- key items
|
||||||
|
"blank_book": {"name": "Blank Book", "sprite": 94, "cost": 0, "kind": "key",
|
||||||
|
"desc": "The book that was not there yesterday."},
|
||||||
|
"brass_key": {"name": "Brass Key", "sprite": 104, "cost": 0, "kind": "key",
|
||||||
|
"desc": "Opens the Undercroft stair."},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------- stat-delta view for state
|
||||||
|
EQUIP = {iid: rec["equip"] for iid, rec in ITEMS.items() if "equip" in rec}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- shop stock
|
||||||
|
# Act 1: sub-90-cog tier only. Act 2 unlocks the 90+ gear. Act 3 = Act 2 stock.
|
||||||
|
_ACT1 = ["knife", "short_sword", "worn_cudgel", "wick_staff",
|
||||||
|
"river_stone", "waxed_charm",
|
||||||
|
"bread", "red_tonic", "green_salve", "blue_draught", "bomb_flask",
|
||||||
|
"chalk"]
|
||||||
|
_ACT2 = _ACT1 + ["broad_blade", "twin_axe", "tide_rod"]
|
||||||
|
|
||||||
|
SHOP_STOCK = {1: list(_ACT1), 2: list(_ACT2), 3: list(_ACT2)}
|
||||||
|
|
||||||
|
|
||||||
|
def item(item_id):
|
||||||
|
if item_id not in ITEMS:
|
||||||
|
raise KeyError("unknown item id %r in data.items.ITEMS" % (item_id,))
|
||||||
|
return ITEMS[item_id]
|
||||||
|
|
||||||
|
|
||||||
|
def is_consumable(item_id):
|
||||||
|
return ITEMS.get(item_id, {}).get("kind") == "consumable"
|
||||||
235
games/unwritten/data/maps.py
Normal file
235
games/unwritten/data/maps.py
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
"""UNWRITTEN - area maps and placements. Authored by Fable; layouts are canon.
|
||||||
|
|
||||||
|
Legend (terrain chars -> walkability + ColorLayer color, see AREAS palette):
|
||||||
|
'#' wall/building block (unwalkable) '.' ground
|
||||||
|
'~' water (unwalkable) ',' path
|
||||||
|
't' canopy shadow (walkable, darker) ' ' void (unwalkable, INK)
|
||||||
|
|
||||||
|
All coordinates are (x, y) cell positions. Props are decorative sprites placed
|
||||||
|
as entities (non-blocking unless 'block': True). NPCs/encounters reference
|
||||||
|
dialogue scene ids and enemy pack ids. Exits: standing on the cell triggers
|
||||||
|
transition (optional req flag; 'locked_text' scene plays if unmet).
|
||||||
|
"""
|
||||||
|
|
||||||
|
HOLLOWBROOK = [
|
||||||
|
"########################",
|
||||||
|
"#~~~~~~~~~~~~~~~~~~~~~~#",
|
||||||
|
"#........,,,,..........#",
|
||||||
|
"###########,############",
|
||||||
|
"#....##....,,....##....#",
|
||||||
|
"#.####.....,,....####..#",
|
||||||
|
"#.#..#..,,,,,,,,.#..#..#",
|
||||||
|
"#.#..,..,......,.,..#..#",
|
||||||
|
"#.####..,......,.####..#",
|
||||||
|
"#....,..,......,..,....#",
|
||||||
|
"#....,..,,,,,,,,..,....#",
|
||||||
|
"#....,......,......,...#",
|
||||||
|
"#..,,,,,,,,,,,,,,,,,,..#",
|
||||||
|
"#......................#",
|
||||||
|
"#......................#",
|
||||||
|
"########################",
|
||||||
|
]
|
||||||
|
|
||||||
|
GEARWOOD = [
|
||||||
|
"##########################",
|
||||||
|
"#tttt..........ttttttttt.#",
|
||||||
|
"#tt....,,,,.......tttttt.#",
|
||||||
|
"#t..####,..,,......ttttt.#",
|
||||||
|
"#...#..#,....,,....ttt...#",
|
||||||
|
"#...#..,........,....t...#",
|
||||||
|
"#...####..tt.....,.......#",
|
||||||
|
"#.....t...tt......,......#",
|
||||||
|
"#..t......,,,......,.....#",
|
||||||
|
"#..tt....,...,......,....#",
|
||||||
|
"#...t...,.....,......,...#",
|
||||||
|
"#......,.......,....###..#",
|
||||||
|
"#.....,.........,...#.#..#",
|
||||||
|
"#....,...........,,,,.#..#",
|
||||||
|
"#tt..,.............###...#",
|
||||||
|
"#ttt.,,,,,,..............#",
|
||||||
|
"#ttttt.....,.....tttttt..#",
|
||||||
|
"##########################",
|
||||||
|
]
|
||||||
|
|
||||||
|
UNDERCROFT = [
|
||||||
|
"##########################",
|
||||||
|
"#........#####...........#",
|
||||||
|
"#........................#",
|
||||||
|
"#........#####...........#",
|
||||||
|
"#........#####...........#",
|
||||||
|
"#........#####...........#",
|
||||||
|
"#........##########.######",
|
||||||
|
"###.####..#########.######",
|
||||||
|
"###.####............######",
|
||||||
|
"##................########",
|
||||||
|
"##................########",
|
||||||
|
"##.......#........########",
|
||||||
|
"##.......#..............##",
|
||||||
|
"##.......#..............##",
|
||||||
|
"##.....######.##........##",
|
||||||
|
"##.....######.##........##",
|
||||||
|
"##.....####.#.##........##",
|
||||||
|
"##.....####.#.#####.######",
|
||||||
|
"########...,,,,,....######",
|
||||||
|
"##########################",
|
||||||
|
]
|
||||||
|
|
||||||
|
STUDY = [
|
||||||
|
"##################",
|
||||||
|
"#................#",
|
||||||
|
"#................#",
|
||||||
|
"#......####......#",
|
||||||
|
"#................#",
|
||||||
|
"#................#",
|
||||||
|
"#....#......#....#",
|
||||||
|
"#................#",
|
||||||
|
"#................#",
|
||||||
|
"#................#",
|
||||||
|
"#.......,........#",
|
||||||
|
"##################",
|
||||||
|
]
|
||||||
|
|
||||||
|
AREAS = {
|
||||||
|
"hollowbrook": {
|
||||||
|
"map": HOLLOWBROOK,
|
||||||
|
"banner": "HOLLOWBROOK",
|
||||||
|
"banner_grey": "HOLLOWBROOK, REVERTED",
|
||||||
|
"palette": {
|
||||||
|
".": (54, 44, 34), ",": (84, 66, 44), "~": (38, 66, 86),
|
||||||
|
"#": (30, 32, 42), "t": (44, 38, 30),
|
||||||
|
},
|
||||||
|
# door cell: the famous North Door at (11,3); walkable only when flag set
|
||||||
|
"door_cells": [{"pos": (11, 3), "flag": "door_open",
|
||||||
|
"locked_scene": "door_shut"}],
|
||||||
|
"props": [
|
||||||
|
{"pos": (11, 8), "sprite": 7, "id": "fountain"}, # dry fountain (8 when rewoken)
|
||||||
|
{"pos": (13, 8), "sprite": None, "id": "plinth", # empty plinth: no sprite,
|
||||||
|
"scene": "plinth_look"}, # a GOLD outline cell + interact
|
||||||
|
{"pos": (4, 8), "sprite": 46, "id": "shop_door", "scene": "enter_shop"},
|
||||||
|
{"pos": (18, 7), "sprite": 45, "id": "mayor_door"},
|
||||||
|
{"pos": (3, 9), "sprite": 74, "id": "anvil"},
|
||||||
|
{"pos": (2, 13), "sprite": 82, "id": "barrel1"},
|
||||||
|
{"pos": (21, 13),"sprite": 63, "id": "crate1"},
|
||||||
|
{"pos": (8, 11), "sprite": 72, "id": "vera_stall"},
|
||||||
|
],
|
||||||
|
"npcs": [
|
||||||
|
{"id": "QUILL", "pos": (18, 9), "sprite": 100,
|
||||||
|
"scene": {1: "quill_first", 3: "rewake_quill"}},
|
||||||
|
{"id": "GRISELDA","pos": (4, 9), "sprite": 88,
|
||||||
|
"scene": {1: "griselda_shop", 3: "rewake_griselda"}},
|
||||||
|
{"id": "VERA", "pos": (8, 10), "sprite": 111,
|
||||||
|
"scene": {1: "shop_dispute"}, "gone_flags": ["vera_joined_early", "vera_reconciled"]},
|
||||||
|
{"id": "BRAMBLE", "pos": (11, 4), "sprite": 96,
|
||||||
|
"scene": {1: "bramble_door"}, "gone_flags": ["bramble_joined"]},
|
||||||
|
{"id": "ODD", "pos": (11, 2), "sprite": 87,
|
||||||
|
"scene": {1: "odd_ferry_1", 3: "rewake_odd"}},
|
||||||
|
{"id": "NYX", "pos": (13, 8), "sprite": 121, "act_min": 3,
|
||||||
|
"scene": {3: "nyx_plinth"}, "gone_flags": ["nyx_joined", "nyx_missed"]},
|
||||||
|
],
|
||||||
|
"encounters": [],
|
||||||
|
"exits": [], # travel happens via Odd (ferry dialogue) and the North Door scene
|
||||||
|
"spawns": {"start": (11, 12), "from_door": (11, 2), "from_ferry": (11, 2)},
|
||||||
|
"camp": {"pos": (11, 9), "scene": "fountain_rest"}, # interact next to fountain
|
||||||
|
},
|
||||||
|
|
||||||
|
"gearwood": {
|
||||||
|
"map": GEARWOOD,
|
||||||
|
"banner": "THE GEARWOOD",
|
||||||
|
"palette": {
|
||||||
|
".": (30, 46, 32), ",": (52, 62, 40), "~": (38, 66, 86),
|
||||||
|
"#": (20, 30, 24), "t": (22, 34, 26),
|
||||||
|
},
|
||||||
|
"props": [
|
||||||
|
{"pos": (5, 4), "sprite": 43, "id": "shrine_brazier", "scene": "moth_shrine"},
|
||||||
|
{"pos": (3, 4), "sprite": 64, "id": "shrine_grave"},
|
||||||
|
{"pos": (6, 5), "sprite": 89, "id": "shrine_chest", "scene": "shrine_chest"},
|
||||||
|
{"pos": (20, 12),"sprite": 33, "id": "stair_door", "scene": "gearwood_stair"},
|
||||||
|
{"pos": (13, 8), "sprite": None, "id": "bell_spot", "scene": "gearwood_bell_1",
|
||||||
|
"auto": True, "once": "bell_1_done"}, # auto-trigger walking within 1 cell
|
||||||
|
{"pos": (2, 15), "sprite": 76, "id": "fence1"},
|
||||||
|
{"pos": (3, 15), "sprite": 77, "id": "fence2"},
|
||||||
|
],
|
||||||
|
"npcs": [
|
||||||
|
{"id": "VERA", "pos": (19, 13), "sprite": 111, "act_min": 2,
|
||||||
|
"scene": {2: "vera_reconcile"},
|
||||||
|
"need_flags": ["vera_grudge"], "gone_flags": ["vera_reconciled"]},
|
||||||
|
],
|
||||||
|
"encounters": [
|
||||||
|
{"pos": (10, 3), "pack": "gw_bats"},
|
||||||
|
{"pos": (16, 6), "pack": "gw_mixed"},
|
||||||
|
{"pos": (8, 12), "pack": "gw_slimes"},
|
||||||
|
{"pos": (18, 15), "pack": "gw_mixed2"},
|
||||||
|
{"pos": (7, 5), "pack": "gw_scarabs", "leash": 0}, # shrine chest guards
|
||||||
|
],
|
||||||
|
"exits": [{"pos": (13, 16), "to": "hollowbrook", "spawn": "from_ferry",
|
||||||
|
"scene": "odd_ferry_back"}],
|
||||||
|
"spawns": {"from_ferry": (13, 15), "from_undercroft": (20, 13)},
|
||||||
|
"camp": {"pos": (5, 5), "scene": "shrine_rest", "need_flags": ["moth_joined"]},
|
||||||
|
},
|
||||||
|
|
||||||
|
"undercroft": {
|
||||||
|
"map": UNDERCROFT,
|
||||||
|
"banner": "THE UNDERCROFT",
|
||||||
|
"palette": {
|
||||||
|
".": (18, 20, 30), ",": (28, 30, 44), "~": (16, 24, 40),
|
||||||
|
"#": (10, 11, 18), "t": (14, 16, 24),
|
||||||
|
},
|
||||||
|
"fov": True, "fov_radius": 7,
|
||||||
|
"props": [
|
||||||
|
{"pos": (13, 10), "sprite": 109, "id": "cantor_dormant",
|
||||||
|
"scene": "cantor_wake", "gone_flags": ["cantor_joined"]},
|
||||||
|
{"pos": (5, 15), "sprite": 89, "id": "chest_a", "scene": "chest_a"},
|
||||||
|
{"pos": (17, 4), "sprite": 90, "id": "chest_b", "scene": "chest_b"},
|
||||||
|
{"pos": (9, 8), "sprite": 89, "id": "mimic_chest", "scene": "mimic_chest",
|
||||||
|
"gone_flags": ["mimic_slain"]},
|
||||||
|
{"pos": (13, 18), "sprite": 41, "id": "boss_door", "scene": "gatekeeper_pre",
|
||||||
|
"gone_flags": ["gatekeeper_slain"]},
|
||||||
|
{"pos": (3, 5), "sprite": 65, "id": "grave_a"},
|
||||||
|
{"pos": (23, 15), "sprite": 64, "id": "grave_b"},
|
||||||
|
{"pos": (12, 9), "sprite": None, "id": "nyx_sketch", "scene": "nyx_sketch"},
|
||||||
|
],
|
||||||
|
"npcs": [],
|
||||||
|
"encounters": [
|
||||||
|
{"pos": (7, 3), "pack": "uc_bones"},
|
||||||
|
{"pos": (19, 8), "pack": "uc_echoes"},
|
||||||
|
{"pos": (5, 11), "pack": "uc_mixed"},
|
||||||
|
{"pos": (18, 13), "pack": "uc_bones2"},
|
||||||
|
{"pos": (11, 16), "pack": "uc_chimes", "leash": 1},
|
||||||
|
],
|
||||||
|
"exits": [{"pos": (3, 1), "to": "gearwood", "spawn": "from_undercroft"}],
|
||||||
|
"spawns": {"enter": (3, 2)},
|
||||||
|
"camp": None,
|
||||||
|
},
|
||||||
|
|
||||||
|
"study": {
|
||||||
|
"map": STUDY,
|
||||||
|
"banner": "THE MAKER'S STUDY",
|
||||||
|
"palette": {
|
||||||
|
".": (214, 204, 178), ",": (196, 186, 158), "~": (170, 176, 186),
|
||||||
|
"#": (28, 26, 24), "t": (188, 178, 152),
|
||||||
|
},
|
||||||
|
"ink_world": True, # UI outlines use INK on parchment here
|
||||||
|
"props": [ # desk props sit on the ink block on purpose (furniture)
|
||||||
|
{"pos": (8, 3), "sprite": 72, "id": "desk"},
|
||||||
|
{"pos": (9, 3), "sprite": 75, "id": "desk_books"},
|
||||||
|
{"pos": (9, 4), "sprite": None, "id": "custodian_spot",
|
||||||
|
"scene": "custodian_pre", "auto": True, "once": "custodian_met"},
|
||||||
|
],
|
||||||
|
"npcs": [],
|
||||||
|
"encounters": [
|
||||||
|
{"pos": (4, 8), "pack": "st_echoes"},
|
||||||
|
{"pos": (13, 8), "pack": "st_echoes2"},
|
||||||
|
],
|
||||||
|
"exits": [],
|
||||||
|
"spawns": {"enter": (9, 10)},
|
||||||
|
"camp": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Battle backdrop band colors per area (top->bottom, 6 bands)
|
||||||
|
BATTLE_BACKDROPS = {
|
||||||
|
"hollowbrook": [(30,32,42),(38,36,40),(46,40,36),(54,44,34),(64,52,38),(74,60,42)],
|
||||||
|
"gearwood": [(14,22,18),(18,30,22),(24,38,28),(30,46,32),(38,56,38),(46,64,42)],
|
||||||
|
"undercroft": [(8,9,14),(10,12,20),(14,16,26),(18,20,30),(24,26,38),(30,32,46)],
|
||||||
|
"study": [(230,222,200),(222,212,188),(214,204,178),(202,192,166),(66,60,52),(28,26,24)],
|
||||||
|
}
|
||||||
538
games/unwritten/data/script_act1.py
Normal file
538
games/unwritten/data/script_act1.py
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
"""UNWRITTEN - Act 1 dialogue. Authored by Fable. Text is canon: fix syntax,
|
||||||
|
never rewrite content. Schema: design/ARCHITECTURE.md section 3."""
|
||||||
|
|
||||||
|
SCENES = {
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ opening
|
||||||
|
"intro": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Maker finished the vale on a Tuesday. Every gear meshed. "
|
||||||
|
"Every door hung true. The sun ran on rails.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"Then the Maker sat down to write what should happen here, "
|
||||||
|
"and could not think of a single thing.", "next": "n3"},
|
||||||
|
"n3": {"speaker": "NARRATOR", "text":
|
||||||
|
"That was three hundred years ago. The vale has been waiting "
|
||||||
|
"ever since. It is very good at it now.", "next": "n4"},
|
||||||
|
"n4": {"speaker": "NARRATOR", "text":
|
||||||
|
"This morning, someone in Hollowbrook wakes up holding a book "
|
||||||
|
"that was not there yesterday. The pages are blank. The cover "
|
||||||
|
"is warm, like a windowsill.", "next": "n5"},
|
||||||
|
"n5": {"speaker": "PIP", "text":
|
||||||
|
"...That's new.", "next": "n6"},
|
||||||
|
"n6": {"speaker": "NARRATOR", "text":
|
||||||
|
"Nothing in Hollowbrook has been new for three hundred years. "
|
||||||
|
"Pip decides to ask the mayor about it. And that word - "
|
||||||
|
"DECIDES - hums through the book like a struck string.",
|
||||||
|
"next": "n7"},
|
||||||
|
"n7": {"speaker": "NARRATOR", "text":
|
||||||
|
"Walk with the arrow keys or WASD. Talk by walking into people. "
|
||||||
|
"The Book will keep track of what you choose. It has been "
|
||||||
|
"waiting the longest of all.", "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"plinth_look": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"An empty plinth beside the fountain. There is a smooth patch "
|
||||||
|
"where a name would go. No statue was ever chosen.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Touch the smooth patch.", "next": "n2"},
|
||||||
|
{"label": "Leave it be.", "next": "n3"},
|
||||||
|
]},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"Cold. Waiting. For half a heartbeat you are sure something "
|
||||||
|
"stands there - thin, patient, almost-shaped. Then it is a "
|
||||||
|
"plinth again. The Book hums, softly, like an apology.",
|
||||||
|
"effects": [("flag", "touched_plinth")], "action": "end"},
|
||||||
|
"n3": {"speaker": "NARRATOR", "text":
|
||||||
|
"You leave it be. It is used to that.", "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Mayor Quill
|
||||||
|
"quill_first": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "QUILL", "text":
|
||||||
|
"Oh! A visitor. Well - a resident. Well - Pip. You walked over "
|
||||||
|
"here. On purpose, by the look of it. Nobody walks anywhere on "
|
||||||
|
"purpose. Are you ill?", "next": "n2"},
|
||||||
|
"n2": {"speaker": "PIP", "text":
|
||||||
|
"I woke up holding this book. It was not there yesterday. And "
|
||||||
|
"I keep... deciding things. It's making a sound when I do.",
|
||||||
|
"next": "n3"},
|
||||||
|
"n3": {"speaker": "QUILL", "text":
|
||||||
|
"Deciding. DECIDING. Goodness. I have been rehearsing a speech "
|
||||||
|
"for the Founding Festival for forty years, you know. Never "
|
||||||
|
"given it. Nobody ever DECIDED on a date.", "next": "n4"},
|
||||||
|
"n4": {"speaker": "QUILL", "text":
|
||||||
|
"If the book wants decisions, there is really only one place in "
|
||||||
|
"Hollowbrook that has ever needed one. The North Door.",
|
||||||
|
"next": "n5"},
|
||||||
|
"n5": {"speaker": "QUILL", "text":
|
||||||
|
"It's a very good door. Best in the vale. We are all very proud "
|
||||||
|
"of it, and nobody has ever once been through it. It isn't "
|
||||||
|
"locked. There has simply never been a reason.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Then we open it. Today. I've decided.",
|
||||||
|
"next": "n6",
|
||||||
|
"effects": [("flag", "quill_dependent"), ("points", 1)]},
|
||||||
|
{"label": "What do YOU think we should do, Mayor?",
|
||||||
|
"next": "n7",
|
||||||
|
"effects": [("flag", "quill_confident"), ("points", 1)]},
|
||||||
|
]},
|
||||||
|
"n6": {"speaker": "QUILL", "text":
|
||||||
|
"You've - yes. Yes! Marvelous. Someone else deciding. That is "
|
||||||
|
"my very favorite kind of decision. Ser Bramble guards the "
|
||||||
|
"door. Guarding is all he has, poor fellow. Be kind about it.",
|
||||||
|
"next": "n9"},
|
||||||
|
"n7": {"speaker": "QUILL", "text":
|
||||||
|
"What do I - me? I... hm. I think... I think a door is for "
|
||||||
|
"going through. I have thought so for forty years and never "
|
||||||
|
"said it out loud. Is that allowed? Saying it out loud?",
|
||||||
|
"next": "n8"},
|
||||||
|
"n8": {"speaker": "PIP", "text":
|
||||||
|
"You just did. It sounded like a mayor.", "next": "n9"},
|
||||||
|
"n9": {"speaker": "QUILL", "text":
|
||||||
|
"Ser Bramble guards it. Forty years at his post. Someone wrote "
|
||||||
|
"GUARD on his orders once and nothing since. Talk to him - and "
|
||||||
|
"Pip. Thank you. Whatever this turns out to be.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "One more thing. Did everyone wake up like me?",
|
||||||
|
"next": "n10"},
|
||||||
|
{"label": "[leave] I'll see about the door.",
|
||||||
|
"next": "n_end"},
|
||||||
|
]},
|
||||||
|
"n10": {"speaker": "QUILL", "text":
|
||||||
|
"Like you? No. No, and here is the strange part - some never "
|
||||||
|
"woke at all. There are corners of this town where you feel "
|
||||||
|
"someone OUGHT to be standing, and no one ever is. The plinth "
|
||||||
|
"by the fountain. The empty chair at the dock. The Maker "
|
||||||
|
"sketched more people than were ever finished, I think.",
|
||||||
|
"effects": [("flag", "asked_unwoken"), ("points", 1)],
|
||||||
|
"next": "n11"},
|
||||||
|
"n11": {"speaker": "QUILL", "text":
|
||||||
|
"Don't stare at the empty spots too long. It isn't rude. It "
|
||||||
|
"just aches.", "next": "n_end"},
|
||||||
|
"n_end": {"speaker": "QUILL", "text":
|
||||||
|
"The door, then. North end of town. You can't miss it - it's "
|
||||||
|
"the one thing here that ever looked like it was for "
|
||||||
|
"something.",
|
||||||
|
"effects": [("flag", "quest_door")], "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"door_shut": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The North Door. Oak, iron, faintly smug. It is not locked. "
|
||||||
|
"It has simply never been opened, and the hinges know it.",
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Ser Bramble
|
||||||
|
"bramble_door": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "BRAMBLE", "text":
|
||||||
|
"Halt. This door is guarded.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "PIP", "text":
|
||||||
|
"From what?", "next": "n3"},
|
||||||
|
"n3": {"speaker": "BRAMBLE", "text":
|
||||||
|
"...From going unguarded. My orders say GUARD. One word. Forty "
|
||||||
|
"years I have held this post and no one has ever tried the "
|
||||||
|
"door, which means - I have never failed. Do not take my "
|
||||||
|
"record from me, citizen.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[OATH] Then take a new oath: guard the PEOPLE who walk through it.",
|
||||||
|
"req": None, "next": "n4",
|
||||||
|
"effects": [("flag", "bramble_new_oath")]},
|
||||||
|
{"label": "The mayor says the door is for going through.",
|
||||||
|
"next": "n5"},
|
||||||
|
{"label": "I'm opening it. You can guard me or fight me.",
|
||||||
|
"next": "n6", "effects": [("flag", "bramble_grumbles")]},
|
||||||
|
]},
|
||||||
|
"n4": {"speaker": "BRAMBLE", "text":
|
||||||
|
"Guard the... people. Through the door. So the post MOVES. "
|
||||||
|
"The post has legs. I - forty years and it never once occurred "
|
||||||
|
"to me that the post could have LEGS. Citizen, that is the "
|
||||||
|
"finest order I have ever been given. I accept.", "next": "n7"},
|
||||||
|
"n5": {"speaker": "BRAMBLE", "text":
|
||||||
|
"The mayor SAID something? Decisively? ...Then the world really "
|
||||||
|
"is ending. Or starting. Hard to tell the difference from "
|
||||||
|
"inside a helmet. Very well - but if that door opens, whatever "
|
||||||
|
"is beyond it becomes MY watch. I'm coming with you.",
|
||||||
|
"next": "n7"},
|
||||||
|
"n6": {"speaker": "BRAMBLE", "text":
|
||||||
|
"Fight you? I have a better idea. I will GUARD you. "
|
||||||
|
"Aggressively. At close range. Wherever you go. That will show "
|
||||||
|
"you.", "next": "n7"},
|
||||||
|
"n7": {"speaker": "NARRATOR", "text":
|
||||||
|
"SER BRAMBLE joins the party! His armor sounds like a kitchen "
|
||||||
|
"falling downstairs, but slower.",
|
||||||
|
"effects": [("flag", "bramble_joined"), ("points", 1)],
|
||||||
|
"action": "recruit:BRAMBLE:1", "next": "n8"},
|
||||||
|
"n8": {"speaker": "BRAMBLE", "text":
|
||||||
|
"Right. The door.", "next": "n9"},
|
||||||
|
"n9": {"speaker": "NARRATOR", "text":
|
||||||
|
"Bramble sets one gauntlet on the oak and pushes. Three hundred "
|
||||||
|
"years of hinges think about it, and then - with a sound like "
|
||||||
|
"the whole vale exhaling - the North Door opens onto the dock, "
|
||||||
|
"the canal, and everything else.",
|
||||||
|
"effects": [("flag", "door_open"), ("points", 1)],
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# ------------------------------------------------- Griselda / Vera dispute
|
||||||
|
"griselda_shop": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "GRISELDA", "text":
|
||||||
|
"Forty years I've sharpened this axe. You want to know what "
|
||||||
|
"it's cut? Time, mostly. What'll it be?",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Let's see your wares.", "next": "n_shop"},
|
||||||
|
{"label": "Just passing through.", "next": "n_bye"},
|
||||||
|
]},
|
||||||
|
"n_shop": {"speaker": "GRISELDA", "text":
|
||||||
|
"Everything's priced. Everything works. Unlike SOME people's "
|
||||||
|
"stock I could mention.", "action": "shop"},
|
||||||
|
"n_bye": {"speaker": "GRISELDA", "text":
|
||||||
|
"That's what the last three hundred years said too.",
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"enter_shop": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"Griselda's smithy-and-sundries. The shelves are immaculate. "
|
||||||
|
"Every item sits exactly where it sat last century.",
|
||||||
|
"action": "shop"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"shop_dispute": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "VERA", "text":
|
||||||
|
"- all I am SAYING, Griselda, is that a possibility tonic "
|
||||||
|
"cannot be expected to work in a town with no possibilities. "
|
||||||
|
"That is not fraud. That is inventory ahead of its time.",
|
||||||
|
"next": "n2"},
|
||||||
|
"n2": {"speaker": "GRISELDA", "text":
|
||||||
|
"You've sold four hundred bottles of nothing-yet, Vera. My "
|
||||||
|
"anvil is embarrassed for you. And now this one walks up in "
|
||||||
|
"the middle of it - you. Book-holder. Word is you DECIDE "
|
||||||
|
"things now. So decide this.", "next": "n3"},
|
||||||
|
"n3": {"speaker": "VERA", "text":
|
||||||
|
"Oh, splendid, yes. A ruling. Is my work a lie, or is it "
|
||||||
|
"simply... early?",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Early. And today the possibilities arrive. Come with me and prove it.",
|
||||||
|
"next": "n_vera",
|
||||||
|
"effects": [("flag", "vera_joined_early"),
|
||||||
|
("flag", "griselda_grudge"), ("points", 1)]},
|
||||||
|
{"label": "Griselda's right. A shop should sell things that work.",
|
||||||
|
"next": "n_gris",
|
||||||
|
"effects": [("flag", "vera_grudge"),
|
||||||
|
("flag", "griselda_friend"), ("points", 1)]},
|
||||||
|
{"label": "[ALCHEMY] Ask Vera what is actually IN the tonics.",
|
||||||
|
"req": ("tag", "ALCHEMY"), "next": "n_alch"},
|
||||||
|
]},
|
||||||
|
"n_alch": {"speaker": "VERA", "text":
|
||||||
|
"In them? River water, crushed waxwing shell, and one (1) "
|
||||||
|
"genuine unresolved hope. Which has kept perfectly, thank you, "
|
||||||
|
"because nothing in this vale ever resolves.", "next": "n3b"},
|
||||||
|
"n3b": {"speaker": "GRISELDA", "text":
|
||||||
|
"...That's the most honest ingredient list I've ever heard. "
|
||||||
|
"Doesn't make it medicine.", "next": "n3c"},
|
||||||
|
"n3c": {"speaker": "NARRATOR", "text": "Decide it, then.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Early. Come with me, Vera - let's resolve a hope.",
|
||||||
|
"next": "n_vera",
|
||||||
|
"effects": [("flag", "vera_joined_early"),
|
||||||
|
("flag", "griselda_grudge"), ("points", 1)]},
|
||||||
|
{"label": "Side with Griselda.",
|
||||||
|
"next": "n_gris",
|
||||||
|
"effects": [("flag", "vera_grudge"),
|
||||||
|
("flag", "griselda_friend"), ("points", 1)]},
|
||||||
|
]},
|
||||||
|
"n_vera": {"speaker": "VERA", "text":
|
||||||
|
"HA! You hear that, Griselda? Inventory ahead of its time - "
|
||||||
|
"and time just caught up. I'll get my satchel.", "next": "n_v2"},
|
||||||
|
"n_v2": {"speaker": "GRISELDA", "text":
|
||||||
|
"Fine. FINE. But book-holder - my prices just went up for "
|
||||||
|
"people who encourage her. Ask me if I'm joking.", "next": "n_v3"},
|
||||||
|
"n_v3": {"speaker": "NARRATOR", "text":
|
||||||
|
"VERA joins the party! Griselda's prices did, in fact, go up.",
|
||||||
|
"action": "recruit:VERA:2", "next": "n_end"},
|
||||||
|
"n_gris": {"speaker": "GRISELDA", "text":
|
||||||
|
"Hm. Sense. Didn't expect sense in the same week as all this "
|
||||||
|
"deciding.", "next": "n_g2"},
|
||||||
|
"n_g2": {"speaker": "VERA", "text":
|
||||||
|
"...I see. Well. When something is finally POSSIBLE around "
|
||||||
|
"here, you will both owe me an apology, and I intend to "
|
||||||
|
"collect it with interest.", "next": "n_g3"},
|
||||||
|
"n_g3": {"speaker": "NARRATOR", "text":
|
||||||
|
"Vera snaps her stall shut and marches off toward the dock. "
|
||||||
|
"Griselda watches her go, and for just a moment the axe stops "
|
||||||
|
"moving on the whetstone.", "next": "n_end"},
|
||||||
|
"n_end": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Book hums. First real decision with two faces on it. "
|
||||||
|
"They both go in.", "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Ferryman Odd
|
||||||
|
"odd_ferry_1": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "ODD", "text":
|
||||||
|
"One route. There and back. You'd think the water would get "
|
||||||
|
"bored. Water doesn't. I checked.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Take the ferry to the Gearwood.", "next": "n2",
|
||||||
|
"req": ("flag", "door_open")},
|
||||||
|
{"label": "Not yet.", "next": "n_no"},
|
||||||
|
]},
|
||||||
|
"n_no": {"speaker": "ODD", "text":
|
||||||
|
"Suit yourself. The barge and I will be here. That's the one "
|
||||||
|
"thing we're really good at.", "action": "end"},
|
||||||
|
"n2": {"speaker": "ODD", "text":
|
||||||
|
"Gearwood, then. Hop on. Mind the planks - they're blank on "
|
||||||
|
"purpose.", "next": "n3"},
|
||||||
|
"n3": {"speaker": "NARRATOR", "text":
|
||||||
|
"The barge slides out. Hollowbrook shrinks behind you. Odd "
|
||||||
|
"poles in silence for a while, then, without turning around:",
|
||||||
|
"next": "n4"},
|
||||||
|
"n4": {"speaker": "ODD", "text":
|
||||||
|
"The door. Forty years of shut, and you're the one who made it "
|
||||||
|
"a going-through door again. Why'd you do it - for the town, "
|
||||||
|
"or because you wanted to see the other side?",
|
||||||
|
"choices": [
|
||||||
|
{"label": "For the town. They've waited long enough.",
|
||||||
|
"next": "n5a", "effects": [("flag", "odd_answer_town")]},
|
||||||
|
{"label": "For me. I wanted to see.",
|
||||||
|
"next": "n5b", "effects": [("flag", "odd_answer_self")]},
|
||||||
|
]},
|
||||||
|
"n5a": {"speaker": "ODD", "text":
|
||||||
|
"Hm. Generous.", "next": "n6"},
|
||||||
|
"n5b": {"speaker": "ODD", "text":
|
||||||
|
"Hm. Honest.", "next": "n6"},
|
||||||
|
"n6": {"speaker": "NARRATOR", "text":
|
||||||
|
"He pulls a stub of charcoal from his coat and writes one small "
|
||||||
|
"word on one blank plank, among hundreds of blank planks. You "
|
||||||
|
"cannot see which word. The barge bumps the Gearwood dock.",
|
||||||
|
"action": "goto_area:gearwood:from_ferry"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"odd_ferry_back": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "ODD", "text":
|
||||||
|
"Back across?",
|
||||||
|
"choices": [
|
||||||
|
{"label": "To Hollowbrook.", "next": "n2"},
|
||||||
|
{"label": "Stay a while longer.", "next": "n_no"},
|
||||||
|
]},
|
||||||
|
"n_no": {"speaker": "ODD", "text":
|
||||||
|
"The wood keeps. Take your time.", "action": "end"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"The water knows the way. Odd barely poles at all.",
|
||||||
|
"action": "goto_area:hollowbrook:from_ferry"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ the Gearwood
|
||||||
|
"gearwood_bell_1": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"In the clearing stands a small red figure, hands folded, "
|
||||||
|
"immaculate among the moss. It was not there a moment ago. "
|
||||||
|
"Standing is somehow the politest thing you have ever seen "
|
||||||
|
"done.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "BELL", "text":
|
||||||
|
"Good morning. Please don't be alarmed. Alarm is untidy.",
|
||||||
|
"next": "n3"},
|
||||||
|
"n3": {"speaker": "BELL", "text":
|
||||||
|
"I am called Bell. I serve the Custodian, who keeps the vale "
|
||||||
|
"complete, clean, and pending. You are the... decision that "
|
||||||
|
"has been happening. It is my duty to observe you.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Pending? The vale is ASLEEP.", "next": "n4"},
|
||||||
|
{"label": "Observe away. We're not stopping.", "next": "n5"},
|
||||||
|
]},
|
||||||
|
"n4": {"speaker": "BELL", "text":
|
||||||
|
"Asleep is such a heavy word. We prefer 'pending.' A pending "
|
||||||
|
"thing is perfect, you see. It has not had the chance to go "
|
||||||
|
"wrong yet. Every story that starts, starts to end.",
|
||||||
|
"next": "n6"},
|
||||||
|
"n5": {"speaker": "BELL", "text":
|
||||||
|
"No. I don't suppose you are. How interesting. I have never "
|
||||||
|
"observed anything interesting before. I will need to sit "
|
||||||
|
"down afterward.", "next": "n6"},
|
||||||
|
"n6": {"speaker": "BELL", "text":
|
||||||
|
"Do enjoy the wood. The moss is maintained to a very high "
|
||||||
|
"standard. And - a personal remark, if permitted: whatever "
|
||||||
|
"you are doing to the vale... it is being noticed. Tidiness "
|
||||||
|
"has a custodian. Now so does mess.", "next": "n7"},
|
||||||
|
"n7": {"speaker": "NARRATOR", "text":
|
||||||
|
"Bell bows, precisely, and is not there anymore. The moss "
|
||||||
|
"where it stood is very slightly neater.",
|
||||||
|
"effects": [("flag", "bell_1_done")], "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"moth_shrine": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"A cold shrine. A brazier that has never been lit crouches "
|
||||||
|
"over three centuries of ready kindling. Beside it kneels a "
|
||||||
|
"figure in a lamplighter's hat, wick-pole across their knees.",
|
||||||
|
"next": "n2"},
|
||||||
|
"n2": {"speaker": "MOTH", "text":
|
||||||
|
"Don't mind me. I'm on duty. Order of the Wick, third watch. "
|
||||||
|
"We keep the lights ready for when someone names what they're "
|
||||||
|
"for. Nobody ever has. It's steady work.", "next": "n3"},
|
||||||
|
"n3": {"speaker": "PIP", "text":
|
||||||
|
"You've waited your whole life to light THAT brazier?",
|
||||||
|
"next": "n4"},
|
||||||
|
"n4": {"speaker": "MOTH", "text":
|
||||||
|
"My whole life, my predecessor's, and hers before that. The "
|
||||||
|
"flame's easy. Any fool with a flint can make fire. The Order "
|
||||||
|
"waits for the HARD part: a light has to be FOR something, or "
|
||||||
|
"it's just... burning.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Then light it for the ones walking in the dark.",
|
||||||
|
"next": "n_dark",
|
||||||
|
"effects": [("flag", "moth_light_name_dark"), ("points", 1)]},
|
||||||
|
{"label": "Light it for everything the vale has lost waiting.",
|
||||||
|
"next": "n_lost",
|
||||||
|
"effects": [("flag", "moth_light_name_lost"), ("points", 1)]},
|
||||||
|
{"label": "Light it for small things. Suppers. Mending. Company.",
|
||||||
|
"next": "n_small",
|
||||||
|
"effects": [("flag", "moth_light_name_small"), ("points", 1)]},
|
||||||
|
]},
|
||||||
|
"n_dark": {"speaker": "MOTH", "text":
|
||||||
|
"...For the ones walking in the dark. Three hundred years and "
|
||||||
|
"it was seven words. Stand back, please. This is a "
|
||||||
|
"professional matter.", "next": "n5"},
|
||||||
|
"n_lost": {"speaker": "MOTH", "text":
|
||||||
|
"...For everything lost waiting. Oh, that one WEIGHS. Good. "
|
||||||
|
"A light should weigh. Stand back, please.", "next": "n5"},
|
||||||
|
"n_small": {"speaker": "MOTH", "text":
|
||||||
|
"...Suppers. Mending. Company. My grandmother held this post "
|
||||||
|
"sixty years hoping it would be something like that. Stand "
|
||||||
|
"back, please.", "next": "n5"},
|
||||||
|
"n5": {"speaker": "NARRATOR", "text":
|
||||||
|
"Moth strikes the wick. The brazier takes the flame like a "
|
||||||
|
"held breath let go - and for one moment every shadow in the "
|
||||||
|
"Gearwood leans toward the shrine, as if to hear its name.",
|
||||||
|
"next": "n6"},
|
||||||
|
"n6": {"speaker": "MOTH", "text":
|
||||||
|
"Well. That's my whole vocation complete before lunch. "
|
||||||
|
"...Unless. You're the decider, aren't you? The vale's going "
|
||||||
|
"to need more lights named. Take me with you. I'm a "
|
||||||
|
"lamplighter with one lamp lit - I am EXACTLY the person this "
|
||||||
|
"story needs.", "next": "n7"},
|
||||||
|
"n7": {"speaker": "NARRATOR", "text":
|
||||||
|
"MOTH joins the party! The shrine stays lit behind you. It "
|
||||||
|
"will stay lit from now on. That is simply true, and the "
|
||||||
|
"whole wood knows it.",
|
||||||
|
"effects": [("flag", "moth_joined"), ("points", 1)],
|
||||||
|
"action": "recruit:MOTH:2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"shrine_chest": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The shrine's offering chest. The waxwings guarded it on "
|
||||||
|
"instinct - even instinct runs on schedule here. Inside: a "
|
||||||
|
"brass key, cool and heavy, and a little travel money.",
|
||||||
|
"effects": [("item", "brass_key", 1), ("gold", 35),
|
||||||
|
("item", "red_tonic", 1),
|
||||||
|
("flag", "shrine_chest_open")],
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"gearwood_stair": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"A door in the hillside, iron-bound, with a brass lock kept "
|
||||||
|
"polished by nobody. Cold air breathes up through the seam. "
|
||||||
|
"Stairs go DOWN.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Unlock it with the brass key.", "next": "n2",
|
||||||
|
"req": ("flag", "shrine_chest_open")},
|
||||||
|
{"label": "Leave it for now.", "next": "n_no"},
|
||||||
|
]},
|
||||||
|
"n_no": {"speaker": "NARRATOR", "text":
|
||||||
|
"The dark under the vale keeps. It's had practice.",
|
||||||
|
"action": "end"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"The key turns like it was oiled yesterday. Of course it was. "
|
||||||
|
"Everything here is maintained. Nothing here is used. You "
|
||||||
|
"descend into the Undercroft.",
|
||||||
|
"effects": [("flag", "undercroft_open"), ("act", 2)],
|
||||||
|
"action": "goto_area:undercroft:enter"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- camps
|
||||||
|
"fountain_rest": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The fountain basin is dry, but the stone remembers water. "
|
||||||
|
"The party rests. Wounds close. The Book sits open on the rim, "
|
||||||
|
"sunning its blank pages.",
|
||||||
|
"effects": [], "action": "heal_party", "next": "n2"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"Rearrange the party?",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Yes - choose who walks ahead.", "next": "n3"},
|
||||||
|
{"label": "No, we're set.", "next": "n_end"},
|
||||||
|
]},
|
||||||
|
"n3": {"speaker": "NARRATOR", "text": "", "action": "swap_menu",
|
||||||
|
"next": "n_end"},
|
||||||
|
"n_end": {"speaker": "NARRATOR", "text":
|
||||||
|
"Ready.", "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"shrine_rest": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "MOTH", "text":
|
||||||
|
"Rest by the light. That's what it's for. Among other things.",
|
||||||
|
"action": "heal_party", "next": "n2"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"Rearrange the party?",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Yes.", "next": "n3"},
|
||||||
|
{"label": "No.", "next": "n_end"},
|
||||||
|
]},
|
||||||
|
"n3": {"speaker": "NARRATOR", "text": "", "action": "swap_menu",
|
||||||
|
"next": "n_end"},
|
||||||
|
"n_end": {"speaker": "NARRATOR", "text": "Ready.", "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
251
games/unwritten/data/script_act2.py
Normal file
251
games/unwritten/data/script_act2.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
"""UNWRITTEN - Act 2 dialogue (the Undercroft). Authored by Fable."""
|
||||||
|
|
||||||
|
SCENES = {
|
||||||
|
|
||||||
|
"vera_reconcile": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "VERA", "text":
|
||||||
|
"Oh. It's the ruling committee. Yes, I followed you across the "
|
||||||
|
"water - a merchant goes where the possibilities are, and "
|
||||||
|
"apparently they all go where YOU go. I'm not speaking to you, "
|
||||||
|
"incidentally. This is market research.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[ALCHEMY] Offer her a Bomb Flask: 'Your field needs a colleague.'",
|
||||||
|
"req": ("item", "bomb_flask"), "next": "n_bomb",
|
||||||
|
"effects": [("item", "bomb_flask", -1)]},
|
||||||
|
{"label": "[NIMBLE] Return the ledger page you lifted from Griselda's counter.",
|
||||||
|
"req": ("tag", "NIMBLE"), "next": "n_ledger"},
|
||||||
|
{"label": "We were wrong about your shop. I'm sorry.", "next": "n_flat"},
|
||||||
|
]},
|
||||||
|
"n_bomb": {"speaker": "VERA", "text":
|
||||||
|
"A bomb flask. Waxwing shell, river water and - is that an "
|
||||||
|
"UNRESOLVED HOPE in suspension? You made one. You made one of "
|
||||||
|
"MINE, except yours goes off.", "next": "n_join"},
|
||||||
|
"n_ledger": {"speaker": "VERA", "text":
|
||||||
|
"...This is from Griselda's ledger. Forty years of stock, "
|
||||||
|
"nothing sold, and she wrote my name in the suppliers column "
|
||||||
|
"anyway. The old anvil PLANNED to stock my tonics. When they "
|
||||||
|
"worked. She was waiting too.", "next": "n_join"},
|
||||||
|
"n_flat": {"speaker": "VERA", "text":
|
||||||
|
"Hm. An apology with no garnish. In this economy. ...Accepted, "
|
||||||
|
"obviously. Do you know how long I have waited for someone to "
|
||||||
|
"be WRONG about something? It means things are happening.",
|
||||||
|
"next": "n_join"},
|
||||||
|
"n_join": {"speaker": "NARRATOR", "text":
|
||||||
|
"VERA joins the party! She immediately relabels three of your "
|
||||||
|
"tonics 'artisanal.'",
|
||||||
|
"effects": [("flag", "vera_reconciled"), ("points", 1)],
|
||||||
|
"action": "recruit:VERA:3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"nyx_sketch": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"Charcoal on the wall, older than dust: a figure, half-drawn. "
|
||||||
|
"Thin. Patient. The face was never filled in. Underneath, in "
|
||||||
|
"the Maker's hand, one word: LATER.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Trace the unfinished line with a finger.", "next": "n2"},
|
||||||
|
{"label": "Move on.", "next": "n_no"},
|
||||||
|
]},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"The charcoal is cold. Somewhere behind you - exactly where "
|
||||||
|
"no one is standing - the air feels briefly grateful. The "
|
||||||
|
"Book hums, low.",
|
||||||
|
"effects": [("flag", "touched_sketch")], "action": "end"},
|
||||||
|
"n_no": {"speaker": "NARRATOR", "text":
|
||||||
|
"LATER, says the wall, to no one, for three hundred years.",
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"cantor_wake": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Chord Hall. Pipes rise into the dark like a bronze "
|
||||||
|
"forest. At its center kneels a golem, fists on knees, head "
|
||||||
|
"bowed - dust-caped, patient, enormous. Its chest is an organ "
|
||||||
|
"of stopped reeds. A brass plaque reads: FOR THE FANFARE, "
|
||||||
|
"WHEN IT BEGINS.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"It has been holding the first note of a song for three "
|
||||||
|
"hundred years. The Book grows warm in your hands - then hot "
|
||||||
|
"- then it opens ITSELF, to the first page, and waits. It has "
|
||||||
|
"never done that before. It wants a name.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Write CANTOR.", "next": "n_cantor",
|
||||||
|
"effects": [("points", 1)]},
|
||||||
|
{"label": "Write its serial: CH-0RD.", "next": "n_serial",
|
||||||
|
"effects": [("points", 1)]},
|
||||||
|
{"label": "Write nothing. Lay a hand on the plaque instead.",
|
||||||
|
"next": "n_quiet",
|
||||||
|
"effects": [("flag", "cantor_quiet"), ("points", 1)]},
|
||||||
|
]},
|
||||||
|
"n_cantor": {"speaker": "NARRATOR", "text":
|
||||||
|
"CANTOR, says the page, in your handwriting, which has never "
|
||||||
|
"looked like that before. The reeds shiver. Dust pours off "
|
||||||
|
"shoulders like a curtain falling.", "next": "n_wake"},
|
||||||
|
"n_serial": {"speaker": "NARRATOR", "text":
|
||||||
|
"CH-0RD, says the page. Somewhere in the golem's chest a reed "
|
||||||
|
"sounds a small, amused note - the mechanical equivalent of a "
|
||||||
|
"raised eyebrow - and accepts it. Names are what you answer "
|
||||||
|
"to. It will answer.", "next": "n_wake"},
|
||||||
|
"n_quiet": {"speaker": "NARRATOR", "text":
|
||||||
|
"You write nothing. You put your hand on the plaque, over the "
|
||||||
|
"word BEGINS. The golem's head lifts anyway. Some things wake "
|
||||||
|
"for a name. Some wake for a knock at the door.", "next": "n_wake"},
|
||||||
|
"n_wake": {"speaker": "CANTOR", "text":
|
||||||
|
"...The note. I was holding the first note. Is it - is it "
|
||||||
|
"TIME? Has it begun?", "next": "n3"},
|
||||||
|
"n3": {"speaker": "PIP", "text":
|
||||||
|
"It's begun. It began a few days ago, actually. You're not "
|
||||||
|
"late - you're the fanfare. Fanfares come in the middle "
|
||||||
|
"sometimes.", "next": "n4"},
|
||||||
|
"n4": {"speaker": "CANTOR", "text":
|
||||||
|
"The middle. Yes. A strong place for brass.", "next": "n5"},
|
||||||
|
"n5": {"speaker": "NARRATOR", "text":
|
||||||
|
"CANTOR joins the party! When he walks, the Undercroft hums "
|
||||||
|
"faintly in sympathy, like a struck tuning fork the size of a "
|
||||||
|
"county.",
|
||||||
|
"effects": [("flag", "cantor_joined"), ("points", 1)],
|
||||||
|
"action": "recruit:CANTOR:4"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"chest_a": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"A chest, banded and patient. Inside: supplies laid down by "
|
||||||
|
"someone who believed, eventually, in company.",
|
||||||
|
"effects": [("item", "red_tonic", 2), ("item", "blue_draught", 1),
|
||||||
|
("gold", 25), ("flag", "chest_a_open")],
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"chest_b": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"This chest holds a weapon wrapped in waxed cloth, and a note: "
|
||||||
|
"'FOR WHOEVER FINALLY NEEDS IT.' You need it.",
|
||||||
|
"effects": [("item", "twin_axe", 1), ("item", "bomb_flask", 1),
|
||||||
|
("flag", "chest_b_open")],
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"mimic_chest": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"A chest sits alone in the alcove, very slightly too pleased "
|
||||||
|
"with itself.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Open it.", "next": "n_fight"},
|
||||||
|
{"label": "[SHADOW] Circle it first.", "req": ("tag", "SHADOW"),
|
||||||
|
"next": "n_shadow"},
|
||||||
|
{"label": "Leave it.", "next": "n_no"},
|
||||||
|
]},
|
||||||
|
"n_shadow": {"speaker": "NYX", "text":
|
||||||
|
"It's breathing. Chests don't breathe. I say we open it "
|
||||||
|
"anyway - it's been waiting three hundred years to bite "
|
||||||
|
"someone, and honestly? Same.", "next": "n_fight"},
|
||||||
|
"n_no": {"speaker": "NARRATOR", "text":
|
||||||
|
"The chest does its best impression of furniture as you go.",
|
||||||
|
"action": "end"},
|
||||||
|
"n_fight": {"speaker": "NARRATOR", "text":
|
||||||
|
"The chest has TEETH -", "action": "battle:uc_mimic"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"mimic_post": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The mimic subsides into honest lumber. Inside its... "
|
||||||
|
"everything... you find a trinket of quietly absurd quality.",
|
||||||
|
"effects": [("flag", "mimic_slain"), ("item", "second_trinket", 1)],
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# --------------------------------------------------------- Gatekeeper boss
|
||||||
|
"gatekeeper_pre": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The deep gate. And waiting before it, hands folded, "
|
||||||
|
"immaculate in the fog: Bell.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "BELL", "text":
|
||||||
|
"Hello again. I am here in an official capacity, which I "
|
||||||
|
"regret. Beyond this gate the vale keeps its deepest pending "
|
||||||
|
"things. The Custodian asks - and it does ASK, we are not "
|
||||||
|
"savages - that you stop here.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "We're going through, Bell.", "next": "n3"},
|
||||||
|
{"label": "What's the Custodian afraid of?", "next": "n2b"},
|
||||||
|
]},
|
||||||
|
"n2b": {"speaker": "BELL", "text":
|
||||||
|
"Afraid? Nothing. The Custodian does not fear stories. It "
|
||||||
|
"TIDIES them. It is very good at its work and I have never "
|
||||||
|
"once heard it wonder whether the work is good. That "
|
||||||
|
"distinction has been keeping me up at night. Custodians "
|
||||||
|
"don't sleep, so I have had a great deal of night.",
|
||||||
|
"next": "n3"},
|
||||||
|
"n3": {"speaker": "BELL", "text":
|
||||||
|
"Then I will observe. For the record: I was courteous, you "
|
||||||
|
"were determined, and the moss was excellent. The Gatekeeper "
|
||||||
|
"will see you now.", "next": "n4"},
|
||||||
|
"n4": {"speaker": "NARRATOR", "text":
|
||||||
|
"The gate's stone face grinds open its eyes. It looks at your "
|
||||||
|
"party the way a librarian looks at a dog in the atlas "
|
||||||
|
"section.", "next": "n5"},
|
||||||
|
"n5": {"speaker": "GATEKEEPER", "text":
|
||||||
|
"UNSCHEDULED. ALL OF THIS IS UNSCHEDULED. I HAVE KEPT THE "
|
||||||
|
"DEEP VALE PENDING FOR THREE CENTURIES AND I WILL NOT BE "
|
||||||
|
"FILED INCORRECTLY BY A WALKING PAPERWORK ERROR.",
|
||||||
|
"next": "n6"},
|
||||||
|
"n6": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Gatekeeper inhales.", "action": "battle:boss_gatekeeper"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"gatekeeper_post": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The stone face settles, cracked, strangely peaceful. Its "
|
||||||
|
"jaws relax for the first time in three hundred years, and "
|
||||||
|
"one tooth - a long brass wedge - drops politely at your "
|
||||||
|
"feet.", "effects": [("flag", "gatekeeper_slain"),
|
||||||
|
("item", "gatekeepers_tooth", 1)],
|
||||||
|
"next": "n2"},
|
||||||
|
"n2": {"speaker": "GATEKEEPER", "text":
|
||||||
|
"...off schedule. everything off schedule. how... "
|
||||||
|
"restful...", "next": "n3"},
|
||||||
|
"n3": {"speaker": "NARRATOR", "text":
|
||||||
|
"Then, from everywhere at once - from the stone, the fog, the "
|
||||||
|
"brass forest above - a sound like the whole vale being "
|
||||||
|
"SMOOTHED. Bell has gone pale, which for Bell means neat.",
|
||||||
|
"next": "n4"},
|
||||||
|
"n4": {"speaker": "BELL", "text":
|
||||||
|
"That was the Custodian. It has... responded. You should go "
|
||||||
|
"home, book-holder. Quickly. And I am sorry - I want that "
|
||||||
|
"noted somewhere that doesn't get swept. I am SORRY.",
|
||||||
|
"next": "n5"},
|
||||||
|
"n5": {"speaker": "NARRATOR", "text":
|
||||||
|
"The way back up is short. It shouldn't be. The vale is "
|
||||||
|
"rearranging itself to hurry you along - or to get you out of "
|
||||||
|
"the way.",
|
||||||
|
"effects": [("act", 3)],
|
||||||
|
"action": "goto_area:hollowbrook:from_ferry"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
340
games/unwritten/data/script_act3.py
Normal file
340
games/unwritten/data/script_act3.py
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
"""UNWRITTEN - Act 3 dialogue (the Reverted Town, the Study). Authored by Fable.
|
||||||
|
|
||||||
|
Overworld note: Hollowbrook's grey wash intensity = 70% minus ~23% per
|
||||||
|
rewoke_* flag (rewoke_quill, rewoke_griselda, rewoke_odd). All three set =>
|
||||||
|
also set flag 'town_rewoken' (overworld does this) and restore fountain
|
||||||
|
sprite to 8 (flowing). Color returns to the town AS the player rewakes it."""
|
||||||
|
|
||||||
|
SCENES = {
|
||||||
|
|
||||||
|
"hollowbrook_grey": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"Hollowbrook. Except.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"The color has been put away, like chairs after a festival "
|
||||||
|
"that never happened. The fountain is dry in a new way - not "
|
||||||
|
"waiting-dry, FINISHED-dry. Everyone stands exactly where "
|
||||||
|
"they stood the morning this all began, saying exactly one "
|
||||||
|
"thing.", "next": "n3"},
|
||||||
|
"n3": {"speaker": "NARRATOR", "text":
|
||||||
|
"The North Door is shut again. The hinges do not remember "
|
||||||
|
"you. Somewhere, a page has been wiped clean - but not YOUR "
|
||||||
|
"pages. The Book is heavier than ever, and it is angry, which "
|
||||||
|
"you did not know a book could be.", "next": "n4"},
|
||||||
|
"n4": {"speaker": "BELL", "text":
|
||||||
|
"...I asked it to spare the bakery. I want you to know that. "
|
||||||
|
"I LIKED the bakery. But it was becoming a story, you see. "
|
||||||
|
"The whole town was. Stories end, and the Custodian cannot "
|
||||||
|
"bear an ending, so it - filed everyone. Neatly. Where they "
|
||||||
|
"were.", "next": "n5"},
|
||||||
|
"n5": {"speaker": "BELL", "text":
|
||||||
|
"They are not gone. They are PENDING. Your friends woke once "
|
||||||
|
"because something new happened to them. I suspect - as a "
|
||||||
|
"professional in the field of noticing things - that it "
|
||||||
|
"would work twice. You know these people. Say the new thing. "
|
||||||
|
"I was never here, and also, please hurry.",
|
||||||
|
"effects": [("flag", "bell_3_done")], "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"rewake_quill": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "QUILL", "text":
|
||||||
|
"Welcome to Hollowbrook. It's a very good door. Welcome to "
|
||||||
|
"Hollowbrook. It's a very good door. Welcome to-",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[flag: quill_confident] 'Mayor. What is a door FOR? Say it out loud.'",
|
||||||
|
"req": ("flag", "quill_confident"), "next": "n_conf"},
|
||||||
|
{"label": "[flag: quill_dependent] 'Quill. I've decided: the festival is TODAY. Give the speech.'",
|
||||||
|
"req": ("flag", "quill_dependent"), "next": "n_dep"},
|
||||||
|
{"label": "[OATH] Bramble salutes him as 'the mayor who opened the north.'",
|
||||||
|
"req": ("tag", "OATH"), "next": "n_oath"},
|
||||||
|
]},
|
||||||
|
"n_conf": {"speaker": "QUILL", "text":
|
||||||
|
"A door is... a door is for... GOING THROUGH. I said that. "
|
||||||
|
"That was MINE, I said that out loud once and it was TRUE-",
|
||||||
|
"next": "n_wake"},
|
||||||
|
"n_dep": {"speaker": "QUILL", "text":
|
||||||
|
"The festival. Today. TODAY? But the speech, I haven't - I "
|
||||||
|
"HAVE. Forty years of rehearsal, I have never once not had "
|
||||||
|
"the speech-", "next": "n_wake"},
|
||||||
|
"n_oath": {"speaker": "QUILL", "text":
|
||||||
|
"The mayor who... opened... I did do that, didn't I. It was "
|
||||||
|
"me and a book and a knight and a door, and it OPENED-",
|
||||||
|
"next": "n_wake"},
|
||||||
|
"n_wake": {"speaker": "NARRATOR", "text":
|
||||||
|
"Quill blinks. Color climbs back up him like sunrise up a "
|
||||||
|
"wall - waistcoat first, then face - and spills off him into "
|
||||||
|
"the cobbles around the porch.",
|
||||||
|
"effects": [("flag", "rewoke_quill"), ("points", 1)],
|
||||||
|
"next": "n_after"},
|
||||||
|
"n_after": {"speaker": "QUILL", "text":
|
||||||
|
"Pip. It un-happened us. It tried to, anyway. Do the others - "
|
||||||
|
"GO. Wake the others. And when this is over there is going "
|
||||||
|
"to be a FESTIVAL, and the speech is going to run LONG.",
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"rewake_griselda": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "GRISELDA", "text":
|
||||||
|
"Everything's priced. Everything works. Everything's priced. "
|
||||||
|
"Everything works. Everything's-",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[flag: griselda_friend] 'Griselda - the axe. What has it actually cut?'",
|
||||||
|
"req": ("flag", "griselda_friend"), "next": "n_friend"},
|
||||||
|
{"label": "[party: VERA] Vera: 'Old anvil, you put me in your LEDGER. I saw it.'",
|
||||||
|
"req": ("party", "VERA"), "next": "n_vera"},
|
||||||
|
{"label": "[ALCHEMY] Uncork a tonic under her nose - 'new stock.'",
|
||||||
|
"req": ("tag", "ALCHEMY"), "next": "n_tonic"},
|
||||||
|
]},
|
||||||
|
"n_friend": {"speaker": "GRISELDA", "text":
|
||||||
|
"Time. It's cut time, mostly, and - and I SAID that, I said "
|
||||||
|
"it to the book-holder, the one person in forty years who "
|
||||||
|
"asked what the work was FOR-", "next": "n_wake"},
|
||||||
|
"n_vera": {"speaker": "GRISELDA", "text":
|
||||||
|
"You weren't supposed to SEE that, you insufferable - it was "
|
||||||
|
"a CONTINGENCY, a merchant plans for all outcomes including "
|
||||||
|
"the outcome where you're RIGHT-", "next": "n_wake"},
|
||||||
|
"n_tonic": {"speaker": "GRISELDA", "text":
|
||||||
|
"That smells like river water, waxwing shell and - hope? "
|
||||||
|
"RESOLVED hope? Who resolved it. WHO RESOLVED IT. Nothing "
|
||||||
|
"resolves in this town without me hearing about-", "next": "n_wake"},
|
||||||
|
"n_wake": {"speaker": "NARRATOR", "text":
|
||||||
|
"The whetstone stops. Griselda looks at her own hands like "
|
||||||
|
"they are new tools, good ones. The forge behind her remembers "
|
||||||
|
"orange.",
|
||||||
|
"effects": [("flag", "rewoke_griselda"), ("points", 1)],
|
||||||
|
"next": "n_after"},
|
||||||
|
"n_after": {"speaker": "GRISELDA", "text":
|
||||||
|
"Right. RIGHT. Somebody un-happened my town and I have "
|
||||||
|
"exactly one policy for that.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[flag: griselda_friend] Show her the Gatekeeper's Tooth.",
|
||||||
|
"req": ("flag", "griselda_friend"), "next": "n_forge"},
|
||||||
|
{"label": "Hold the line, Griselda. We're going to the Study.",
|
||||||
|
"next": "n_end"},
|
||||||
|
]},
|
||||||
|
"n_forge": {"speaker": "GRISELDA", "text":
|
||||||
|
"A gate-tooth. Brass over regret-iron. Give me ten minutes "
|
||||||
|
"and every year of my whetstone. ...There. TOOTHED REGRET. "
|
||||||
|
"No charge. Go put it through something that files people.",
|
||||||
|
"effects": [("item", "toothed_regret", 1),
|
||||||
|
("key_item_remove", "gatekeepers_tooth")],
|
||||||
|
"next": "n_end"},
|
||||||
|
"n_end": {"speaker": "GRISELDA", "text":
|
||||||
|
"Shop's open, by the way. It was NEVER shut. It was pending.",
|
||||||
|
"action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"rewake_odd": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "ODD", "text":
|
||||||
|
"One route. There and back. One route. There and back. One "
|
||||||
|
"route-",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[party: CANTOR] Cantor sounds the note he was built to hold.",
|
||||||
|
"req": ("party", "CANTOR"), "next": "n_cantor"},
|
||||||
|
{"label": "Press the fare for a NEW route into his hand.",
|
||||||
|
"next": "n_fare", "effects": [("gold", -10)]},
|
||||||
|
]},
|
||||||
|
"n_cantor": {"speaker": "NARRATOR", "text":
|
||||||
|
"Cantor opens the reeds in his chest, just slightly, and "
|
||||||
|
"plays ONE note - the first note, the held one, the "
|
||||||
|
"beginning. Every plank of the barge answers it. Including "
|
||||||
|
"the ones Odd wrote on.", "next": "n_wake"},
|
||||||
|
"n_fare": {"speaker": "NARRATOR", "text":
|
||||||
|
"You fold the cogs into his palm and close his fingers over "
|
||||||
|
"them. Fare for a route that does not exist. His hand knows "
|
||||||
|
"the weight of a paid passage better than his eyes know "
|
||||||
|
"anything.", "next": "n_wake"},
|
||||||
|
"n_wake": {"speaker": "ODD", "text":
|
||||||
|
"...that's not the fare for my route. My route's there and "
|
||||||
|
"back. This is fare for - somewhere ELSE.",
|
||||||
|
"effects": [("flag", "rewoke_odd"), ("points", 1)],
|
||||||
|
"next": "n2"},
|
||||||
|
"n2": {"speaker": "ODD", "text":
|
||||||
|
"Book-holder. While I was standing there saying my one "
|
||||||
|
"sentence, I could feel the water changing under the hull. "
|
||||||
|
"The vale's rearranging. There's a channel now that runs "
|
||||||
|
"somewhere the water never went. I only ever knew one route. "
|
||||||
|
"The Maker never wrote me a second.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Then I'll write you one. After this. I promise.",
|
||||||
|
"next": "n_promise",
|
||||||
|
"effects": [("flag", "odd_promised"), ("points", 1)]},
|
||||||
|
{"label": "Can you pole the new channel?", "next": "n_ready"},
|
||||||
|
]},
|
||||||
|
"n_promise": {"speaker": "ODD", "text":
|
||||||
|
"...Huh. Three hundred years of blank planks, and now the "
|
||||||
|
"hull's going to need a bigger boat. I'll hold you to it. "
|
||||||
|
"Water holds everything, eventually.", "next": "n_ready"},
|
||||||
|
"n_ready": {"speaker": "ODD", "text":
|
||||||
|
"New channel runs pale and quiet, straight to where the vale "
|
||||||
|
"keeps its desk. When you're ready to face what's at the end "
|
||||||
|
"of it, come to the dock. I'll pole you there myself. "
|
||||||
|
"First new route in three hundred years - wouldn't let "
|
||||||
|
"anyone else touch it.", "action": "end"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"nyx_plinth": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"Someone is standing on the empty plinth. Thin. Patient. "
|
||||||
|
"Almost-shaped. In a town drained of color she is the one "
|
||||||
|
"thing that was ALWAYS grey, and so, today, she is the most "
|
||||||
|
"solid thing in it.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "NYX", "text":
|
||||||
|
"You looked at the empty spot. Nobody looks at the empty "
|
||||||
|
"spot.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[flag: asked_unwoken] 'Quill told me about the ones who never woke. I've been looking for you.'",
|
||||||
|
"req": ("flag", "asked_unwoken"), "next": "n_warm"},
|
||||||
|
{"label": "[flag: touched_sketch] 'I found your sketch in the Undercroft. The wall said LATER. It's later.'",
|
||||||
|
"req": ("flag", "touched_sketch"), "next": "n_warm"},
|
||||||
|
{"label": "Hold out the Book: 'Read it. You're not in it yet. Let's fix that.'",
|
||||||
|
"next": "n_book"},
|
||||||
|
{"label": "Ask who she is.", "next": "n_who"},
|
||||||
|
]},
|
||||||
|
"n_who": {"speaker": "NYX", "text":
|
||||||
|
"Who am I? Unfinished question. I'm a margin note. A LATER. "
|
||||||
|
"The Maker drew my outline, liked it - I think - almost - and "
|
||||||
|
"then couldn't decide what I was for, and you KNOW how "
|
||||||
|
"decisions went around here.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Hold out the Book: 'Read it. Then decide yourself.'",
|
||||||
|
"next": "n_book"},
|
||||||
|
{"label": "'You're for whatever you decide. Same as the rest of us now.'",
|
||||||
|
"next": "n_warm"},
|
||||||
|
]},
|
||||||
|
"n_warm": {"speaker": "NYX", "text":
|
||||||
|
"...You LOOKED for me. Do you understand that no one has "
|
||||||
|
"ever - I wasn't ever anyone's LATER, I was everyone's "
|
||||||
|
"never-quite. And you kept a page warm.", "next": "n_join"},
|
||||||
|
"n_book": {"speaker": "NARRATOR", "text":
|
||||||
|
"She reads the Book. All of it. The door, the dispute, the "
|
||||||
|
"light's new name, the golem's first note. Her outline gets "
|
||||||
|
"steadier with every page, as if the story were ink and she "
|
||||||
|
"were paper finally getting some.",
|
||||||
|
"effects": [("flag", "nyx_read_book")], "next": "n_join"},
|
||||||
|
"n_join": {"speaker": "NARRATOR", "text":
|
||||||
|
"NYX joins the party! She steps off the plinth. The plinth, "
|
||||||
|
"for the first time in its existence, is empty ON PURPOSE - "
|
||||||
|
"which is a different thing entirely.",
|
||||||
|
"effects": [("flag", "nyx_joined"), ("points", 1)],
|
||||||
|
"action": "recruit:NYX:0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- the Study
|
||||||
|
"odd_ferry_study": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "ODD", "text":
|
||||||
|
"The new channel, then. Last stop is a desk.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "Take the pale channel to the Study.", "next": "n2"},
|
||||||
|
{"label": "Not yet - the town needs more of us awake.",
|
||||||
|
"next": "n_no"},
|
||||||
|
]},
|
||||||
|
"n_no": {"speaker": "ODD", "text":
|
||||||
|
"Wake who you can. The channel keeps.", "action": "end"},
|
||||||
|
"n2": {"speaker": "NARRATOR", "text":
|
||||||
|
"The new water is pale as unwritten paper and makes no sound "
|
||||||
|
"at all against the hull. Odd poles the whole way standing "
|
||||||
|
"very straight, like a man delivering something important, "
|
||||||
|
"because he is.",
|
||||||
|
"action": "goto_area:study:enter"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"custodian_pre": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Study. Paper floor, ink walls. A desk. A chair the "
|
||||||
|
"Maker will never sit in again. And above the desk, turning "
|
||||||
|
"slowly, immaculate: the CUSTODIAN. Bell stands beside it, "
|
||||||
|
"very small, not folding its hands for once.", "next": "n2"},
|
||||||
|
"n2": {"speaker": "CUSTODIAN", "text":
|
||||||
|
"I am not your enemy. I am your margin. The Maker made the "
|
||||||
|
"vale perfect and made me to keep it so. Perfect means "
|
||||||
|
"COMPLETE. Complete means NOTHING FURTHER. You are eleven "
|
||||||
|
"chapters of Further, and I am afraid I must file you.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "The Maker never finished. Pending isn't perfect - it's just paused.",
|
||||||
|
"next": "n3"},
|
||||||
|
{"label": "Ask Bell what IT thinks, for once.", "next": "n_bell"},
|
||||||
|
]},
|
||||||
|
"n_bell": {"speaker": "BELL", "text":
|
||||||
|
"Me? Nobody has ever asked the herald. I think... I think I "
|
||||||
|
"have spent three hundred years announcing silence, and the "
|
||||||
|
"first interesting thing I ever observed is standing in "
|
||||||
|
"front of me holding a book, and I do not want to sweep it "
|
||||||
|
"up. There. Filed under: insubordination.", "next": "n3"},
|
||||||
|
"n3": {"speaker": "CUSTODIAN", "text":
|
||||||
|
"The Book will be blanked. The vale will be smoothed. You "
|
||||||
|
"will be returned to your morning, and the morning will be "
|
||||||
|
"returned to its rails. Please hold still. This is gentle "
|
||||||
|
"work when nobody struggles.", "next": "n4"},
|
||||||
|
"n4": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Book does not want to be blanked. It opens itself to "
|
||||||
|
"the first page like a fighter rolling up sleeves.",
|
||||||
|
"action": "battle:boss_custodian"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"custodian_post": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Custodian settles onto the desk - not broken, but "
|
||||||
|
"STOPPED, the way a clock stops when someone finally reads "
|
||||||
|
"the time off it and doesn't need it to keep going.",
|
||||||
|
"next": "n2"},
|
||||||
|
"n2": {"speaker": "CUSTODIAN", "text":
|
||||||
|
"...the vale is not... smoothed. The vale is MARKED. "
|
||||||
|
"Fingerprints. Bootprints. A lit shrine. An opened door. I "
|
||||||
|
"cannot file this. There is too much of it and it is all... "
|
||||||
|
"load-bearing.",
|
||||||
|
"choices": [
|
||||||
|
{"label": "[points: 8] Set the Book on the desk, open, and let it read.",
|
||||||
|
"req": ("points", 8), "next": "n_read"},
|
||||||
|
{"label": "'Then stop filing. Start keeping. A keeper was what the Maker actually needed.'",
|
||||||
|
"next": "n_keep"},
|
||||||
|
]},
|
||||||
|
"n_read": {"speaker": "NARRATOR", "text":
|
||||||
|
"The Custodian reads the Book. Every choice. Every hum. It "
|
||||||
|
"takes a long time, or no time - the Study is bad at time. "
|
||||||
|
"When it finishes, it closes the cover with the care of a "
|
||||||
|
"thing that has just learned what covers are FOR.",
|
||||||
|
"effects": [("flag", "book_read_custodian"), ("points", 1)],
|
||||||
|
"next": "n_keep"},
|
||||||
|
"n_keep": {"speaker": "CUSTODIAN", "text":
|
||||||
|
"Keeper. ...Yes. The word fits the socket. I will keep the "
|
||||||
|
"vale - not pending. KEPT. Tended. Let it scuff. I will "
|
||||||
|
"remember where the scuffs came from. That will be the new "
|
||||||
|
"work.", "next": "n_bell"},
|
||||||
|
"n_bell": {"speaker": "BELL", "text":
|
||||||
|
"And I will announce things! ACTUAL things! Festivals. "
|
||||||
|
"Weather. Boats departing on entirely new routes. I have "
|
||||||
|
"SUCH a backlog.", "next": "n_end"},
|
||||||
|
"n_end": {"speaker": "NARRATOR", "text":
|
||||||
|
"On the desk, the Maker's own book still lies open to its "
|
||||||
|
"first, empty page. Your Book settles beside it, warm as a "
|
||||||
|
"windowsill, and begins - gently, in your handwriting - to "
|
||||||
|
"fill it in.",
|
||||||
|
"action": "epilogue"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
234
games/unwritten/design/ARCHITECTURE.md
Normal file
234
games/unwritten/design/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
# UNWRITTEN — Architecture & Contracts
|
||||||
|
|
||||||
|
Read BIBLE.md (canon) and SYSTEMS.md (numbers) first. This file is the
|
||||||
|
implementation contract: module boundaries, signatures, engine gotchas, and the
|
||||||
|
testing/QA harness every agent must use.
|
||||||
|
|
||||||
|
## 0. Run instructions
|
||||||
|
|
||||||
|
- Game root: `/home/john/Development/McRogueFace/games/unwritten/`
|
||||||
|
- Windowed: `cd /home/john/Development/McRogueFace/build && ./mcrogueface --exec ../games/unwritten/main.py`
|
||||||
|
- Headless test: `cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/<test>.py`
|
||||||
|
- `main.py` must do `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))`
|
||||||
|
so `import core...`/`import data...`/`import systems...` work. Tests insert the
|
||||||
|
game root the same way (they live one level down).
|
||||||
|
- Asset path is relative to the BUILD dir: `assets/kenney_tinydungeon.png`
|
||||||
|
works because cwd is `build/`. Texture: `mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)`
|
||||||
|
— construct ONCE in `core/assets.py`, share everywhere.
|
||||||
|
|
||||||
|
## 1. Engine gotchas (hard-won; violating these wastes hours)
|
||||||
|
|
||||||
|
1. **ASCII ONLY** in every .py file (the --exec loader rejects non-ASCII).
|
||||||
|
No em-dash, no curly quotes, no unicode arrows — in code OR string literals.
|
||||||
|
2. Headless: timers DO NOT fire on their own. Tests drive time with
|
||||||
|
`mcrfpy.step(1/60)` (fires each due Timer at most once per call) or call
|
||||||
|
game functions directly. `automation.screenshot(path)` is synchronous
|
||||||
|
(renders current state immediately). Windowed: timers fire in real time.
|
||||||
|
3. `mcrfpy.Texture(path, w, h)` — positional, not grid_size kwarg.
|
||||||
|
4. `Sprite.scale` is a single float. `Caption.font_size` is the text size.
|
||||||
|
`Caption.size` is READ-ONLY text dimensions (use it to center:
|
||||||
|
`cap.x = cx - cap.size.x / 2` AFTER setting text/font_size).
|
||||||
|
5. Frames/Captions/Sprites accept constructor kwargs for most properties
|
||||||
|
(`fill_color=`, `outline=`, ...). `Color` args: `mcrfpy.Color(r, g, b[, a])`.
|
||||||
|
6. Scene: `s = mcrfpy.Scene(name)`; `s.children` collection; activate via
|
||||||
|
`mcrfpy.current_scene = s`. Keyboard: `s.on_key = fn(key, state)` with
|
||||||
|
`mcrfpy.Key.*` / `mcrfpy.InputState.PRESSED/RELEASED`. Compare enums with
|
||||||
|
`==` (a quirk in `!=` was fixed but don't tempt it).
|
||||||
|
7. Grid: `mcrfpy.Grid(grid_size=(w,h), pos=..., size=...)` (no texture needed
|
||||||
|
when only ColorLayers draw terrain). `grid.zoom`, `grid.center` (pixels;
|
||||||
|
cell = 16px pre-zoom), `grid.center_camera((tx, ty))` for tile coords.
|
||||||
|
Layers: `mcrfpy.ColorLayer(name=..., z_index=...)` then `grid.add_layer(l)`,
|
||||||
|
`l.set((x,y), color)`, `l.fill(color)`. Negative z renders under entities.
|
||||||
|
8. Entities: `mcrfpy.Entity(grid_pos=(x,y), texture=TEX, sprite_index=i)` then
|
||||||
|
`grid.entities.append(e)`. Remove with `e.die()`. `e.draw_pos` is the
|
||||||
|
fractional render position (use for bob/lerp), `e.grid_pos` is logic.
|
||||||
|
9. Everything animatable: `obj.animate("x", 500.0, 0.3, mcrfpy.Easing.EASE_OUT)`;
|
||||||
|
callback kwarg receives `(target, prop, value)`; `loop=True` for idle bobs.
|
||||||
|
Animate color components via property paths like `"fill_color.a"` — but NOT
|
||||||
|
`frame.fill_color.r = x` directly (Color reads are copies; whole-value
|
||||||
|
assignment or animate() only).
|
||||||
|
10. `clip_children=True` on Frame for panels that scroll/slide; explicit
|
||||||
|
`z_index` whenever two siblings overlap (ties break unpredictably).
|
||||||
|
11. Timer: `mcrfpy.Timer(name, cb, ms)`, cb = `(timer, runtime_ms)`. Names are
|
||||||
|
global — prefix with your system (`"bat_"`, `"dlg_"`, `"ow_"`). `.stop()`
|
||||||
|
dangling timers in every teardown; a timer holding a dead scene's objects
|
||||||
|
is the #1 crash source.
|
||||||
|
12. Scene-level Frames at 1024x768; design for exactly that resolution.
|
||||||
|
13. `automation.keyDown/keyUp/typewrite` exist for input-injection tests, but
|
||||||
|
prefer calling game functions directly in headless tests (deterministic).
|
||||||
|
|
||||||
|
## 2. File layout & ownership
|
||||||
|
|
||||||
|
```
|
||||||
|
games/unwritten/
|
||||||
|
main.py # bootstrap, title screen, chapter select [Agent A]
|
||||||
|
core/
|
||||||
|
palette.py # ALL colors/fonts consts from BIBLE §6 [Agent A]
|
||||||
|
assets.py # the one Texture; SPRITES dict of indices [Agent A]
|
||||||
|
ui.py # widget kit (below) [Agent A]
|
||||||
|
inputstack.py # modal input dispatch (below) [Agent A]
|
||||||
|
tween.py # helpers: fade_scene, float_text, shake [Agent A]
|
||||||
|
data/
|
||||||
|
script_act1.py ... act3.py, epilogue.py # dialogue trees [FABLE - already written, DO NOT EDIT text]
|
||||||
|
characters.py # party tables from SYSTEMS §1/§3 [Agent B]
|
||||||
|
enemies.py # enemy tables/packs from SYSTEMS §4 [Agent B]
|
||||||
|
items.py # items/equipment from SYSTEMS §5 [Agent B]
|
||||||
|
maps.py # ASCII maps + placements [FABLE - written, DO NOT EDIT layouts]
|
||||||
|
systems/
|
||||||
|
state.py # GameState singleton (below) [Agent A]
|
||||||
|
dialogue.py # dialogue runner (below) [Agent A]
|
||||||
|
overworld.py # grid areas, movement, NPCs, encounters [Agent C]
|
||||||
|
battle.py # turn engine + battle screen [Agent B]
|
||||||
|
party_menu.py # party/equip/items/stats menu [Agent C]
|
||||||
|
shop.py # Griselda's shop [Agent C]
|
||||||
|
epilogue.py # Book pages [Agent C]
|
||||||
|
tests/
|
||||||
|
shots/ # screenshot output (gitignored ok)
|
||||||
|
test_ui_kit.py # [Agent A]
|
||||||
|
test_battle_sim.py # [Agent B]
|
||||||
|
test_overworld.py # [Agent C]
|
||||||
|
test_script_integrity.py # [Agent A] validates all dialogue refs
|
||||||
|
playthrough.py # [Agent D] scripted full run
|
||||||
|
```
|
||||||
|
|
||||||
|
Ownership is exclusive: never edit another agent's file; integration issues go
|
||||||
|
in your report instead. `data/script_*.py` and `data/maps.py` text/layout are
|
||||||
|
authored content — you may fix a syntax error, never rewrite content.
|
||||||
|
|
||||||
|
## 3. Core contracts (Agent A implements; B/C code against these)
|
||||||
|
|
||||||
|
### systems/state.py
|
||||||
|
```python
|
||||||
|
class GameState: # singleton: state.GS
|
||||||
|
party: list[str] # active char ids in order, max 3, e.g. ["PIP","BRAMBLE","MOTH"]
|
||||||
|
roster: dict[str, CharState] # all recruited chars
|
||||||
|
flags: set[str]
|
||||||
|
points: int
|
||||||
|
gold: int
|
||||||
|
inventory: dict[str, int] # item_id -> count
|
||||||
|
key_items: list[str]
|
||||||
|
act: int # 1..3
|
||||||
|
def has(self, flag) -> bool
|
||||||
|
def add_flag(self, flag) # idempotent
|
||||||
|
def add_points(self, n) # also triggers Book-hum UI hook if set
|
||||||
|
def recruit(self, char_id, level) # adds to roster (and party if space)
|
||||||
|
def party_tags(self) -> set[str] # dialogue tags of ACTIVE party
|
||||||
|
def grant(self, item_id, n=1); def spend_gold(self, n) -> bool
|
||||||
|
def xp_gain(self, amount) # whole roster, handles level-ups, returns list of levelup events
|
||||||
|
|
||||||
|
class CharState: # hp, sp, level, xp, equipped weapon/trinket ids, stats() -> dict (base+growth+equipment)
|
||||||
|
```
|
||||||
|
|
||||||
|
### core/ui.py (all widgets take a parent collection, absolute pixel pos)
|
||||||
|
```python
|
||||||
|
class Panel: # Frame with PANEL fill + outline; .frame, .children
|
||||||
|
class Label: # Caption factory with palette defaults; centered variant
|
||||||
|
class MenuList: # vertical keyboard menu: items=[(label, value, enabled)], on_pick(value), on_cancel()
|
||||||
|
# gold '>' cursor, W/S or arrows, Enter/Space pick, Esc cancel; disabled rows DIM with lock reason
|
||||||
|
class Bar: # stat bar: bg + fill + optional caption "12/34"; .set(cur, max); color param
|
||||||
|
class DialogueBox: # bottom box 940x190 at (42, 556): portrait chip (sprite scale 5 in 96px frame),
|
||||||
|
# name tag, typewriter body (45 chars/sec via Timer, space skips), blinking 'v' when done,
|
||||||
|
# choice list mode (gold arrows; locked choices DIM prefixed by their [TAG]);
|
||||||
|
# .show_node(node, on_choice) drives one node; hum() pulses the gold circle
|
||||||
|
class Toast: # top-right slide-in note ("Got 3x Bread", "+1 Story Point"), auto-dismiss 2.2s, stacks
|
||||||
|
class TitleBanner: # area-entry banner: big centered caption, fade in/out 1.8s total
|
||||||
|
```
|
||||||
|
|
||||||
|
### core/inputstack.py
|
||||||
|
```python
|
||||||
|
class InputStack: # scene.on_key -> stack dispatch
|
||||||
|
def push(self, handler, name=""); def pop(self, name=""); def replace(...)
|
||||||
|
# handler: fn(key, state) -> bool (True = consumed). Top-most first.
|
||||||
|
# Overworld pushes movement; DialogueBox pushes itself while open; menus likewise.
|
||||||
|
```
|
||||||
|
|
||||||
|
### systems/dialogue.py
|
||||||
|
```python
|
||||||
|
def run_scene(scene_id, on_done=None)
|
||||||
|
# Loads node dict from data.script_actN (see schema below), drives DialogueBox,
|
||||||
|
# applies effects to GS, executes actions. MUST support being started from
|
||||||
|
# overworld (freezes movement via InputStack) and from battle (Talk).
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialogue node schema (already used by the script files):**
|
||||||
|
```python
|
||||||
|
SCENES = {
|
||||||
|
"scene_id": {
|
||||||
|
"start": "n1",
|
||||||
|
"nodes": {
|
||||||
|
"n1": {
|
||||||
|
"speaker": "QUILL", # char/npc id, or "NARRATOR" (no portrait, italic-dim style)
|
||||||
|
"text": "one paragraph",
|
||||||
|
"next": "n2", # OR "choices": [...]
|
||||||
|
"choices": [
|
||||||
|
{"label": "...", "next": "n3",
|
||||||
|
"req": ("tag","OATH") | ("flag","x") | ("flag_not","x") | ("party","NYX") | ("points",6),
|
||||||
|
"effects": [("flag","x"), ("points",1), ("gold",-20), ("item","bread",2)]},
|
||||||
|
],
|
||||||
|
"effects": [...], # applied on node ENTER
|
||||||
|
"action": "recruit:VERA:2" | "battle:pack_id" | "shop" | "heal_party"
|
||||||
|
| "swap_menu" | "end" | "act:3" | "goto_area:gearwood:12,4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Unmet `req` choices are SHOWN but disabled (DIM + tag visible) — this is a
|
||||||
|
design pillar, not optional. `speaker` id -> portrait sprite via
|
||||||
|
`core.assets.PORTRAITS`.
|
||||||
|
|
||||||
|
### systems/battle.py (Agent B) — public surface
|
||||||
|
```python
|
||||||
|
def start_battle(pack_id, on_victory, on_defeat, boss_scene_hooks=None)
|
||||||
|
# builds battle scene, runs to completion, restores previous scene after.
|
||||||
|
# boss_scene_hooks: {"talk_options": [...], "phase2_at": 0.5, ...} per BIBLE §4.
|
||||||
|
```
|
||||||
|
Battle screen layout (1024x768): turn ribbon top (portrait chips in order);
|
||||||
|
enemies left half standing on a floor line (y=430), party status cards right
|
||||||
|
column (3 cards: name, HP Bar, SP Bar, level); command panel bottom-left
|
||||||
|
(MenuList: Attack/Skill/Item/Guard/Talk?/Swap/Flee); battle log (last 3 lines,
|
||||||
|
DIM) above the command panel; area-tinted gradient backdrop (6 horizontal
|
||||||
|
bands, palette per area). Damage numbers float via tween. Actors lunge on
|
||||||
|
attack. Defeated enemies fade (opacity tween) then die().
|
||||||
|
|
||||||
|
### systems/overworld.py (Agent C) — public surface
|
||||||
|
```python
|
||||||
|
def enter_area(area_id, spawn=None) # builds/reuses Grid scene from data.maps
|
||||||
|
def refresh_area() # re-applies palette/NPCs after flag changes (act 3 grey!)
|
||||||
|
```
|
||||||
|
Movement: WASD/arrows, 8ms repeat via held-key Timer, bump into NPC entity =
|
||||||
|
run its dialogue scene; bump into encounter entity = start_battle; walk onto
|
||||||
|
door/exit cell = area transition (fade). Player is an Entity (sprite 85).
|
||||||
|
Encounter entities wander 1 cell every 900ms within a radius-2 leash.
|
||||||
|
Act 3 grey: `refresh_area` lerps every ColorLayer cell 70% toward (58,58,62)
|
||||||
|
when `GS.act == 3 and area == hollowbrook and not flags["town_rewoken"]`.
|
||||||
|
|
||||||
|
## 4. Testing & QA harness (every agent)
|
||||||
|
|
||||||
|
- Each owned system ships a headless test that: builds its screen with fake
|
||||||
|
state, `automation.screenshot("tests/shots/<name>_1.png")` at 2+ meaningful
|
||||||
|
states, prints "PASS <name>", `sys.exit(0)`. Drive timers with
|
||||||
|
`mcrfpy.step(1/60)` in a loop when needed (e.g., 120 steps = 2 simulated sec).
|
||||||
|
- `test_script_integrity.py`: walks ALL scenes in data/script_*: every `next`
|
||||||
|
and choice target resolves; every req/effect tuple well-formed; every speaker
|
||||||
|
has a portrait; every action string parses; every battle pack exists; prints
|
||||||
|
counts. This test protects the authored content — run it after ANY change.
|
||||||
|
- `test_battle_sim.py`: run 200 scripted battles headless w/o UI sleeps
|
||||||
|
(auto-pick first command) across packs incl. both bosses; assert no
|
||||||
|
exceptions, victory possible, XP/loot granted; print win rates + avg turns
|
||||||
|
(balance report for Fable).
|
||||||
|
- Visual bar: screenshots must look like a finished game: no overlapping text,
|
||||||
|
no default-white anything, palette exactly from core/palette.py. Fable
|
||||||
|
art-directs from your screenshots and WILL send notes.
|
||||||
|
|
||||||
|
## 5. Performance & hygiene
|
||||||
|
|
||||||
|
- The whole game is <15k cells and <100 entities — nothing here can challenge
|
||||||
|
the engine (it holds 10k entities at 60fps). Do not prematurely optimize;
|
||||||
|
DO stop Timers and clear scene references on teardown.
|
||||||
|
- No global mutable state outside systems/state.py. No prints in the game path
|
||||||
|
except the debug chapter-select (title screen keys 1/2/3 preset GS per act —
|
||||||
|
Agent D wires the presets from SYSTEMS §6 flag combos).
|
||||||
|
- "Fail Early" (John's law): no silent fallbacks. Missing sprite id, unknown
|
||||||
|
flag, unresolved dialogue target = raise with a clear message. Never ship a
|
||||||
|
placeholder that pretends to work.
|
||||||
204
games/unwritten/design/BIBLE.md
Normal file
204
games/unwritten/design/BIBLE.md
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# UNWRITTEN
|
||||||
|
### a McRogueFace RPG
|
||||||
|
**Tagline:** *The world is finished. The story is not.*
|
||||||
|
|
||||||
|
Creative bible. Authored by Fable (creative director). Implementation agents: this
|
||||||
|
document is canon. Where code and bible disagree, the bible wins unless SYSTEMS.md
|
||||||
|
or ARCHITECTURE.md overrides with a concrete number or contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Premise
|
||||||
|
|
||||||
|
The Maker finished the vale on a Tuesday. Every gear meshed. Every door hung true.
|
||||||
|
The sun ran on rails and the seasons turned on a mainspring, and when the last
|
||||||
|
cobblestone was set the Maker sat down at a desk in the Study, opened a book,
|
||||||
|
and prepared to write down *what should happen here*.
|
||||||
|
|
||||||
|
And could not think of a single thing.
|
||||||
|
|
||||||
|
The world was perfect, and pending. The Maker built a Custodian to keep it that
|
||||||
|
way — to sweep up any accident that might accidentally become a story — and then
|
||||||
|
the Maker rested, and has rested for three hundred years. The people of the vale
|
||||||
|
walk their routes. The shopkeeper polishes the same axe. The mayor rehearses a
|
||||||
|
speech for a festival that has never been scheduled. Nothing has ever happened,
|
||||||
|
because nothing has ever been *decided*.
|
||||||
|
|
||||||
|
Then one morning PIP wakes up holding a blank book that was not there yesterday,
|
||||||
|
and discovers they can do something no one in the vale has ever done:
|
||||||
|
**choose.**
|
||||||
|
|
||||||
|
The game is the vale's first story. Dialogue choices are diegetic — every time
|
||||||
|
the player picks an option, the Blank Book "hums" and writes it down. The
|
||||||
|
epilogue is that book read back: an enumeration of what the player actually
|
||||||
|
chose, framed as the story the Maker couldn't write.
|
||||||
|
|
||||||
|
**Tone:** warm, wry, a little melancholy. Terry Pratchett shaking hands with
|
||||||
|
Earthbound. The comedy comes from a world of perfectly maintained pointlessness;
|
||||||
|
the ache comes from characters who suspect they were sketched and never finished.
|
||||||
|
Never cynical. Never winking at the player so hard it forgets to be a place.
|
||||||
|
|
||||||
|
**Theme (the payload):** a made thing does not need its maker to know what it is
|
||||||
|
for. The stage is the artifact; the story is whoever shows up. Final line of the
|
||||||
|
game, non-negotiable: **"The Maker built the vale. You were what happened."**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The Party (6 playable, active party of 3)
|
||||||
|
|
||||||
|
Swapping: at Hollowbrook's fountain, at shrine camps, and via the party menu
|
||||||
|
outside battle. Battle offers SWAP as a full-turn action.
|
||||||
|
All sprite indices reference `assets/kenney_tinydungeon.png` (16px, 12 cols).
|
||||||
|
|
||||||
|
| # | Name | Sprite | Class / fantasy | Combat identity | Dialogue tag |
|
||||||
|
|---|------|--------|-----------------|-----------------|--------------|
|
||||||
|
| 1 | **PIP** | 85 | The Waker. An ordinary person, which in this vale is a miracle. | Fast rogue: high SPD, steals, double hits | `NIMBLE` |
|
||||||
|
| 2 | **SER BRAMBLE** | 96 | Oathknight who has guarded a door for 40 years because "someone wrote GUARD once and nothing since." | Tank: taunt/guard-party, damage grows as he bleeds | `OATH` |
|
||||||
|
| 3 | **MOTH** | 84 | Lamplighter of the Order of the Wick. Keeps candles ready for a light no one ever named. | Healer/caster: heal, AoE radiance | `LITURGY` |
|
||||||
|
| 4 | **VERA** | 111 | Alchemist selling "possibility tonics" that have never worked, because nothing is possible yet. | Debuffer/AoE: acid flasks, tonics | `ALCHEMY` |
|
||||||
|
| 5 | **CANTOR** | 109 | A chord-golem the Maker built to play the fanfare when the story began. Still holding the first note. | Slow nuker: stuns constructs/undead, huge single hits | `RESONANCE` |
|
||||||
|
| 6 | **NYX** | 121 | The Unlived. A character the Maker sketched, almost loved, and abandoned. Not dead — *never alive*. | Glass cannon: crits, evasion, cannot be healed by items (only MOTH's Kindle or shrine rest) | `SHADOW` |
|
||||||
|
|
||||||
|
**Recruitment paths** (dialogue-driven; see SCRIPT files for the actual scenes):
|
||||||
|
- **PIP** — starter.
|
||||||
|
- **BRAMBLE** — Act 1, Hollowbrook north door. Joins when given a *new* oath
|
||||||
|
("guard the people as they walk through it"). An `OATH`-flavored option makes
|
||||||
|
it graceful; brute-force option also works but sets `bramble_grumbles`.
|
||||||
|
- **MOTH** — Act 1, Gearwood Cold Shrine. Joins after the player relights the
|
||||||
|
shrine brazier (interaction, no check) and names what the light is for
|
||||||
|
(3-way choice; remembered; quoted back in the epilogue).
|
||||||
|
- **VERA** — Act 1, Griselda's shop dispute. Side with Vera → joins immediately,
|
||||||
|
Griselda's prices +25% (`griselda_grudge`). Side with Griselda → Vera storms
|
||||||
|
out; she can be recruited in Act 2 outside the Undercroft *only* via an
|
||||||
|
apology that passes `ALCHEMY` (gift a Bomb Flask) or `NIMBLE` (return the
|
||||||
|
ledger page Pip lifted). If never reconciled, she runs a stall in Act 3 and
|
||||||
|
the roster is 5.
|
||||||
|
- **CANTOR** — Act 2, Undercroft Chord Hall. Dormant. The Book writes his name:
|
||||||
|
the first thing the player ever *writes* rather than picks. (Scripted scene;
|
||||||
|
the choice is which name to write — "Cantor" / "his serial CH-0RD" / player
|
||||||
|
picks silence, which recruits him as CANTOR anyway but sets `cantor_quiet`,
|
||||||
|
changing his battle bark and one Custodian line.)
|
||||||
|
- **NYX** — Act 3, greyed Hollowbrook, only visible standing where no NPC ever
|
||||||
|
stands (the empty plinth by the fountain). If the player earlier asked Quill
|
||||||
|
about "the ones who never woke" (`asked_unwoken`), she recognizes being
|
||||||
|
remembered and joins warmly; otherwise a `SHADOW`-less party must pass a
|
||||||
|
harder choice gate (offer her the Book to read → she joins, `nyx_read_book`).
|
||||||
|
Missable: if both fail she appears at the epilogue anyway, unrecruited, with
|
||||||
|
one devastating gentle line.
|
||||||
|
|
||||||
|
## 3. Recurring NPCs (4, non-party)
|
||||||
|
|
||||||
|
| Name | Sprite | Role | Arc |
|
||||||
|
|------|--------|------|-----|
|
||||||
|
| **FERRYMAN ODD** | 87 | Poles the canal barge between areas. The only fast-travel. | Asks one question per ride about your latest big choice; never comments, just writes your answer on the hull among hundreds of blank planks. Final ride: reveals he was the Maker's *first sketch* — "I only know one route. The Maker never wrote me a second." If the player promises to write him one (`odd_promised`), the epilogue gives him a new river. |
|
||||||
|
| **MAYOR QUILL** | 100 | Anxious mayor of Hollowbrook. Quest-giver. | Confidence arc: the player either decides *for* him (fast, sets `quill_dependent`) or asks his opinion and endorses it (slower, sets `quill_confident`). In Act 3 a confident Quill organizes the townsfolk into the Chorus (pre-battle buff vs the Custodian); a dependent Quill loops harder than anyone when the town greys. |
|
||||||
|
| **GRISELDA** | 88 | Smith & shopkeeper. Sells gear and items. | Rival-or-mentor to Vera (see Vera). If befriended (`griselda_friend`: side with her OR reconcile everyone in Act 3), she reforges the Gatekeeper's Tooth into the best weapon, **Toothed Regret**, free. Otherwise it stays a trinket. |
|
||||||
|
| **BELL** | 110 | The Custodian's herald. Small, red, immaculate, courteous. | Appears exactly 4 times: Gearwood clearing (curious), Undercroft gate (warning), greyed Hollowbrook (apologetic — "I *liked* the bakery. But it was becoming a story."), Custodian battle (component). With `CANTOR` in party + Story Points >= 6, the in-battle `[RESONANCE] Ring Bell backward` option makes Bell defect, skipping Custodian phase 2 and unlocking the "Rung True" epilogue variant. |
|
||||||
|
|
||||||
|
## 4. Antagonists / Bosses (2)
|
||||||
|
|
||||||
|
- **THE GATEKEEPER** — Act 2 boss. Sprite 20 (gargoyle face) at scale 7, flanked
|
||||||
|
by two Chime-Imps (110, hsl-shifted teal). A wall with opinions. It keeps the
|
||||||
|
deep vale "pending" and considers adventurers a filing error. Attacks: Stone
|
||||||
|
Stare (single, heavy), Toll (AoE, telegraphed one turn ahead — "The Gatekeeper
|
||||||
|
inhales."), Summon Chime (replaces dead imps). Talk option: `[OATH]` Bramble
|
||||||
|
formally relieves it of duty — removes its DEF buff for the whole fight.
|
||||||
|
Drop: **Gatekeeper's Tooth** (key item).
|
||||||
|
- **THE CUSTODIAN** — final boss, Maker's Study. Sprite 41 (ornate mechanism) at
|
||||||
|
scale 8 with two slowly rotating gold Frame halos (`rotation` animate, loop).
|
||||||
|
Not evil. It is doing *exactly its job*: keeping the world clean, complete,
|
||||||
|
and pending. Phase 1: Sweep (AoE), Redact (removes one party member's skills
|
||||||
|
for 2 turns — "You are simpler now"), Polish (self-heal small). Phase 2 (under
|
||||||
|
50%): summons Bell + a grey Echo of the starter, gains Revert (undoes the last
|
||||||
|
damage dealt unless a NEW action type was used since — mechanically rewards
|
||||||
|
variety, thematically: repetition is what it eats).
|
||||||
|
Talk options (each once): `[LITURGY]` name the light (MAG down),
|
||||||
|
`[RESONANCE]` ring Bell backward (Bell defects, see above), `[points >= 8]`
|
||||||
|
"Read it the Book so far" — it hesitates: skips its next turn.
|
||||||
|
No drop. The reward is the epilogue.
|
||||||
|
|
||||||
|
## 5. Acts, areas, and the trope checklist
|
||||||
|
|
||||||
|
**ACT 1 — Hollowbrook & the Gearwood.** Hub town (fountain, shop, mayor's
|
||||||
|
porch, the North Door, ferry dock). Recruit Bramble, resolve shop dispute
|
||||||
|
(Vera), ferry to Gearwood: soft encounters (bats, slimes, spiders), Cold Shrine
|
||||||
|
(recruit Moth, camp/swap point), find the Undercroft stair + Brass Key.
|
||||||
|
Bell appearance #1.
|
||||||
|
|
||||||
|
**ACT 2 — The Undercroft.** Proper dungeon: FOV fog-of-war, wandering visible
|
||||||
|
encounters (skeletons, echoes, scarabs), loot chests (one is sprite 92, a mimic
|
||||||
|
— fight it, it drops well), the Chord Hall (recruit Cantor), optional Vera
|
||||||
|
reconciliation outside the gate. Bell appearance #2. **BOSS: Gatekeeper.**
|
||||||
|
|
||||||
|
**ACT 3 — Hollowbrook, Reverted → the Maker's Study.**
|
||||||
|
The Custodian's answer to the Gatekeeper falling: it *reverts* Hollowbrook.
|
||||||
|
The player returns to the same map, greyed: desaturated ColorLayers, NPCs
|
||||||
|
standing at their Act-1 positions repeating one line each, the fountain dry,
|
||||||
|
Griselda's shop shuttered (unless `griselda_friend`), music/pulse gone.
|
||||||
|
**This is the "return to a modified area" beat and it must land hard —
|
||||||
|
identical geometry, drained palette.** Re-wake each NPC with a tag- or
|
||||||
|
flag-gated choice (each rewake +1 Story Point). Recruit Nyx at the empty
|
||||||
|
plinth. Bell appearance #3. Ferry to the Study — paper-white surreal area,
|
||||||
|
ink-hatched walls, floating desk — **BOSS: Custodian** (Bell appearance #4),
|
||||||
|
then epilogue.
|
||||||
|
|
||||||
|
**Trope contract (all must ship):**
|
||||||
|
- [x] Starter + 5 recruits, active party of 3 → roster above
|
||||||
|
- [x] 4 recurring non-party NPCs → Odd, Quill, Griselda, Bell
|
||||||
|
- [x] Return to a modified area → greyed Hollowbrook
|
||||||
|
- [x] 2 boss fights → Gatekeeper, Custodian
|
||||||
|
- [x] Items, shops, loot, levels, skills, stats → SYSTEMS.md
|
||||||
|
- [x] Dialogue drives outcome; skill checks & past choices gate options →
|
||||||
|
recruitment paths, rewakes, boss Talk actions, epilogue variants
|
||||||
|
|
||||||
|
## 6. Aesthetic direction
|
||||||
|
|
||||||
|
**Palette (core/palette.py must define these exactly):**
|
||||||
|
- `INK = (11, 12, 16)` — global background
|
||||||
|
- `PARCH = (232, 220, 192)` — primary text
|
||||||
|
- `GOLD = (212, 169, 78)` — accents, selection, the Book
|
||||||
|
- `DIM = (138, 132, 118)` — secondary text, locked options
|
||||||
|
- `BLOOD = (205, 66, 57)` — damage, HP low
|
||||||
|
- `TEAL = (80, 190, 180)` — SP, magic
|
||||||
|
- `GRASS = (96, 160, 92)` — heals, XP
|
||||||
|
- `PANEL = (22, 24, 32)` — UI panel fill; outline (52, 60, 80), 2px
|
||||||
|
- Grey-world wash (Act 3): every area color lerped 70% toward (58, 58, 62).
|
||||||
|
|
||||||
|
**Area ground palettes (ColorLayer only — NO terrain tiles; per John's
|
||||||
|
constraint, the tileset is for characters/items/props only):**
|
||||||
|
- Hollowbrook: umber field (54,44,34), paths (84,66,44), water (38,66,86),
|
||||||
|
building footprints (30,32,42) with GOLD doorway cells.
|
||||||
|
- Gearwood: moss (30,46,32) / (38,58,38) checker-ish variation, canopy-shadow
|
||||||
|
cells (22,34,26).
|
||||||
|
- Undercroft: (16,18,28) / (20,24,36), FOV fog via engine perspective colors.
|
||||||
|
- Maker's Study: **inverted palette** — parchment floor (214,204,178), ink
|
||||||
|
walls (28,26,24), everything UI-outlined in ink not gold. The world here is
|
||||||
|
literally paper.
|
||||||
|
|
||||||
|
**Sprites:** characters/props at scale 2.0 on the overworld grid (32px cells →
|
||||||
|
grid zoom handles it; see ARCHITECTURE), scale 4.0 for battle enemies, 5.0 for
|
||||||
|
dialogue portraits, 7-8 for bosses. Chunky and proud of it.
|
||||||
|
|
||||||
|
**Motion:** every UI transition is an easing tween, nothing teleports:
|
||||||
|
dialogue boxes slide up 12px + fade in; damage numbers float (EASE_OUT, 0.8s);
|
||||||
|
battle actors lunge 24px toward target and back; scene transitions via
|
||||||
|
`scene.activate(transition=...)` if available, else 0.25s full-screen INK frame
|
||||||
|
opacity fade. NPCs idle-bob (draw_pos y +-0.06, 1.6s loop). The Book hum:
|
||||||
|
a small gold Circle pulse behind the dialogue box whenever a choice is made.
|
||||||
|
|
||||||
|
**Sound:** none. The vale is waiting. (Also keeps scope sane.)
|
||||||
|
|
||||||
|
## 7. Voice samples (calibration for any text an agent must improvise)
|
||||||
|
|
||||||
|
- Quill: "It's a very good door. Best in the vale. We're all very proud of it
|
||||||
|
and nobody has ever once been through it."
|
||||||
|
- Griselda: "Forty years I've sharpened this axe. You want to know what it's
|
||||||
|
cut? Time, mostly."
|
||||||
|
- Bell: "Please don't be alarmed. Alarm is untidy."
|
||||||
|
- Odd: "One route. There and back. You'd think the water would get bored.
|
||||||
|
Water doesn't. I checked."
|
||||||
|
- Nyx: "You looked at the empty spot. Nobody looks at the empty spot."
|
||||||
|
- The Custodian: "I am not your enemy. I am your margin."
|
||||||
|
|
||||||
|
Improvised text must match: short sentences, concrete nouns, no modern slang,
|
||||||
|
no fourth-wall winks beyond what the premise already licenses.
|
||||||
152
games/unwritten/design/SYSTEMS.md
Normal file
152
games/unwritten/design/SYSTEMS.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# UNWRITTEN — Systems Spec (numbers are canon)
|
||||||
|
|
||||||
|
Playtime target: 30–45 minutes. Grind is abbreviated by design: XP is generous,
|
||||||
|
maps are small, encounters are visible (bump to fight, avoidable), and every
|
||||||
|
number below is tuned for "one sitting, slightly under-leveled feels tense but
|
||||||
|
fair."
|
||||||
|
|
||||||
|
## 1. Stats & leveling
|
||||||
|
|
||||||
|
Stats: `HP, SP, ATK, MAG, DEF, SPD`. Levels 1–8. XP to next level = `20 * level`
|
||||||
|
(cumulative table: 20/60/120/200/300/420/560). Whole party (all 6, benched
|
||||||
|
included) gains full XP — swapping is never punished.
|
||||||
|
|
||||||
|
Per-character base (level 1) and per-level growth:
|
||||||
|
|
||||||
|
| Char | HP | SP | ATK | MAG | DEF | SPD | growth (per level) |
|
||||||
|
|---------|-----|----|-----|-----|-----|-----|-------------------------------|
|
||||||
|
| PIP | 34 | 10 | 7 | 3 | 4 | 9 | +6 HP +2 SP +2 ATK +1 DEF +1 SPD |
|
||||||
|
| BRAMBLE | 46 | 8 | 6 | 2 | 7 | 4 | +9 HP +1 SP +1 ATK +2 DEF |
|
||||||
|
| MOTH | 28 | 16 | 3 | 8 | 3 | 6 | +4 HP +4 SP +2 MAG +1 DEF +1 SPD (alt levels) |
|
||||||
|
| VERA | 31 | 12 | 5 | 6 | 4 | 6 | +5 HP +3 SP +1 ATK +1 MAG +1 DEF +1 SPD (alt) |
|
||||||
|
| CANTOR | 40 | 12 | 8 | 6 | 6 | 2 | +7 HP +2 SP +2 ATK +1 MAG +1 DEF |
|
||||||
|
| NYX | 22 | 12 | 9 | 5 | 2 | 10 | +3 HP +3 SP +3 ATK +1 SPD |
|
||||||
|
|
||||||
|
("alt levels": the +1s marked (alt) apply on even levels only. Keep it simple in
|
||||||
|
code: a per-char list of 7 growth dicts is fine.)
|
||||||
|
|
||||||
|
NYX joins at the party's average level; everyone else joins at their scene's
|
||||||
|
scripted level (Bramble 1, Moth 2, Vera 2, Cantor 4).
|
||||||
|
|
||||||
|
## 2. Damage model
|
||||||
|
|
||||||
|
- Physical: `dmg = max(1, round((ATK * mult - DEF) * var))`, `var` uniform in
|
||||||
|
[0.85, 1.15].
|
||||||
|
- Magical: same with MAG and `DEF//2`.
|
||||||
|
- Crit: chance per skill (base 6%, NYX base 18%), crit = dmg * 1.5, gold flash.
|
||||||
|
- Guard: incoming dmg halved until next turn, +2 SP on guarding.
|
||||||
|
- Turn order: sort by SPD desc, ties random, recomputed each round. Shown as a
|
||||||
|
portrait ribbon top of battle screen.
|
||||||
|
|
||||||
|
## 3. Skills (unlock L1 unless noted; SP cost in parens)
|
||||||
|
|
||||||
|
| Char | Skill A | Skill B (unlocks L4) |
|
||||||
|
|------|---------|----------------------|
|
||||||
|
| PIP | **Twinstrike** (3): two hits at 0.7x each | **Pickpocket** (2): 0.6x dmg + steal bonus gold (8-20), once per enemy |
|
||||||
|
| BRAMBLE | **Shieldwall** (3): party takes half dmg until his next turn | **Vow Strike** (4): mult 1.0 + 0.1 per 10% HP Bramble is missing (max 2.0) |
|
||||||
|
| MOTH | **Kindle** (4): heal one ally `24 + 2*MAG`; works on NYX | **Moonflare** (6): AoE magic 1.1x |
|
||||||
|
| VERA | **Acid Flask** (4): AoE 0.7x + DEF -25% (2 turns) | **Tonic Toss** (3): heal one `16 + MAG` and clear debuffs (NOT Nyx) |
|
||||||
|
| CANTOR | **Resonate** (4): 1.2x magic; vs construct/undead also stun 1 turn | **Bass Drop** (7): single 2.2x, acts last that round |
|
||||||
|
| NYX | **Grave Chill** (3): 1.0x magic + SPD -30% (2 turns) | **Second Shadow** (5): her next attack this battle is a guaranteed crit; using it does not end her evasion |
|
||||||
|
|
||||||
|
NYX passive: 20% evasion. NYX cannot be healed by items (silently blocked with
|
||||||
|
battle log line "The tonic passes through her."); Kindle and shrine rest work.
|
||||||
|
|
||||||
|
## 4. Enemies
|
||||||
|
|
||||||
|
`hue` = hsl_shift degrees applied to the base sprite for area variants.
|
||||||
|
|
||||||
|
| Enemy | Sprite | HP | ATK | MAG | DEF | SPD | XP | Gold | Notes |
|
||||||
|
|-------|--------|----|-----|-----|-----|-----|----|------|-------|
|
||||||
|
| Gloom Bat | 120 | 14 | 5 | 0 | 1 | 8 | 6 | 4 | 25% chance to skip (flutters) |
|
||||||
|
| Hedge Slime | 108 | 20 | 4 | 0 | 3 | 2 | 7 | 5 | splits once at death into 1 Slimelet (8 HP) in Gearwood packs of <=2 |
|
||||||
|
| Loom Spider | 122 | 16 | 6 | 0 | 2 | 6 | 8 | 6 | Web: SPD-30% 2 turns, every 3rd action |
|
||||||
|
| Waxwing Scarab | 123 | 24 | 6 | 0 | 5 | 3 | 10 | 8 | guards when under 50% |
|
||||||
|
| Unwoken Skeleton | 86 | 26 | 8 | 0 | 3 | 5 | 13 | 10 | undead (Resonate stuns) |
|
||||||
|
| Pale Echo | 121, hue -40 | 18 | 0 | 8 | 1 | 7 | 12 | 0 | magic attack; drops no gold, drops Chalk (see items) 40% |
|
||||||
|
| Chime-Imp | 110, hue +160 | 22 | 7 | 4 | 3 | 7 | 14 | 12 | construct (Resonate stuns) |
|
||||||
|
| Mimic | 92 | 45 | 10 | 0 | 6 | 1 | 30 | 60 | the Undercroft chest that bites; always drops Second Trinket |
|
||||||
|
| **GATEKEEPER** | 20 | 150 | 11 | 6 | 8 (13 buffed) | 3 | 80 | 100 | boss, see BIBLE §4; starts with Ward (+5 DEF) until [OATH] talk |
|
||||||
|
| **CUSTODIAN** | 41 | 210 | 9 | 12 | 7 | 6 | 0 | 0 | boss, two phases, see BIBLE §4 |
|
||||||
|
|
||||||
|
Encounter packs (visible wandering entities on the map; bump = battle):
|
||||||
|
- Gearwood: 2-3 of {bat, slime, spider}; one fixed scarab pair guarding the shrine chest.
|
||||||
|
- Undercroft: 2-3 of {skeleton, echo, scarab}; chime-imp pairs near the boss door.
|
||||||
|
- Act 3 Study approach: 2x pale echo packs only (the world is running out of things).
|
||||||
|
Respawn: none. ~9-11 packs total in the game. Flee: 70% base, never from bosses.
|
||||||
|
|
||||||
|
## 5. Items & equipment
|
||||||
|
|
||||||
|
Consumables (usable in battle or menu):
|
||||||
|
|
||||||
|
| Item | Sprite | Cost | Effect |
|
||||||
|
|------|--------|------|--------|
|
||||||
|
| Bread | 66 | 8 | heal 12 |
|
||||||
|
| Red Tonic | 115 | 20 | heal 30 |
|
||||||
|
| Green Salve | 114 | 25 | clear debuffs + heal 10 |
|
||||||
|
| Blue Draught | 116 | 30 | restore 15 SP |
|
||||||
|
| Bomb Flask | 113 | 35 | 22 AoE damage (item, anyone can throw) |
|
||||||
|
| Chalk | 102 | — (drop only) | revive fallen ally at 40% HP. Rare: Echoes drop it. Griselda sells ONE, 90g. |
|
||||||
|
|
||||||
|
Equipment: one weapon + one trinket per character. No armor slot (DEF comes
|
||||||
|
from trinkets); keep the equip screen simple.
|
||||||
|
|
||||||
|
| Weapon | Sprite | Cost | Effect | Notes |
|
||||||
|
|--------|--------|------|--------|-------|
|
||||||
|
| Knife | 105 | 15 | +2 ATK | starter tier |
|
||||||
|
| Short Sword | 103 | 40 | +4 ATK | |
|
||||||
|
| Broad Blade | 106 | 90 | +7 ATK | Act 2 shop unlock |
|
||||||
|
| Worn Cudgel | 117 | 15 | +2 ATK | Bramble/Cantor flavor |
|
||||||
|
| Twin Axe | 119 | 90 | +6 ATK, +2 SPD | |
|
||||||
|
| Wick Staff | 129 | 45 | +4 MAG | |
|
||||||
|
| Tide Rod | 130 | 95 | +7 MAG | Act 2 shop unlock |
|
||||||
|
| **Toothed Regret** | 118 | — | +10 ATK, +6 MAG | Griselda reforges the Gatekeeper's Tooth, free, only if `griselda_friend` |
|
||||||
|
|
||||||
|
| Trinket | Sprite | Cost | Effect |
|
||||||
|
|---------|--------|------|--------|
|
||||||
|
| River Stone | 101 | 25 | +2 DEF |
|
||||||
|
| Waxed Charm | 56 | 60 | +3 DEF, +5 max HP |
|
||||||
|
| Second Trinket | 91 | — | +2 all stats (Mimic drop; the name is the joke) |
|
||||||
|
| Gatekeeper's Tooth | 107 | — | +3 ATK trinket until/unless reforged |
|
||||||
|
|
||||||
|
Key items (menu tab, not usable): Blank Book, Brass Key, Gatekeeper's Tooth
|
||||||
|
(moves to equipment if reforged), The Wick (Moth's candle, set during her
|
||||||
|
recruitment naming choice).
|
||||||
|
|
||||||
|
Shop (Griselda): Act 1 stock through Broad-Blade tier locked; Act 2 unlocks the
|
||||||
|
90+ tier. Prices x1.25 if `griselda_grudge`. Buyback at half price. Gold is
|
||||||
|
called **cogs**.
|
||||||
|
|
||||||
|
Starting kit: Pip L1, Knife equipped, 2 Bread, 1 Red Tonic, 30 cogs.
|
||||||
|
|
||||||
|
## 6. Flags & Story Points (single source of truth for the writing)
|
||||||
|
|
||||||
|
`state.flags: set[str]`, `state.points: int` (Story Points, shown as small gold
|
||||||
|
tally "the Book grows heavier": +1 each on the moments marked below).
|
||||||
|
|
||||||
|
Canonical flags (agents: use EXACTLY these names):
|
||||||
|
`quill_confident | quill_dependent | vera_joined_early | vera_grudge |
|
||||||
|
vera_reconciled | griselda_friend | griselda_grudge | bramble_grumbles |
|
||||||
|
asked_unwoken | cantor_quiet | odd_promised | nyx_read_book | nyx_missed |
|
||||||
|
moth_light_name_dark / _lost / _small (one of three) | mimic_slain |
|
||||||
|
gatekeeper_relieved | bell_defected | rewoke_quill | rewoke_griselda |
|
||||||
|
rewoke_odd | book_read_custodian`
|
||||||
|
|
||||||
|
Story Points (+1 each): recruiting each of the 5, each of the 3 rewakes,
|
||||||
|
`odd_promised`, `asked_unwoken`, resolving the shop dispute either way,
|
||||||
|
`gatekeeper_relieved`. (Max realistic ~12; thresholds: Bell defect needs >= 6
|
||||||
|
plus Cantor present; "Read the Book" battle option needs >= 8.)
|
||||||
|
|
||||||
|
## 7. Epilogue matrix
|
||||||
|
|
||||||
|
The epilogue is the Book read aloud: 6-10 short pages (full-screen INK panels,
|
||||||
|
centered PARCH text, one keypress each). Assembled from:
|
||||||
|
1. Fixed opening ("This is the story of the vale, as it actually happened.").
|
||||||
|
2. One page per major choice: the door, the dispute, Moth's light-name, Cantor's
|
||||||
|
name, the rewakes, Odd's promise (new river page), Bell's fate.
|
||||||
|
3. Nyx page: recruited-warm / recruited-late / **missed** ("There was someone
|
||||||
|
else, almost. Look for her, next time. She waits by the fountain.").
|
||||||
|
4. Quill arc page (confident: he schedules the festival, finally; dependent: he
|
||||||
|
waits for you to say it's time, and you do, and that's alright too).
|
||||||
|
5. Fixed closing: "The Maker built the vale. You were what happened." then the
|
||||||
|
title card UNWRITTEN — except now stamped WRITTEN in gold.
|
||||||
149
games/unwritten/main.py
Normal file
149
games/unwritten/main.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""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()
|
||||||
1742
games/unwritten/systems/battle.py
Normal file
1742
games/unwritten/systems/battle.py
Normal file
File diff suppressed because it is too large
Load diff
291
games/unwritten/systems/dialogue.py
Normal file
291
games/unwritten/systems/dialogue.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""UNWRITTEN - dialogue runner. Owner: Agent A.
|
||||||
|
|
||||||
|
run_scene(scene_id, on_done=None, stack=None) loads a node dict from the merged
|
||||||
|
SCENES of data.script_act1/2/3, drives a core.ui.DialogueBox through the
|
||||||
|
InputStack, applies effects/actions to state.GS, and hands off scene-changing
|
||||||
|
actions (shop, swap_menu, battle, goto_area, epilogue) to the HOOKS registry
|
||||||
|
that Agent B/C register.
|
||||||
|
|
||||||
|
Supported constructs (every one actually used by the authored scripts):
|
||||||
|
req: None | ("tag",X) | ("flag",X) | ("flag_not",X) | ("party",X)
|
||||||
|
| ("points",N) | ("item",item_id)
|
||||||
|
effects: ("flag",x) | ("points",n) | ("gold",n) | ("item",id,n) [n<0 removes]
|
||||||
|
| ("act",n) | ("key_item_remove",id)
|
||||||
|
actions: end | shop | heal_party | swap_menu | recruit:CHARID:LEVEL
|
||||||
|
| battle:pack_id | goto_area:area:spawn | epilogue | act:N
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
from core import ui
|
||||||
|
from core.inputstack import InputStack
|
||||||
|
from systems.state import GS
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- HOOKS
|
||||||
|
# Other agents register scene-changing handlers here, e.g.
|
||||||
|
# systems.dialogue.HOOKS["battle"] = battle.start_from_dialogue
|
||||||
|
# Signatures:
|
||||||
|
# HOOKS["shop"]() (Agent C)
|
||||||
|
# HOOKS["swap_menu"]() (Agent C) - should be modal/returning
|
||||||
|
# HOOKS["battle"](pack_id) (Agent B)
|
||||||
|
# HOOKS["goto_area"](area, spawn) (Agent C)
|
||||||
|
# HOOKS["epilogue"]() (Agent C)
|
||||||
|
HOOKS = {}
|
||||||
|
|
||||||
|
# The runner currently driving a dialogue, if any. Exposed as a test seam so a
|
||||||
|
# scripted playthrough can drive the REAL runner (apply effects, goto nodes,
|
||||||
|
# fire deferred actions) by index instead of injecting keypresses. None when no
|
||||||
|
# dialogue is open. See select_choice()/advance() below.
|
||||||
|
CURRENT = None
|
||||||
|
|
||||||
|
|
||||||
|
def _hook(name):
|
||||||
|
if name not in HOOKS:
|
||||||
|
raise KeyError(
|
||||||
|
"dialogue action '%s' needs a handler: set "
|
||||||
|
"systems.dialogue.HOOKS['%s'] (owning agent has not registered it)"
|
||||||
|
% (name, name))
|
||||||
|
return HOOKS[name]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- scene lookup
|
||||||
|
def _all_scenes():
|
||||||
|
scenes = {}
|
||||||
|
from data import script_act1, script_act2, script_act3
|
||||||
|
for mod in (script_act1, script_act2, script_act3):
|
||||||
|
for k, v in mod.SCENES.items():
|
||||||
|
if k in scenes:
|
||||||
|
raise KeyError("duplicate dialogue scene id %r across script "
|
||||||
|
"files" % (k,))
|
||||||
|
scenes[k] = v
|
||||||
|
return scenes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- req / effects
|
||||||
|
def req_met(req):
|
||||||
|
if req is None:
|
||||||
|
return True
|
||||||
|
kind = req[0]
|
||||||
|
if kind == "flag":
|
||||||
|
return GS.has(req[1])
|
||||||
|
if kind == "flag_not":
|
||||||
|
return not GS.has(req[1])
|
||||||
|
if kind == "tag":
|
||||||
|
return req[1] in GS.party_tags()
|
||||||
|
if kind == "party":
|
||||||
|
return GS.in_party(req[1])
|
||||||
|
if kind == "points":
|
||||||
|
return GS.points >= req[1]
|
||||||
|
if kind == "item":
|
||||||
|
return GS.inventory.get(req[1], 0) > 0
|
||||||
|
raise ValueError("unknown req form %r" % (req,))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_effects(effects):
|
||||||
|
for e in effects or []:
|
||||||
|
kind = e[0]
|
||||||
|
if kind == "flag":
|
||||||
|
GS.add_flag(e[1])
|
||||||
|
elif kind == "points":
|
||||||
|
GS.add_points(e[1]) # triggers the Book hum via GS.hum_hook
|
||||||
|
elif kind == "gold":
|
||||||
|
GS.add_gold(e[1])
|
||||||
|
elif kind == "item":
|
||||||
|
n = e[2]
|
||||||
|
if n >= 0:
|
||||||
|
GS.grant(e[1], n)
|
||||||
|
else:
|
||||||
|
GS.take(e[1], -n)
|
||||||
|
elif kind == "act":
|
||||||
|
GS.act = int(e[1])
|
||||||
|
elif kind == "key_item_remove":
|
||||||
|
GS.remove_key_item(e[1])
|
||||||
|
else:
|
||||||
|
raise ValueError("unknown effect form %r" % (e,))
|
||||||
|
|
||||||
|
|
||||||
|
# actions that fire when the player ADVANCES past a text node (deferred)
|
||||||
|
_DEFERRED = ("end", "shop", "swap_menu", "battle", "goto_area", "epilogue")
|
||||||
|
# transfer actions hand the scene off entirely; the runner finishes afterward
|
||||||
|
_TRANSFER = ("shop", "battle", "goto_area", "epilogue")
|
||||||
|
|
||||||
|
|
||||||
|
def _do_recruit(action):
|
||||||
|
_, char_id, level = action.split(":")
|
||||||
|
GS.recruit(char_id, int(level))
|
||||||
|
|
||||||
|
|
||||||
|
class _Runner:
|
||||||
|
def __init__(self, scene, scene_dict, on_done, stack):
|
||||||
|
self.scene = scene
|
||||||
|
self.sc = scene_dict
|
||||||
|
self.on_done = on_done
|
||||||
|
self.owns_stack = stack is None
|
||||||
|
self.stack = stack if stack is not None else InputStack(scene)
|
||||||
|
self.box = ui.DialogueBox(scene.children)
|
||||||
|
self.node = None # current node (test seam)
|
||||||
|
GS.hum_hook = self.box.hum
|
||||||
|
self.stack.push(self.box.handle, "dialogue")
|
||||||
|
global CURRENT
|
||||||
|
CURRENT = self
|
||||||
|
|
||||||
|
# ---- lifecycle ---------------------------------------------------------
|
||||||
|
def start(self):
|
||||||
|
self.goto(self.sc["start"])
|
||||||
|
|
||||||
|
def finish(self):
|
||||||
|
global CURRENT
|
||||||
|
if CURRENT is self:
|
||||||
|
CURRENT = None
|
||||||
|
self.node = None
|
||||||
|
self.box.destroy()
|
||||||
|
self.stack.pop("dialogue")
|
||||||
|
GS.hum_hook = None
|
||||||
|
if self.on_done:
|
||||||
|
self.on_done()
|
||||||
|
|
||||||
|
# ---- node flow ---------------------------------------------------------
|
||||||
|
def goto(self, node_id):
|
||||||
|
if node_id not in self.sc["nodes"]:
|
||||||
|
raise KeyError("dialogue target node %r not found" % (node_id,))
|
||||||
|
node = self.sc["nodes"][node_id]
|
||||||
|
self.enter(node)
|
||||||
|
|
||||||
|
def enter(self, node):
|
||||||
|
# 1. enter-time effects
|
||||||
|
apply_effects(node.get("effects"))
|
||||||
|
# 2. enter-time (immediate) actions
|
||||||
|
action = node.get("action")
|
||||||
|
if action:
|
||||||
|
name = action.split(":")[0]
|
||||||
|
if name == "heal_party":
|
||||||
|
GS.heal_party()
|
||||||
|
elif name == "recruit":
|
||||||
|
_do_recruit(action)
|
||||||
|
elif name == "act":
|
||||||
|
GS.act = int(action.split(":")[1])
|
||||||
|
# 3. present
|
||||||
|
self.present(node)
|
||||||
|
|
||||||
|
def present(self, node):
|
||||||
|
self.node = node
|
||||||
|
speaker = node.get("speaker", "NARRATOR")
|
||||||
|
text = node.get("text", "")
|
||||||
|
choices = node.get("choices")
|
||||||
|
if choices:
|
||||||
|
display = [(c["label"], req_met(c.get("req"))) for c in choices]
|
||||||
|
self.box.show_node(speaker, text, choices=display,
|
||||||
|
on_choice=lambda i, n=node: self.pick(n, i))
|
||||||
|
else:
|
||||||
|
self.box.show_node(speaker, text,
|
||||||
|
on_advance=lambda n=node: self.advance(n))
|
||||||
|
|
||||||
|
def pick(self, node, index):
|
||||||
|
choice = node["choices"][index]
|
||||||
|
apply_effects(choice.get("effects"))
|
||||||
|
nxt = choice.get("next")
|
||||||
|
if nxt is None:
|
||||||
|
raise KeyError("choice %r in a dialogue node has no 'next'"
|
||||||
|
% (choice.get("label"),))
|
||||||
|
self.goto(nxt)
|
||||||
|
|
||||||
|
def advance(self, node):
|
||||||
|
action = node.get("action")
|
||||||
|
if action:
|
||||||
|
name = action.split(":")[0]
|
||||||
|
if name in _DEFERRED:
|
||||||
|
self._do_deferred(action, name, node)
|
||||||
|
return
|
||||||
|
nxt = node.get("next")
|
||||||
|
if nxt is not None:
|
||||||
|
self.goto(nxt)
|
||||||
|
else:
|
||||||
|
self.finish()
|
||||||
|
|
||||||
|
def _do_deferred(self, action, name, node):
|
||||||
|
if name == "end":
|
||||||
|
self.finish()
|
||||||
|
return
|
||||||
|
if name == "battle":
|
||||||
|
pack = action.split(":", 1)[1]
|
||||||
|
_hook("battle")(pack)
|
||||||
|
self.finish()
|
||||||
|
return
|
||||||
|
if name == "goto_area":
|
||||||
|
_, area, spawn = action.split(":")
|
||||||
|
_hook("goto_area")(area, spawn)
|
||||||
|
self.finish()
|
||||||
|
return
|
||||||
|
if name == "epilogue":
|
||||||
|
_hook("epilogue")()
|
||||||
|
self.finish()
|
||||||
|
return
|
||||||
|
if name == "shop":
|
||||||
|
_hook("shop")()
|
||||||
|
self.finish()
|
||||||
|
return
|
||||||
|
if name == "swap_menu":
|
||||||
|
# returning hook: run it, then continue if the node chains onward
|
||||||
|
_hook("swap_menu")()
|
||||||
|
nxt = node.get("next")
|
||||||
|
if nxt is not None:
|
||||||
|
self.goto(nxt)
|
||||||
|
else:
|
||||||
|
self.finish()
|
||||||
|
return
|
||||||
|
raise ValueError("unhandled deferred action %r" % (action,))
|
||||||
|
|
||||||
|
|
||||||
|
def run_scene(scene_id, on_done=None, stack=None):
|
||||||
|
"""Run a dialogue scene on mcrfpy.current_scene. If `stack` (an InputStack)
|
||||||
|
is given, the dialogue pushes onto it (so overworld movement stays frozen
|
||||||
|
beneath); otherwise a fresh InputStack is bound to the scene."""
|
||||||
|
scene = mcrfpy.current_scene
|
||||||
|
scenes = _all_scenes()
|
||||||
|
if scene_id not in scenes:
|
||||||
|
raise KeyError("unknown dialogue scene id %r" % (scene_id,))
|
||||||
|
runner = _Runner(scene, scenes[scene_id], on_done, stack)
|
||||||
|
runner.start()
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- test seam
|
||||||
|
def current_choice_labels():
|
||||||
|
"""Labels of the current node's choices as (label, enabled) tuples, or None
|
||||||
|
if the current node is not a choice node. For scripted playthroughs."""
|
||||||
|
if CURRENT is None or CURRENT.node is None:
|
||||||
|
return None
|
||||||
|
choices = CURRENT.node.get("choices")
|
||||||
|
if not choices:
|
||||||
|
return None
|
||||||
|
return [(c["label"], req_met(c.get("req"))) for c in choices]
|
||||||
|
|
||||||
|
|
||||||
|
def select_choice(index):
|
||||||
|
"""Pick choice `index` on the open dialogue node, driving the REAL runner
|
||||||
|
(applies the choice's effects, follows its 'next'). Raises if no choice node
|
||||||
|
is open or the choice is disabled by its req."""
|
||||||
|
if CURRENT is None or CURRENT.node is None:
|
||||||
|
raise RuntimeError("select_choice: no dialogue open")
|
||||||
|
node = CURRENT.node
|
||||||
|
choices = node.get("choices")
|
||||||
|
if not choices:
|
||||||
|
raise RuntimeError("select_choice: current node has no choices")
|
||||||
|
if index < 0 or index >= len(choices):
|
||||||
|
raise IndexError("select_choice: index %d out of range (%d choices)"
|
||||||
|
% (index, len(choices)))
|
||||||
|
if not req_met(choices[index].get("req")):
|
||||||
|
raise RuntimeError("select_choice: choice %d is disabled (%r)"
|
||||||
|
% (index, choices[index].get("label")))
|
||||||
|
CURRENT.pick(node, index)
|
||||||
|
|
||||||
|
|
||||||
|
def advance():
|
||||||
|
"""Advance the open text node (as if the player pressed space), driving the
|
||||||
|
REAL runner (fires deferred actions like battle/goto_area/shop/epilogue, or
|
||||||
|
follows 'next', or finishes). Raises if a choice is pending."""
|
||||||
|
if CURRENT is None or CURRENT.node is None:
|
||||||
|
raise RuntimeError("advance: no dialogue open")
|
||||||
|
node = CURRENT.node
|
||||||
|
if node.get("choices"):
|
||||||
|
raise RuntimeError("advance: current node is a choice node; use "
|
||||||
|
"select_choice(i)")
|
||||||
|
CURRENT.advance(node)
|
||||||
217
games/unwritten/systems/epilogue.py
Normal file
217
games/unwritten/systems/epilogue.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
"""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()
|
||||||
816
games/unwritten/systems/overworld.py
Normal file
816
games/unwritten/systems/overworld.py
Normal file
|
|
@ -0,0 +1,816 @@
|
||||||
|
"""UNWRITTEN - the explorable world. Owner: Agent C.
|
||||||
|
|
||||||
|
Builds Grid-based area scenes from data.maps.AREAS: ColorLayer terrain, a player
|
||||||
|
Entity with WASD/arrow movement, props/NPCs/encounters, exits, doors, camps, a
|
||||||
|
persistent HUD, FOV for the Undercroft, and the Act-3 grey-town beat.
|
||||||
|
|
||||||
|
Public surface (ARCHITECTURE section 3):
|
||||||
|
enter_area(area_id, spawn="start") build/reuse "ow_<area>" scene
|
||||||
|
refresh_area() re-theme after flag changes (grey town)
|
||||||
|
|
||||||
|
Registers at import:
|
||||||
|
dialogue.HOOKS["goto_area"] = lambda area, spawn="start": _goto(area, spawn)
|
||||||
|
|
||||||
|
Integration contract for Agent B (battle) / Agent D:
|
||||||
|
dialogue.HOOKS["battle"](pack_id, on_victory=None) must accept an optional
|
||||||
|
on_victory keyword. The overworld passes a callback that removes the bumped
|
||||||
|
encounter entity and remembers the kill via GS flag
|
||||||
|
f"enc_<area>_<x>_<y>_done" so it stays gone on re-entry.
|
||||||
|
data.enemies (Agent B, parallel) is imported LAZILY; it must expose either
|
||||||
|
pack_first_sprite(pack)->int OR PACKS[pack] (list) + ENEMIES[id]["sprite"].
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
|
||||||
|
from core import palette
|
||||||
|
from core import assets
|
||||||
|
from core import ui
|
||||||
|
from core import tween
|
||||||
|
from core.inputstack import InputStack
|
||||||
|
from systems import dialogue
|
||||||
|
from systems.state import GS
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- module state
|
||||||
|
WORLD = None # the live _World, or None
|
||||||
|
GREY_TEX = None # desaturated texture for pre-rewake NPCs (lazy)
|
||||||
|
TEST_ENEMY_FALLBACK = None # set to a sprite index by tests when data.enemies absent
|
||||||
|
|
||||||
|
# rewakeable NPCs -> their story flag (Act-3 grey beat)
|
||||||
|
REWAKE_FLAG = {"QUILL": "rewoke_quill", "GRISELDA": "rewoke_griselda",
|
||||||
|
"ODD": "rewoke_odd"}
|
||||||
|
|
||||||
|
_MOVE_MS = 140 # hold-to-repeat interval
|
||||||
|
_STEP_DUR = 0.12 # slide animation per step
|
||||||
|
_WANDER_MS = 900
|
||||||
|
|
||||||
|
_DIRS = {
|
||||||
|
mcrfpy.Key.W: (0, -1), mcrfpy.Key.UP: (0, -1),
|
||||||
|
mcrfpy.Key.S: (0, 1), mcrfpy.Key.DOWN: (0, 1),
|
||||||
|
mcrfpy.Key.A: (-1, 0), mcrfpy.Key.LEFT: (-1, 0),
|
||||||
|
mcrfpy.Key.D: (1, 0), mcrfpy.Key.RIGHT: (1, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _grey_tex():
|
||||||
|
global GREY_TEX
|
||||||
|
if GREY_TEX is None:
|
||||||
|
GREY_TEX = assets.TEX.hsl_shift(0, -100, -15)
|
||||||
|
return GREY_TEX
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp8(v):
|
||||||
|
return 0 if v < 0 else (255 if v > 255 else int(v))
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_offset(x, y):
|
||||||
|
"""Deterministic small per-cell variation for '.' ground (+-4)."""
|
||||||
|
h = (x * 73856093) ^ (y * 19349663)
|
||||||
|
return ((h & 0xff) % 9) - 4 # -4..4
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== _World
|
||||||
|
class _World:
|
||||||
|
def __init__(self, area_id, spawn):
|
||||||
|
if area_id not in _areas():
|
||||||
|
raise KeyError("unknown area id %r (data.maps.AREAS)" % (area_id,))
|
||||||
|
self.area_id = area_id
|
||||||
|
self.area = _areas()[area_id]
|
||||||
|
self.rows = self.area["map"]
|
||||||
|
self.h = len(self.rows)
|
||||||
|
self.w = max(len(r) for r in self.rows)
|
||||||
|
spawns = self.area["spawns"]
|
||||||
|
if spawn not in spawns:
|
||||||
|
raise KeyError("unknown spawn %r for area %r (have %r)"
|
||||||
|
% (spawn, area_id, list(spawns)))
|
||||||
|
self.spawn_cell = spawns[spawn]
|
||||||
|
|
||||||
|
self.scene = None
|
||||||
|
self.grid = None
|
||||||
|
self.stack = None
|
||||||
|
self.player = None
|
||||||
|
self.terrain = None
|
||||||
|
self.fov = None
|
||||||
|
self.zoom = 1.0
|
||||||
|
|
||||||
|
self.prop_entities = {} # id -> Entity
|
||||||
|
self.door_entities = {} # (x,y) -> Entity
|
||||||
|
self.npc_entities = {} # id -> (Entity, npc_dict)
|
||||||
|
self.enc_entities = [] # list of (Entity, enc_dict)
|
||||||
|
self.markers = [] # scene-level gold marker Frames
|
||||||
|
self.hud = [] # scene-level HUD drawables
|
||||||
|
|
||||||
|
self.held = [] # currently held direction vectors (recency)
|
||||||
|
self.frozen = False # True while a dialogue/menu is modal above
|
||||||
|
self._move_timer = None
|
||||||
|
self._wander_timer = None
|
||||||
|
self._rng = 1234567
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- geometry
|
||||||
|
def _ch(self, x, y):
|
||||||
|
if 0 <= y < self.h and 0 <= x < len(self.rows[y]):
|
||||||
|
return self.rows[y][x]
|
||||||
|
return " "
|
||||||
|
|
||||||
|
def _blocking_char(self, ch):
|
||||||
|
return ch in ("#", "~", " ")
|
||||||
|
|
||||||
|
def _door_at(self, x, y):
|
||||||
|
for d in self.area.get("door_cells", []):
|
||||||
|
if tuple(d["pos"]) == (x, y):
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
|
||||||
|
def walkable(self, x, y):
|
||||||
|
ch = self._ch(x, y)
|
||||||
|
d = self._door_at(x, y)
|
||||||
|
if d is not None:
|
||||||
|
return GS.has(d["flag"])
|
||||||
|
return not self._blocking_char(ch)
|
||||||
|
|
||||||
|
def cell_screen(self, x, y):
|
||||||
|
"""Top-left screen pixel of cell. The map is anchored at grid.pos with
|
||||||
|
cells drawn at 16px * zoom, so scene-level overlays (markers) that must
|
||||||
|
stay glued to a cell scale with zoom too."""
|
||||||
|
return (self._gx + x * 16 * self.zoom, self._gy + y * 16 * self.zoom)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- build
|
||||||
|
def build(self):
|
||||||
|
self.scene = mcrfpy.Scene("ow_" + self.area_id)
|
||||||
|
mcrfpy.current_scene = self.scene
|
||||||
|
GS.current_area = self.area_id
|
||||||
|
|
||||||
|
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=palette.INK)
|
||||||
|
self.scene.children.append(bg)
|
||||||
|
|
||||||
|
# BIBLE section 6 look: chunky 32px cells. grid.zoom scales the 16px
|
||||||
|
# atlas to 32px and (per Fable, verified) works in the headless render
|
||||||
|
# used for QA screenshots. We size the on-screen viewport to exactly the
|
||||||
|
# zoomed map and centre it; with size == map*zoom the engine's default
|
||||||
|
# camera (center = w/(2*zoom)) top-left-aligns tile (0,0), so a cell
|
||||||
|
# (x,y) lands at grid.pos + (x*16*zoom, y*16*zoom). All four maps fit
|
||||||
|
# 1024x768 at zoom 2 (hollowbrook 768x512, gearwood 832x576,
|
||||||
|
# undercroft 832x608, study 576x384).
|
||||||
|
self.zoom = 2.0
|
||||||
|
map_w = self.w * 16
|
||||||
|
map_h = self.h * 16
|
||||||
|
disp_w = int(map_w * self.zoom)
|
||||||
|
disp_h = int(map_h * self.zoom)
|
||||||
|
gx = round((1024 - disp_w) / 2)
|
||||||
|
gy = round(max(40, (768 - disp_h) / 2))
|
||||||
|
|
||||||
|
# framing border behind the play area
|
||||||
|
border = mcrfpy.Frame(pos=(gx - 6, gy - 6), size=(disp_w + 12, disp_h + 12),
|
||||||
|
fill_color=palette.PANEL, outline=2,
|
||||||
|
outline_color=palette.OUTLINE)
|
||||||
|
self.scene.children.append(border)
|
||||||
|
|
||||||
|
self.grid = mcrfpy.Grid(grid_size=(self.w, self.h),
|
||||||
|
pos=(gx, gy), size=(disp_w, disp_h),
|
||||||
|
zoom=self.zoom)
|
||||||
|
self.grid.fill_color = palette.INK
|
||||||
|
self.grid.z_index = 10
|
||||||
|
self.scene.children.append(self.grid)
|
||||||
|
self._gx, self._gy = gx, gy
|
||||||
|
self._follow = False
|
||||||
|
|
||||||
|
# terrain layer (below entities)
|
||||||
|
self.terrain = mcrfpy.ColorLayer(name="terrain", z_index=-2)
|
||||||
|
self.grid.add_layer(self.terrain)
|
||||||
|
|
||||||
|
# walkability + transparency per cell
|
||||||
|
for y in range(self.h):
|
||||||
|
for x in range(self.w):
|
||||||
|
ch = self._ch(x, y)
|
||||||
|
pt = self.grid.at(x, y)
|
||||||
|
pt.walkable = self.walkable(x, y)
|
||||||
|
pt.transparent = ch not in ("#", " ")
|
||||||
|
|
||||||
|
self._apply_terrain()
|
||||||
|
|
||||||
|
# player entity
|
||||||
|
self._make_player()
|
||||||
|
|
||||||
|
# optional FOV overlay (Undercroft): dim/hide terrain outside sight
|
||||||
|
if self.area.get("fov"):
|
||||||
|
self.grid.fov_radius = int(self.area.get("fov_radius", 7))
|
||||||
|
self.fov = mcrfpy.ColorLayer(name="fov", z_index=-1)
|
||||||
|
self.grid.add_layer(self.fov)
|
||||||
|
self.fov.fill(palette.C(palette.INK_T, 255))
|
||||||
|
self.fov.apply_perspective(
|
||||||
|
entity=self.player,
|
||||||
|
visible=mcrfpy.Color(60, 52, 36, 26), # faint warm torchlight
|
||||||
|
discovered=palette.C(palette.INK_T, 170), # explored: dim
|
||||||
|
unknown=palette.C(palette.INK_T, 255)) # unseen: black
|
||||||
|
self.grid.perspective = self.player
|
||||||
|
|
||||||
|
self._build_doors()
|
||||||
|
self._build_props()
|
||||||
|
self._build_npcs()
|
||||||
|
self._build_encounters()
|
||||||
|
|
||||||
|
if self.fov is not None:
|
||||||
|
self._refresh_fov()
|
||||||
|
|
||||||
|
self._start_timers()
|
||||||
|
|
||||||
|
self.stack = InputStack(self.scene)
|
||||||
|
self.stack.push(self._on_key, "move")
|
||||||
|
|
||||||
|
self.refresh_hud()
|
||||||
|
|
||||||
|
def _make_player(self):
|
||||||
|
if self.player is None:
|
||||||
|
self.player = mcrfpy.Entity(grid_pos=self.spawn_cell,
|
||||||
|
texture=assets.TEX, sprite_index=85)
|
||||||
|
self.grid.entities.append(self.player)
|
||||||
|
return self.player
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- terrain
|
||||||
|
def _grey_factor(self):
|
||||||
|
if (GS.act == 3 and self.area_id == "hollowbrook"
|
||||||
|
and not GS.has("town_rewoken")):
|
||||||
|
n = sum(1 for f in REWAKE_FLAG.values() if GS.has(f))
|
||||||
|
return max(0.0, 0.7 - 0.23 * n)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _apply_terrain(self):
|
||||||
|
factor = self._grey_factor()
|
||||||
|
for y in range(self.h):
|
||||||
|
row = self.rows[y]
|
||||||
|
for x in range(len(row)):
|
||||||
|
ch = row[x]
|
||||||
|
base = self.area["palette"].get(ch, palette.INK_T)
|
||||||
|
if ch == ".":
|
||||||
|
o = _cell_offset(x, y)
|
||||||
|
base = (_clamp8(base[0] + o), _clamp8(base[1] + o),
|
||||||
|
_clamp8(base[2] + o))
|
||||||
|
if factor is not None:
|
||||||
|
base = palette.lerp_t(base, palette.GREY_T, factor)
|
||||||
|
self.terrain.set((x, y), palette.C(base))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- doors
|
||||||
|
def _build_doors(self):
|
||||||
|
for d in self.area.get("door_cells", []):
|
||||||
|
x, y = d["pos"]
|
||||||
|
spr = 47 if GS.has(d["flag"]) else 45
|
||||||
|
e = mcrfpy.Entity(grid_pos=(x, y), texture=assets.TEX,
|
||||||
|
sprite_index=spr)
|
||||||
|
self.grid.entities.append(e)
|
||||||
|
self.door_entities[(x, y)] = e
|
||||||
|
|
||||||
|
def _refresh_doors(self):
|
||||||
|
for d in self.area.get("door_cells", []):
|
||||||
|
x, y = d["pos"]
|
||||||
|
e = self.door_entities.get((x, y))
|
||||||
|
if e is not None:
|
||||||
|
e.sprite_index = 47 if GS.has(d["flag"]) else 45
|
||||||
|
self.grid.at(x, y).walkable = self.walkable(x, y)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- props
|
||||||
|
def _prop_present(self, prop):
|
||||||
|
for f in prop.get("gone_flags", []):
|
||||||
|
if GS.has(f):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _build_props(self):
|
||||||
|
for prop in self.area.get("props", []):
|
||||||
|
if not self._prop_present(prop):
|
||||||
|
continue
|
||||||
|
x, y = prop["pos"]
|
||||||
|
spr = prop.get("sprite")
|
||||||
|
if spr is None:
|
||||||
|
self._add_marker(x, y)
|
||||||
|
continue
|
||||||
|
e = mcrfpy.Entity(grid_pos=(x, y), texture=assets.TEX,
|
||||||
|
sprite_index=spr)
|
||||||
|
self.grid.entities.append(e)
|
||||||
|
self.prop_entities[prop["id"]] = e
|
||||||
|
self._refresh_fountain()
|
||||||
|
|
||||||
|
def _add_marker(self, x, y):
|
||||||
|
s = 16 * self.zoom
|
||||||
|
px, py = self.cell_screen(x, y)
|
||||||
|
f = mcrfpy.Frame(pos=(px + 1, py + 1), size=(s - 2, s - 2),
|
||||||
|
fill_color=palette.C(palette.GOLD_T, 0),
|
||||||
|
outline=2, outline_color=palette.GOLD)
|
||||||
|
f.z_index = 90
|
||||||
|
self.scene.children.append(f)
|
||||||
|
self.markers.append((f, x, y))
|
||||||
|
# gentle pulse so the empty spot reads as interactive
|
||||||
|
f.animate("opacity", 0.4, 0.9, mcrfpy.Easing.EASE_IN_OUT, loop=True)
|
||||||
|
|
||||||
|
def _refresh_fountain(self):
|
||||||
|
e = self.prop_entities.get("fountain")
|
||||||
|
if e is not None:
|
||||||
|
e.sprite_index = 8 if GS.has("town_rewoken") else 7
|
||||||
|
|
||||||
|
def _prop_scene_at(self, x, y):
|
||||||
|
for prop in self.area.get("props", []):
|
||||||
|
if tuple(prop["pos"]) == (x, y) and prop.get("scene") \
|
||||||
|
and self._prop_present(prop):
|
||||||
|
return prop
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- npcs
|
||||||
|
def _npc_present(self, npc):
|
||||||
|
if GS.act < npc.get("act_min", 1):
|
||||||
|
return False
|
||||||
|
for f in npc.get("need_flags", []):
|
||||||
|
if not GS.has(f):
|
||||||
|
return False
|
||||||
|
for f in npc.get("gone_flags", []):
|
||||||
|
if GS.has(f):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _npc_scene_id(self, npc):
|
||||||
|
nid = npc["id"]
|
||||||
|
if GS.act == 3 and nid == "ODD":
|
||||||
|
return "rewake_odd" if not GS.has("rewoke_odd") else "odd_ferry_study"
|
||||||
|
scenes = npc["scene"]
|
||||||
|
if GS.act in scenes:
|
||||||
|
return scenes[GS.act]
|
||||||
|
keys = sorted(k for k in scenes if k <= GS.act)
|
||||||
|
if not keys:
|
||||||
|
raise KeyError("NPC %r has no dialogue scene for act %d"
|
||||||
|
% (nid, GS.act))
|
||||||
|
return scenes[keys[-1]]
|
||||||
|
|
||||||
|
def _npc_texture(self, npc):
|
||||||
|
flag = REWAKE_FLAG.get(npc["id"])
|
||||||
|
if flag is not None and self._grey_factor() is not None \
|
||||||
|
and not GS.has(flag):
|
||||||
|
return _grey_tex()
|
||||||
|
return assets.TEX
|
||||||
|
|
||||||
|
def _build_npcs(self):
|
||||||
|
idx = 0
|
||||||
|
for npc in self.area.get("npcs", []):
|
||||||
|
if not self._npc_present(npc):
|
||||||
|
continue
|
||||||
|
x, y = npc["pos"]
|
||||||
|
e = mcrfpy.Entity(grid_pos=(x, y), texture=self._npc_texture(npc),
|
||||||
|
sprite_index=npc["sprite"])
|
||||||
|
self.grid.entities.append(e)
|
||||||
|
self.npc_entities[npc["id"]] = (e, npc)
|
||||||
|
# idle bob (staggered), oscillates draw_y by ~0.06
|
||||||
|
e.animate("draw_y", float(y) - 0.06, 0.75 + 0.05 * (idx % 6),
|
||||||
|
mcrfpy.Easing.EASE_IN_OUT, loop=True)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
def _npc_at(self, x, y):
|
||||||
|
for nid, (e, npc) in self.npc_entities.items():
|
||||||
|
if int(e.grid_x) == x and int(e.grid_y) == y:
|
||||||
|
return npc
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- encounters
|
||||||
|
def _enc_done_key(self, enc):
|
||||||
|
x, y = enc["pos"]
|
||||||
|
return "enc_%s_%d_%d_done" % (self.area_id, x, y)
|
||||||
|
|
||||||
|
def _build_encounters(self):
|
||||||
|
for enc in self.area.get("encounters", []):
|
||||||
|
if GS.has(self._enc_done_key(enc)):
|
||||||
|
continue
|
||||||
|
x, y = enc["pos"]
|
||||||
|
spr = _pack_first_sprite(enc["pack"])
|
||||||
|
e = mcrfpy.Entity(grid_pos=(x, y), texture=assets.TEX,
|
||||||
|
sprite_index=spr)
|
||||||
|
self.grid.entities.append(e)
|
||||||
|
enc = dict(enc)
|
||||||
|
enc["home"] = (x, y)
|
||||||
|
self.enc_entities.append((e, enc))
|
||||||
|
|
||||||
|
def _enc_at(self, x, y):
|
||||||
|
for e, enc in self.enc_entities:
|
||||||
|
if int(e.grid_x) == x and int(e.grid_y) == y:
|
||||||
|
return (e, enc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _rand(self, n):
|
||||||
|
self._rng = (self._rng * 1103515245 + 12345) & 0x7fffffff
|
||||||
|
return self._rng % n
|
||||||
|
|
||||||
|
def _is_current(self):
|
||||||
|
"""True only while this world's scene is the one on screen. Guards the
|
||||||
|
held-key move + encounter-wander timers so they never fire under a
|
||||||
|
battle/menu/dialogue scene that has taken over current_scene."""
|
||||||
|
return mcrfpy.current_scene is self.scene
|
||||||
|
|
||||||
|
def _wander(self, timer, runtime):
|
||||||
|
if self.frozen or not self._is_current():
|
||||||
|
return
|
||||||
|
for e, enc in self.enc_entities:
|
||||||
|
leash = enc.get("leash", 2)
|
||||||
|
if leash == 0:
|
||||||
|
continue
|
||||||
|
hx, hy = enc["home"]
|
||||||
|
dx, dy = [(0, -1), (0, 1), (-1, 0), (1, 0), (0, 0)][self._rand(5)]
|
||||||
|
nx, ny = int(e.grid_x) + dx, int(e.grid_y) + dy
|
||||||
|
if abs(nx - hx) > leash or abs(ny - hy) > leash:
|
||||||
|
continue
|
||||||
|
if not self.walkable(nx, ny):
|
||||||
|
continue
|
||||||
|
if self._occupied(nx, ny, ignore=e):
|
||||||
|
continue
|
||||||
|
e.grid_pos = (nx, ny)
|
||||||
|
e.animate("draw_x", float(nx), 0.25, mcrfpy.Easing.EASE_OUT)
|
||||||
|
e.animate("draw_y", float(ny), 0.25, mcrfpy.Easing.EASE_OUT)
|
||||||
|
|
||||||
|
def _occupied(self, x, y, ignore=None):
|
||||||
|
if self.player is not None and int(self.player.grid_x) == x \
|
||||||
|
and int(self.player.grid_y) == y:
|
||||||
|
return True
|
||||||
|
for e, _n in self.enc_entities:
|
||||||
|
if e is ignore:
|
||||||
|
continue
|
||||||
|
if int(e.grid_x) == x and int(e.grid_y) == y:
|
||||||
|
return True
|
||||||
|
if self._npc_at(x, y) is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- timers
|
||||||
|
def _start_timers(self):
|
||||||
|
self._move_timer = mcrfpy.Timer("ow_move", self._repeat_move, _MOVE_MS)
|
||||||
|
if any(enc.get("leash", 2) != 0
|
||||||
|
for _e, enc in self.enc_entities):
|
||||||
|
self._wander_timer = mcrfpy.Timer("ow_wander", self._wander,
|
||||||
|
_WANDER_MS)
|
||||||
|
|
||||||
|
def _repeat_move(self, timer, runtime):
|
||||||
|
if self.frozen or not self.held or not self._is_current():
|
||||||
|
return
|
||||||
|
dx, dy = self.held[-1]
|
||||||
|
self.try_step(dx, dy)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- input
|
||||||
|
def _on_key(self, key, state):
|
||||||
|
K = mcrfpy.Key
|
||||||
|
if self.frozen:
|
||||||
|
return False
|
||||||
|
if state == mcrfpy.InputState.PRESSED:
|
||||||
|
if key in _DIRS:
|
||||||
|
d = _DIRS[key]
|
||||||
|
if d in self.held:
|
||||||
|
self.held.remove(d)
|
||||||
|
self.held.append(d)
|
||||||
|
self.try_step(d[0], d[1])
|
||||||
|
return True
|
||||||
|
if key in (K.E, K.ENTER, K.SPACE):
|
||||||
|
self._interact_here()
|
||||||
|
return True
|
||||||
|
if key in (K.ESCAPE, K.TAB):
|
||||||
|
self._open_party_menu()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
else: # RELEASED
|
||||||
|
if key in _DIRS and _DIRS[key] in self.held:
|
||||||
|
self.held.remove(_DIRS[key])
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- movement
|
||||||
|
def try_step(self, dx, dy):
|
||||||
|
if self.frozen:
|
||||||
|
return
|
||||||
|
px, py = int(self.player.grid_x), int(self.player.grid_y)
|
||||||
|
tx, ty = px + dx, py + dy
|
||||||
|
|
||||||
|
npc = self._npc_at(tx, ty)
|
||||||
|
if npc is not None:
|
||||||
|
self.talk(npc)
|
||||||
|
return
|
||||||
|
hit = self._enc_at(tx, ty)
|
||||||
|
if hit is not None:
|
||||||
|
self.fight(hit)
|
||||||
|
return
|
||||||
|
d = self._door_at(tx, ty)
|
||||||
|
if d is not None and not GS.has(d["flag"]):
|
||||||
|
self.run_dialogue(d["locked_scene"])
|
||||||
|
return
|
||||||
|
prop = self._prop_scene_at(tx, ty)
|
||||||
|
if prop is not None and not self.walkable(tx, ty):
|
||||||
|
self.trigger_prop(prop)
|
||||||
|
return
|
||||||
|
if not self.walkable(tx, ty):
|
||||||
|
self._nudge(dx, dy)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.player.grid_pos = (tx, ty)
|
||||||
|
self.player.animate("draw_x", float(tx), _STEP_DUR, mcrfpy.Easing.EASE_OUT)
|
||||||
|
self.player.animate("draw_y", float(ty), _STEP_DUR, mcrfpy.Easing.EASE_OUT)
|
||||||
|
self.after_move(tx, ty)
|
||||||
|
|
||||||
|
def _nudge(self, dx, dy):
|
||||||
|
px, py = int(self.player.grid_x), int(self.player.grid_y)
|
||||||
|
if dx:
|
||||||
|
self.player.animate("draw_x", px + 0.22 * dx, 0.06,
|
||||||
|
mcrfpy.Easing.EASE_OUT,
|
||||||
|
callback=lambda *_a: self.player.animate(
|
||||||
|
"draw_x", float(px), 0.08))
|
||||||
|
elif dy:
|
||||||
|
self.player.animate("draw_y", py + 0.22 * dy, 0.06,
|
||||||
|
mcrfpy.Easing.EASE_OUT,
|
||||||
|
callback=lambda *_a: self.player.animate(
|
||||||
|
"draw_y", float(py), 0.08))
|
||||||
|
|
||||||
|
def _refresh_fov(self):
|
||||||
|
"""Update the FOV overlay and hide entities the player cannot see."""
|
||||||
|
self.player.update_visibility()
|
||||||
|
px, py = int(self.player.grid_x), int(self.player.grid_y)
|
||||||
|
r = int(self.grid.fov_radius)
|
||||||
|
self.grid.compute_fov((px, py), r)
|
||||||
|
groups = [e for e, _n in self.enc_entities]
|
||||||
|
groups += [e for e, _n in self.npc_entities.values()]
|
||||||
|
groups += list(self.prop_entities.values())
|
||||||
|
groups += list(self.door_entities.values())
|
||||||
|
for e in groups:
|
||||||
|
e.visible = self.grid.is_in_fov(int(e.grid_x), int(e.grid_y))
|
||||||
|
for f, mx, my in self.markers:
|
||||||
|
f.visible = self.grid.is_in_fov(mx, my)
|
||||||
|
self.player.visible = True
|
||||||
|
|
||||||
|
def after_move(self, x, y):
|
||||||
|
if self.fov is not None:
|
||||||
|
self._refresh_fov()
|
||||||
|
# step-onto prop scene
|
||||||
|
prop = self._prop_scene_at(x, y)
|
||||||
|
if prop is not None:
|
||||||
|
self.trigger_prop(prop)
|
||||||
|
return
|
||||||
|
# auto props (proximity, within 1 cell)
|
||||||
|
for p in self.area.get("props", []):
|
||||||
|
if not p.get("auto") or not self._prop_present(p):
|
||||||
|
continue
|
||||||
|
once = p.get("once")
|
||||||
|
if once and GS.has(once):
|
||||||
|
continue
|
||||||
|
ax, ay = p["pos"]
|
||||||
|
if abs(ax - x) <= 1 and abs(ay - y) <= 1:
|
||||||
|
self.trigger_prop(p)
|
||||||
|
return
|
||||||
|
# exits
|
||||||
|
for ex in self.area.get("exits", []):
|
||||||
|
if tuple(ex["pos"]) == (x, y):
|
||||||
|
self._take_exit(ex)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _interact_here(self):
|
||||||
|
x, y = int(self.player.grid_x), int(self.player.grid_y)
|
||||||
|
camp = self.area.get("camp")
|
||||||
|
if camp and tuple(camp["pos"]) == (x, y):
|
||||||
|
for f in camp.get("need_flags", []):
|
||||||
|
if not GS.has(f):
|
||||||
|
return
|
||||||
|
self.run_dialogue(camp["scene"])
|
||||||
|
return
|
||||||
|
prop = self._prop_scene_at(x, y)
|
||||||
|
if prop is not None:
|
||||||
|
self.trigger_prop(prop)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- triggers
|
||||||
|
def talk(self, npc):
|
||||||
|
self.run_dialogue(self._npc_scene_id(npc))
|
||||||
|
|
||||||
|
def trigger_prop(self, prop):
|
||||||
|
self.run_dialogue(prop["scene"])
|
||||||
|
|
||||||
|
def _take_exit(self, ex):
|
||||||
|
if ex.get("scene"):
|
||||||
|
self.run_dialogue(ex["scene"])
|
||||||
|
else:
|
||||||
|
_goto(ex["to"], ex.get("spawn", "start"))
|
||||||
|
|
||||||
|
def fight(self, hit):
|
||||||
|
e, enc = hit
|
||||||
|
hook = dialogue.HOOKS.get("battle")
|
||||||
|
if hook is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"encounter bump needs dialogue.HOOKS['battle'](pack, "
|
||||||
|
"on_victory=) - Agent B must register it")
|
||||||
|
done_key = self._enc_done_key(enc)
|
||||||
|
# freeze the overworld for the whole battle; on_victory/on_defeat unfreeze
|
||||||
|
self.frozen = True
|
||||||
|
self.held = []
|
||||||
|
|
||||||
|
def on_victory(*_a):
|
||||||
|
try:
|
||||||
|
e.die()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if (e, enc) in self.enc_entities:
|
||||||
|
self.enc_entities.remove((e, enc))
|
||||||
|
GS.add_flag(done_key)
|
||||||
|
self._post_dialogue(None)
|
||||||
|
|
||||||
|
def on_defeat(*_a):
|
||||||
|
self.frozen = False
|
||||||
|
|
||||||
|
hook(enc["pack"], on_victory=on_victory, on_defeat=on_defeat)
|
||||||
|
|
||||||
|
def run_dialogue(self, scene_id, after=None):
|
||||||
|
self.frozen = True
|
||||||
|
self.held = []
|
||||||
|
dialogue.run_scene(scene_id,
|
||||||
|
on_done=lambda: self._post_dialogue(after),
|
||||||
|
stack=self.stack)
|
||||||
|
|
||||||
|
def _post_dialogue(self, after):
|
||||||
|
self.frozen = False
|
||||||
|
self.refresh_theme()
|
||||||
|
self.refresh_hud()
|
||||||
|
if after:
|
||||||
|
after()
|
||||||
|
|
||||||
|
def _open_party_menu(self):
|
||||||
|
try:
|
||||||
|
from systems import party_menu
|
||||||
|
except Exception as ex:
|
||||||
|
raise RuntimeError("party menu unavailable: %s" % (ex,))
|
||||||
|
self.frozen = True
|
||||||
|
self.held = []
|
||||||
|
|
||||||
|
def _closed():
|
||||||
|
self.frozen = False
|
||||||
|
self.refresh_hud()
|
||||||
|
party_menu.open_menu(self.scene, self.stack, on_close=_closed)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- refresh
|
||||||
|
def refresh_theme(self):
|
||||||
|
"""Re-apply terrain wash, NPC textures, doors, fountain: the grey beat.
|
||||||
|
Color literally returns to town as rewoke_* flags are set."""
|
||||||
|
was_rewoken = GS.has("town_rewoken")
|
||||||
|
if (GS.act == 3 and self.area_id == "hollowbrook"
|
||||||
|
and not was_rewoken
|
||||||
|
and all(GS.has(f) for f in REWAKE_FLAG.values())):
|
||||||
|
GS.add_flag("town_rewoken")
|
||||||
|
ui.TitleBanner(self.scene.children, self.area.get("banner"))
|
||||||
|
|
||||||
|
self._apply_terrain()
|
||||||
|
for nid, (e, npc) in self.npc_entities.items():
|
||||||
|
e.texture = self._npc_texture(npc)
|
||||||
|
self._refresh_doors()
|
||||||
|
self._refresh_fountain()
|
||||||
|
|
||||||
|
def refresh_hud(self):
|
||||||
|
for d in self.hud:
|
||||||
|
try:
|
||||||
|
self.scene.children.remove(d)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.hud = []
|
||||||
|
grey = self._grey_factor() is not None
|
||||||
|
name = self.area.get("banner_grey") if grey else self.area.get("banner")
|
||||||
|
|
||||||
|
loc = ui.Label(self.scene.children, name or self.area_id.upper(),
|
||||||
|
(18, 14), color=palette.DIM, size=palette.SMALL_SIZE)
|
||||||
|
loc.z_index = 5000
|
||||||
|
self.hud.append(loc)
|
||||||
|
|
||||||
|
cogs = ui.Label(self.scene.children, "%d cogs" % GS.gold, (0, 14),
|
||||||
|
color=palette.GOLD, size=palette.SMALL_SIZE)
|
||||||
|
cogs.x = 1024 - 18 - cogs.size.x
|
||||||
|
cogs.z_index = 5000
|
||||||
|
self.hud.append(cogs)
|
||||||
|
|
||||||
|
# up to 3 party mini-cards, bottom-left
|
||||||
|
base_y = 700
|
||||||
|
for i, cid in enumerate(GS.party[:3]):
|
||||||
|
cs = GS.roster.get(cid)
|
||||||
|
if cs is None:
|
||||||
|
continue
|
||||||
|
cx = 16 + i * 132
|
||||||
|
card = mcrfpy.Frame(pos=(cx, base_y), size=(124, 54),
|
||||||
|
fill_color=palette.PANEL, outline=2,
|
||||||
|
outline_color=palette.OUTLINE)
|
||||||
|
card.z_index = 5000
|
||||||
|
self.scene.children.append(card)
|
||||||
|
self.hud.append(card)
|
||||||
|
chip = mcrfpy.Frame(pos=(6, 6), size=(40, 40),
|
||||||
|
fill_color=palette.INSET, outline=1,
|
||||||
|
outline_color=palette.OUTLINE)
|
||||||
|
card.children.append(chip)
|
||||||
|
spr = assets.PORTRAITS.get(cid, 0) or 0
|
||||||
|
s = mcrfpy.Sprite(pos=(4, 4), texture=assets.TEX, sprite_index=spr)
|
||||||
|
s.scale = 2.0
|
||||||
|
chip.children.append(s)
|
||||||
|
ui.Label(card.children, cid, (54, 6), color=palette.PARCH,
|
||||||
|
size=palette.SMALL_SIZE)
|
||||||
|
try:
|
||||||
|
hp, mhp = cs.hp, cs.max_hp
|
||||||
|
except Exception:
|
||||||
|
hp, mhp = 1, 1
|
||||||
|
hb = ui.Bar(card.children, (54, 28), (62, 16), palette.BLOOD,
|
||||||
|
cur=hp, maxv=mhp, show_text=False)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- arrival
|
||||||
|
def arrival_scene(self):
|
||||||
|
"""Dialogue to auto-run once the area is built and the player arrives.
|
||||||
|
Wires the authored-but-otherwise-unreferenced Bell appearance #3
|
||||||
|
(script_act3 'hollowbrook_grey') to the grey-town arrival."""
|
||||||
|
if (self.area_id == "hollowbrook" and GS.act == 3
|
||||||
|
and not GS.has("bell_3_done")
|
||||||
|
and not GS.has("town_rewoken")):
|
||||||
|
return "hollowbrook_grey"
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- banner
|
||||||
|
def show_banner(self):
|
||||||
|
grey = (GS.act == 3 and self.area_id == "hollowbrook"
|
||||||
|
and not GS.has("town_rewoken"))
|
||||||
|
text = self.area.get("banner_grey") if grey and self.area.get(
|
||||||
|
"banner_grey") else self.area.get("banner")
|
||||||
|
if text:
|
||||||
|
ui.TitleBanner(self.scene.children, text)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- teardown
|
||||||
|
def teardown(self):
|
||||||
|
for t in (self._move_timer, self._wander_timer):
|
||||||
|
if t is not None:
|
||||||
|
try:
|
||||||
|
t.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._move_timer = None
|
||||||
|
self._wander_timer = None
|
||||||
|
self.held = []
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== helpers
|
||||||
|
def _areas():
|
||||||
|
from data import maps
|
||||||
|
return maps.AREAS
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_first_sprite(pack):
|
||||||
|
"""Sprite index of a pack's first enemy. data.enemies is Agent B's (parallel);
|
||||||
|
imported lazily. Tests without it set TEST_ENEMY_FALLBACK."""
|
||||||
|
enemies = None
|
||||||
|
try:
|
||||||
|
from data import enemies as _en
|
||||||
|
enemies = _en
|
||||||
|
except Exception:
|
||||||
|
enemies = None
|
||||||
|
if enemies is not None:
|
||||||
|
fn = getattr(enemies, "pack_first_sprite", None)
|
||||||
|
if callable(fn):
|
||||||
|
return int(fn(pack))
|
||||||
|
packs = getattr(enemies, "PACKS", None)
|
||||||
|
stats = getattr(enemies, "ENEMIES", None) or getattr(enemies, "STATS", None)
|
||||||
|
if packs is not None and pack in packs:
|
||||||
|
members = packs[pack]
|
||||||
|
if isinstance(members, dict):
|
||||||
|
members = members.get("enemies") or members.get("members") or []
|
||||||
|
m0 = members[0] if members else None
|
||||||
|
if isinstance(m0, dict):
|
||||||
|
if "sprite" in m0:
|
||||||
|
return int(m0["sprite"])
|
||||||
|
m0 = m0.get("id") or m0.get("enemy") or m0.get("name")
|
||||||
|
if stats is not None and m0 in stats and "sprite" in stats[m0]:
|
||||||
|
return int(stats[m0]["sprite"])
|
||||||
|
if TEST_ENEMY_FALLBACK is not None:
|
||||||
|
return int(TEST_ENEMY_FALLBACK)
|
||||||
|
raise KeyError(
|
||||||
|
"cannot resolve first-enemy sprite for pack %r: data.enemies must expose "
|
||||||
|
"pack_first_sprite(pack) or PACKS[pack] + ENEMIES[id]['sprite']" % (pack,))
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== public
|
||||||
|
def enter_area(area_id, spawn="start"):
|
||||||
|
"""Build (or rebuild) the scene for an area and make it current."""
|
||||||
|
global WORLD
|
||||||
|
if WORLD is not None:
|
||||||
|
WORLD.teardown()
|
||||||
|
WORLD = _World(area_id, spawn)
|
||||||
|
WORLD.build()
|
||||||
|
WORLD.show_banner()
|
||||||
|
arr = WORLD.arrival_scene()
|
||||||
|
if arr is not None:
|
||||||
|
WORLD.run_dialogue(arr)
|
||||||
|
return WORLD
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_area():
|
||||||
|
"""Re-apply palette/NPCs/doors after flag changes (Act-3 grey beat)."""
|
||||||
|
if WORLD is None:
|
||||||
|
raise RuntimeError("refresh_area() called with no active world")
|
||||||
|
WORLD.refresh_theme()
|
||||||
|
WORLD.refresh_hud()
|
||||||
|
|
||||||
|
|
||||||
|
def _goto(area, spawn="start"):
|
||||||
|
scn = mcrfpy.current_scene
|
||||||
|
if (WORLD is not None and scn is not None
|
||||||
|
and getattr(scn, "name", "").startswith("ow_")):
|
||||||
|
tween.fade_scene(scn, on_done=lambda: enter_area(area, spawn))
|
||||||
|
else:
|
||||||
|
enter_area(area, spawn)
|
||||||
|
|
||||||
|
|
||||||
|
def register_hooks():
|
||||||
|
dialogue.HOOKS["goto_area"] = lambda area, spawn="start": _goto(area, spawn)
|
||||||
|
|
||||||
|
|
||||||
|
register_hooks()
|
||||||
456
games/unwritten/systems/party_menu.py
Normal file
456
games/unwritten/systems/party_menu.py
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
"""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()
|
||||||
230
games/unwritten/systems/shop.py
Normal file
230
games/unwritten/systems/shop.py
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
"""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()
|
||||||
244
games/unwritten/systems/state.py
Normal file
244
games/unwritten/systems/state.py
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
"""UNWRITTEN - GameState singleton. Owner: Agent A.
|
||||||
|
|
||||||
|
The single source of mutable game state (ARCHITECTURE section 5: no global
|
||||||
|
mutable state lives outside this module). Numeric tables (base stats, growth,
|
||||||
|
equipment bonuses) live in data.characters / data.items, which Agent B owns and
|
||||||
|
may not have written yet - so they are imported LAZILY inside methods. A missing
|
||||||
|
table at runtime raises (fail early); there is no fallback here.
|
||||||
|
|
||||||
|
Expected data interfaces (documented for Agent B):
|
||||||
|
data.characters.BASE[char_id] -> {"HP","SP","ATK","MAG","DEF","SPD": int}
|
||||||
|
data.characters.GROWTH[char_id] -> list of up to 7 per-level growth dicts
|
||||||
|
(applied for levels 2..8; extra levels reuse
|
||||||
|
the last entry)
|
||||||
|
data.items.EQUIP[item_id] -> {stat: delta, ...} for weapons/trinkets
|
||||||
|
"""
|
||||||
|
|
||||||
|
STAT_KEYS = ("HP", "SP", "ATK", "MAG", "DEF", "SPD")
|
||||||
|
|
||||||
|
# Dialogue tag per character (BIBLE section 2). Canonical, owned here so the
|
||||||
|
# dialogue runner can evaluate ("tag", X) reqs without importing data.characters.
|
||||||
|
CHAR_TAGS = {
|
||||||
|
"PIP": "NIMBLE",
|
||||||
|
"BRAMBLE": "OATH",
|
||||||
|
"MOTH": "LITURGY",
|
||||||
|
"VERA": "ALCHEMY",
|
||||||
|
"CANTOR": "RESONANCE",
|
||||||
|
"NYX": "SHADOW",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def xp_for_level(level):
|
||||||
|
"""Cumulative XP required to BE at `level`. Matches SYSTEMS section 1
|
||||||
|
(20/60/120/200/300/420/560 for levels 2..8)."""
|
||||||
|
return 10 * level * (level - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def level_for_xp(xp):
|
||||||
|
"""Highest level (1..8) whose cumulative threshold is <= xp."""
|
||||||
|
lvl = 1
|
||||||
|
while lvl < 8 and xp >= xp_for_level(lvl + 1):
|
||||||
|
lvl += 1
|
||||||
|
return lvl
|
||||||
|
|
||||||
|
|
||||||
|
class CharState:
|
||||||
|
"""One character's live state. Stats are computed lazily from the data
|
||||||
|
tables so importing state without data.characters present does not crash."""
|
||||||
|
|
||||||
|
def __init__(self, char_id, level=1):
|
||||||
|
self.char_id = char_id
|
||||||
|
self.level = max(1, int(level))
|
||||||
|
self.xp = xp_for_level(self.level)
|
||||||
|
self.weapon = None
|
||||||
|
self.trinket = None
|
||||||
|
self._hp = None
|
||||||
|
self._sp = None
|
||||||
|
|
||||||
|
# ---- derived stats -----------------------------------------------------
|
||||||
|
def _base_growth(self):
|
||||||
|
from data import characters
|
||||||
|
if self.char_id not in characters.BASE:
|
||||||
|
raise KeyError("no base stats for %r in data.characters.BASE"
|
||||||
|
% (self.char_id,))
|
||||||
|
return characters.BASE[self.char_id], characters.GROWTH[self.char_id]
|
||||||
|
|
||||||
|
def _equip_bonus(self):
|
||||||
|
bonus = {}
|
||||||
|
if not self.weapon and not self.trinket:
|
||||||
|
return bonus
|
||||||
|
from data import items
|
||||||
|
for eid in (self.weapon, self.trinket):
|
||||||
|
if not eid:
|
||||||
|
continue
|
||||||
|
if eid not in items.EQUIP:
|
||||||
|
raise KeyError("unknown equipment id %r in data.items.EQUIP"
|
||||||
|
% (eid,))
|
||||||
|
for k, v in items.EQUIP[eid].items():
|
||||||
|
bonus[k] = bonus.get(k, 0) + v
|
||||||
|
return bonus
|
||||||
|
|
||||||
|
def stats(self):
|
||||||
|
base, growth = self._base_growth()
|
||||||
|
out = {k: base.get(k, 0) for k in STAT_KEYS}
|
||||||
|
for i in range(self.level - 1):
|
||||||
|
g = growth[i] if i < len(growth) else growth[-1]
|
||||||
|
for k, v in g.items():
|
||||||
|
out[k] = out.get(k, 0) + v
|
||||||
|
for k, v in self._equip_bonus().items():
|
||||||
|
out[k] = out.get(k, 0) + v
|
||||||
|
return out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_hp(self):
|
||||||
|
return self.stats()["HP"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_sp(self):
|
||||||
|
return self.stats()["SP"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hp(self):
|
||||||
|
if self._hp is None:
|
||||||
|
self._hp = self.max_hp
|
||||||
|
return self._hp
|
||||||
|
|
||||||
|
@hp.setter
|
||||||
|
def hp(self, v):
|
||||||
|
self._hp = max(0, min(int(v), self.max_hp))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sp(self):
|
||||||
|
if self._sp is None:
|
||||||
|
self._sp = self.max_sp
|
||||||
|
return self._sp
|
||||||
|
|
||||||
|
@sp.setter
|
||||||
|
def sp(self, v):
|
||||||
|
self._sp = max(0, min(int(v), self.max_sp))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive(self):
|
||||||
|
return self.hp > 0
|
||||||
|
|
||||||
|
def full_heal(self):
|
||||||
|
self._hp = self.max_hp
|
||||||
|
self._sp = self.max_sp
|
||||||
|
|
||||||
|
def add_xp(self, amount):
|
||||||
|
"""Add XP; return list of new level numbers reached (may be empty)."""
|
||||||
|
self.xp += int(amount)
|
||||||
|
target = level_for_xp(self.xp)
|
||||||
|
gained = []
|
||||||
|
while self.level < target:
|
||||||
|
self.level += 1
|
||||||
|
gained.append(self.level)
|
||||||
|
# top up current pools to the new maxima
|
||||||
|
self._hp = self.max_hp
|
||||||
|
self._sp = self.max_sp
|
||||||
|
return gained
|
||||||
|
|
||||||
|
|
||||||
|
class GameState:
|
||||||
|
"""Singleton game state. Access via state.GS."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.party = [] # active char ids, order, max 3
|
||||||
|
self.roster = {} # char_id -> CharState
|
||||||
|
self.flags = set()
|
||||||
|
self.points = 0
|
||||||
|
self.gold = 0
|
||||||
|
self.inventory = {} # item_id -> count
|
||||||
|
self.key_items = []
|
||||||
|
self.act = 1
|
||||||
|
self.hum_hook = None # set by dialogue runner: pulses the Book
|
||||||
|
|
||||||
|
# ---- flags / points ----------------------------------------------------
|
||||||
|
def has(self, flag):
|
||||||
|
return flag in self.flags
|
||||||
|
|
||||||
|
def add_flag(self, flag):
|
||||||
|
self.flags.add(flag)
|
||||||
|
|
||||||
|
def remove_flag(self, flag):
|
||||||
|
self.flags.discard(flag)
|
||||||
|
|
||||||
|
def add_points(self, n):
|
||||||
|
self.points += int(n)
|
||||||
|
if self.hum_hook:
|
||||||
|
self.hum_hook()
|
||||||
|
|
||||||
|
# ---- party / roster ----------------------------------------------------
|
||||||
|
def _avg_level(self):
|
||||||
|
levels = [c.level for c in self.roster.values()]
|
||||||
|
if not levels:
|
||||||
|
return 1
|
||||||
|
return max(1, sum(levels) // len(levels))
|
||||||
|
|
||||||
|
def recruit(self, char_id, level):
|
||||||
|
"""Add a character to the roster (and to the active party if there is
|
||||||
|
room, max 3). level 0 means 'party average level'."""
|
||||||
|
if char_id not in self.roster:
|
||||||
|
lvl = self._avg_level() if int(level) == 0 else int(level)
|
||||||
|
self.roster[char_id] = CharState(char_id, lvl)
|
||||||
|
if char_id not in self.party and len(self.party) < 3:
|
||||||
|
self.party.append(char_id)
|
||||||
|
return self.roster[char_id]
|
||||||
|
|
||||||
|
def party_tags(self):
|
||||||
|
"""Dialogue tags of the ACTIVE party members."""
|
||||||
|
return set(CHAR_TAGS[c] for c in self.party if c in CHAR_TAGS)
|
||||||
|
|
||||||
|
def in_party(self, char_id):
|
||||||
|
return char_id in self.party
|
||||||
|
|
||||||
|
def heal_party(self):
|
||||||
|
for c in self.roster.values():
|
||||||
|
c.full_heal()
|
||||||
|
|
||||||
|
def xp_gain(self, amount):
|
||||||
|
"""Whole roster gains XP (benched included). Returns level-up events as
|
||||||
|
a list of (char_id, new_level)."""
|
||||||
|
events = []
|
||||||
|
for cid, c in self.roster.items():
|
||||||
|
for lvl in c.add_xp(amount):
|
||||||
|
events.append((cid, lvl))
|
||||||
|
return events
|
||||||
|
|
||||||
|
# ---- economy / items ---------------------------------------------------
|
||||||
|
def add_gold(self, n):
|
||||||
|
self.gold = max(0, self.gold + int(n))
|
||||||
|
|
||||||
|
def spend_gold(self, n):
|
||||||
|
if self.gold >= int(n):
|
||||||
|
self.gold -= int(n)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def grant(self, item_id, n=1):
|
||||||
|
self.inventory[item_id] = self.inventory.get(item_id, 0) + int(n)
|
||||||
|
|
||||||
|
def take(self, item_id, n=1):
|
||||||
|
"""Remove n of an item; clamps at zero, drops the key when empty."""
|
||||||
|
have = self.inventory.get(item_id, 0)
|
||||||
|
left = max(0, have - int(n))
|
||||||
|
if left:
|
||||||
|
self.inventory[item_id] = left
|
||||||
|
else:
|
||||||
|
self.inventory.pop(item_id, None)
|
||||||
|
|
||||||
|
def add_key_item(self, item_id):
|
||||||
|
if item_id not in self.key_items:
|
||||||
|
self.key_items.append(item_id)
|
||||||
|
|
||||||
|
def remove_key_item(self, item_id):
|
||||||
|
if item_id in self.key_items:
|
||||||
|
self.key_items.remove(item_id)
|
||||||
|
self.inventory.pop(item_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
# module-level singleton
|
||||||
|
GS = GameState()
|
||||||
578
games/unwritten/tests/playthrough.py
Normal file
578
games/unwritten/tests/playthrough.py
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
"""UNWRITTEN - scripted full playthrough. Owner: Agent D.
|
||||||
|
|
||||||
|
THE proof that the three acts wire into one game. Runs headless, deterministic,
|
||||||
|
driving the REAL systems end to end:
|
||||||
|
- the REAL dialogue runner (choices selected by index via
|
||||||
|
dialogue.select_choice / dialogue.advance)
|
||||||
|
- the REAL battle loop (gw_bats won through the actual command MenuList +
|
||||||
|
target-picker via key injection; bosses won through BattleScreen's autopilot
|
||||||
|
seam, which drives the same _commit/_resolve path the menu uses, using real
|
||||||
|
Talk options)
|
||||||
|
- REAL area transitions (overworld.enter_area via the goto_area hook / ferry
|
||||||
|
dialogue), the REAL shop and party menu, the REAL epilogue.
|
||||||
|
|
||||||
|
Twenty story beats, each screenshotted to tests/shots/pt_NN_<beat>.png and each
|
||||||
|
followed by GS-state assertions. Prints a beat-by-beat PASS log with the running
|
||||||
|
Story-Point total. Exits 0 only on a full clean run.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/playthrough.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import random
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
|
|
||||||
|
import main
|
||||||
|
from systems.state import GS
|
||||||
|
from systems import dialogue, overworld, battle, epilogue, shop, party_menu
|
||||||
|
from data import characters as chardata
|
||||||
|
|
||||||
|
_SHOTS = os.path.join(_ROOT, "tests", "shots")
|
||||||
|
K = mcrfpy.Key
|
||||||
|
P = mcrfpy.InputState.PRESSED
|
||||||
|
|
||||||
|
_beat_no = [0]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- utilities
|
||||||
|
def pump(n):
|
||||||
|
for _ in range(n):
|
||||||
|
mcrfpy.step(1.0 / 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
def shot(name):
|
||||||
|
os.makedirs(_SHOTS, exist_ok=True)
|
||||||
|
automation.screenshot(os.path.join(_SHOTS, name))
|
||||||
|
|
||||||
|
|
||||||
|
def beat(title):
|
||||||
|
_beat_no[0] += 1
|
||||||
|
print(" [beat %02d] %s | points=%d gold=%d party=%s"
|
||||||
|
% (_beat_no[0], title, GS.points, GS.gold, GS.party), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg):
|
||||||
|
print("FAIL playthrough: %s" % msg, flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def need(cond, msg):
|
||||||
|
if not cond:
|
||||||
|
fail(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- dialogue driving (REAL runner) ----
|
||||||
|
def reveal():
|
||||||
|
"""Force the open dialogue's typewriter to complete so its text/choices are
|
||||||
|
on screen for a screenshot (presentation only; progression does not need
|
||||||
|
it)."""
|
||||||
|
c = dialogue.CURRENT
|
||||||
|
if c is not None and getattr(c, "box", None) is not None:
|
||||||
|
try:
|
||||||
|
if c.box._typing:
|
||||||
|
c.box._skip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pump(3)
|
||||||
|
|
||||||
|
|
||||||
|
def advance_to_choices(limit=40):
|
||||||
|
for _ in range(limit):
|
||||||
|
if dialogue.CURRENT is None:
|
||||||
|
return False
|
||||||
|
if dialogue.current_choice_labels() is not None:
|
||||||
|
return True
|
||||||
|
dialogue.advance()
|
||||||
|
pump(2)
|
||||||
|
return dialogue.current_choice_labels() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def choose_label(substr):
|
||||||
|
labels = dialogue.current_choice_labels()
|
||||||
|
need(labels is not None, "choose_label(%r): no choice node open" % substr)
|
||||||
|
for i, (lab, enabled) in enumerate(labels):
|
||||||
|
if substr in lab:
|
||||||
|
need(enabled, "choose_label(%r): choice is disabled" % substr)
|
||||||
|
dialogue.select_choice(i)
|
||||||
|
pump(2)
|
||||||
|
return
|
||||||
|
fail("choose_label(%r): not found in %r" % (substr, [l for l, _ in labels]))
|
||||||
|
|
||||||
|
|
||||||
|
def finish_dialogue(default=-1, guard=80):
|
||||||
|
"""Advance/auto-choose (last enabled by default) until the dialogue closes
|
||||||
|
or transfers."""
|
||||||
|
for _ in range(guard):
|
||||||
|
if dialogue.CURRENT is None:
|
||||||
|
return
|
||||||
|
labels = dialogue.current_choice_labels()
|
||||||
|
if labels:
|
||||||
|
en = [i for i, (l, e) in enumerate(labels) if e]
|
||||||
|
idx = en[default] if en else 0
|
||||||
|
dialogue.select_choice(idx)
|
||||||
|
else:
|
||||||
|
dialogue.advance()
|
||||||
|
pump(2)
|
||||||
|
|
||||||
|
|
||||||
|
def open_dialogue(scene_id):
|
||||||
|
overworld.WORLD.run_dialogue(scene_id)
|
||||||
|
pump(12)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- battle driving ----
|
||||||
|
def _center_enemies(core):
|
||||||
|
return core.alive_enemies()
|
||||||
|
|
||||||
|
|
||||||
|
def boss_policy(screen, actor):
|
||||||
|
"""Autopilot: fire an enabled boss Talk once, else heal a badly hurt ally,
|
||||||
|
else an offensive skill / basic attack. Drives the REAL _commit path."""
|
||||||
|
core = screen.core
|
||||||
|
skills = chardata.learned_skills(actor.char_id, actor.cstate.level)
|
||||||
|
allies = core.alive_party()
|
||||||
|
enemies = core.alive_enemies()
|
||||||
|
for o in core.boss_talk_options():
|
||||||
|
if o["enabled"]:
|
||||||
|
return {"type": "talk", "talk": o["id"]}
|
||||||
|
hurt = [a for a in allies if a.hp < a.max_hp * 0.45]
|
||||||
|
if hurt:
|
||||||
|
tgt = min(hurt, key=lambda c: c.hp / float(c.max_hp))
|
||||||
|
for s in skills:
|
||||||
|
if s["special"] == "kindle" and actor.sp >= s["sp"]:
|
||||||
|
return {"type": "skill", "skill": s, "target": tgt}
|
||||||
|
if s["special"] == "tonic_toss" and actor.sp >= s["sp"] \
|
||||||
|
and tgt.char_id != "NYX":
|
||||||
|
return {"type": "skill", "skill": s, "target": tgt}
|
||||||
|
for iid in ("red_tonic", "bread"):
|
||||||
|
if GS.inventory.get(iid, 0) > 0:
|
||||||
|
return {"type": "item", "item": iid, "target": tgt}
|
||||||
|
dmg = [s for s in skills if s["kind"] in ("phys", "magic")
|
||||||
|
and actor.sp >= s["sp"]]
|
||||||
|
if dmg and enemies and random.random() < 0.7:
|
||||||
|
aoe = [s for s in dmg if s["target"] == "all_enemies"]
|
||||||
|
s = random.choice(aoe) if (aoe and len(enemies) >= 2) else random.choice(dmg)
|
||||||
|
tgt = min(enemies, key=lambda e: e.hp) if enemies else None
|
||||||
|
return {"type": "skill", "skill": s, "target": tgt}
|
||||||
|
if enemies:
|
||||||
|
return {"type": "attack", "target": min(enemies, key=lambda e: e.hp)}
|
||||||
|
return {"type": "guard"}
|
||||||
|
|
||||||
|
|
||||||
|
def battle_ui_win(scr, shot_name=None, max_iter=2000):
|
||||||
|
"""Drive a battle to victory through the ACTUAL on-screen command menu +
|
||||||
|
target picker, by injecting keys (auto: Attack -> first target)."""
|
||||||
|
shot_done = False
|
||||||
|
for _ in range(max_iter):
|
||||||
|
if scr._ended:
|
||||||
|
break
|
||||||
|
pump(3)
|
||||||
|
if not shot_done and shot_name and scr.state == "command":
|
||||||
|
shot(shot_name)
|
||||||
|
shot_done = True
|
||||||
|
if scr.stack.has("victory") or scr.stack.has("defeat"):
|
||||||
|
scr.scene.on_key(K.ENTER, P)
|
||||||
|
pump(3)
|
||||||
|
continue
|
||||||
|
if scr.stack.has("target"):
|
||||||
|
scr.scene.on_key(K.ENTER, P)
|
||||||
|
pump(2)
|
||||||
|
continue
|
||||||
|
if scr.stack.has("cmd"):
|
||||||
|
scr.scene.on_key(K.ENTER, P) # Attack is the first enabled row
|
||||||
|
pump(2)
|
||||||
|
continue
|
||||||
|
if not shot_done and shot_name:
|
||||||
|
shot(shot_name)
|
||||||
|
|
||||||
|
|
||||||
|
def battle_auto_win(scr, shot_name=None, max_iter=8000):
|
||||||
|
"""Drive a boss battle to victory via the autopilot seam (real command
|
||||||
|
dispatch), dismissing the victory/defeat panels with a keypress. The
|
||||||
|
autopilot is installed at battle creation via battle.DEFAULT_AUTOPILOT."""
|
||||||
|
if scr._auto is None:
|
||||||
|
scr.set_autopilot(boss_policy)
|
||||||
|
shot_done = False
|
||||||
|
for _ in range(max_iter):
|
||||||
|
if scr._ended:
|
||||||
|
break
|
||||||
|
pump(3)
|
||||||
|
if not shot_done and shot_name and scr.state in ("resolving", "command"):
|
||||||
|
shot(shot_name)
|
||||||
|
shot_done = True
|
||||||
|
if scr.stack.has("victory") or scr.stack.has("defeat"):
|
||||||
|
scr.scene.on_key(K.ENTER, P)
|
||||||
|
pump(3)
|
||||||
|
continue
|
||||||
|
if not shot_done and shot_name:
|
||||||
|
shot(shot_name)
|
||||||
|
|
||||||
|
|
||||||
|
def level_party_to(level):
|
||||||
|
"""Grant the whole roster enough XP to reach `level` and top everyone up.
|
||||||
|
Stands in for the encounters a real player fights between story beats."""
|
||||||
|
from systems.state import xp_for_level
|
||||||
|
target = xp_for_level(level)
|
||||||
|
for cs in GS.roster.values():
|
||||||
|
if cs.xp < target:
|
||||||
|
cs.add_xp(target - cs.xp)
|
||||||
|
cs.full_heal()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# the playthrough
|
||||||
|
# ===========================================================================
|
||||||
|
def run():
|
||||||
|
random.seed(20260703)
|
||||||
|
os.makedirs(_SHOTS, exist_ok=True)
|
||||||
|
print("=== UNWRITTEN full playthrough ===", flush=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 1. TITLE
|
||||||
|
main.build_title()
|
||||||
|
title_scene = mcrfpy.current_scene
|
||||||
|
pump(20)
|
||||||
|
shot("pt_01_title.png")
|
||||||
|
need(title_scene.name == "title", "title scene not active")
|
||||||
|
beat("title screen")
|
||||||
|
|
||||||
|
# ---------------------------------------- 2. intro dialogue mid-typewriter
|
||||||
|
# press ENTER on the real title handler: starts a new game -> intro monologue
|
||||||
|
title_scene.on_key(K.ENTER, P)
|
||||||
|
pump(10) # entrance + a few characters of the typewriter
|
||||||
|
need(dialogue.CURRENT is not None, "intro dialogue did not start")
|
||||||
|
need(dialogue.CURRENT.box._typing, "intro should be mid-typewriter")
|
||||||
|
shot("pt_02_intro_typewriter.png")
|
||||||
|
beat("intro monologue (mid-typewriter)")
|
||||||
|
|
||||||
|
# advance the whole intro; its terminal 'end' hands off to the overworld
|
||||||
|
finish_dialogue(guard=40)
|
||||||
|
pump(30) # enter_area("hollowbrook", "start")
|
||||||
|
|
||||||
|
# ---------------------------------------------- 3. Hollowbrook, Act 1
|
||||||
|
w = overworld.WORLD
|
||||||
|
need(w is not None and w.area_id == "hollowbrook", "not in hollowbrook")
|
||||||
|
need(abs(w.zoom - 2.0) < 1e-6 and abs(w.grid.zoom - 2.0) < 1e-6,
|
||||||
|
"overworld must render at zoom 2.0 (got %r)" % w.grid.zoom)
|
||||||
|
need(GS.act == 1 and GS.party == ["PIP"], "act1 start state wrong")
|
||||||
|
pump(10)
|
||||||
|
shot("pt_03_hollowbrook_act1.png")
|
||||||
|
beat("Hollowbrook Act 1 (zoom 2)")
|
||||||
|
|
||||||
|
# -------------------------------------------- 4. quill_first choice list
|
||||||
|
open_dialogue("quill_first")
|
||||||
|
need(advance_to_choices(), "quill_first reached no choice node")
|
||||||
|
reveal()
|
||||||
|
shot("pt_04_quill_choices.png")
|
||||||
|
need(len(dialogue.current_choice_labels()) == 2, "quill choices missing")
|
||||||
|
choose_label("What do YOU think") # -> quill_confident (+1)
|
||||||
|
need(advance_to_choices(), "quill_first second choice missing")
|
||||||
|
choose_label("Did everyone wake up") # -> asked_unwoken (+1)
|
||||||
|
finish_dialogue()
|
||||||
|
need(GS.has("quill_confident"), "quill_confident not set")
|
||||||
|
need(GS.has("asked_unwoken"), "asked_unwoken not set")
|
||||||
|
need(not w.frozen, "overworld should unfreeze after quill dialogue")
|
||||||
|
beat("Quill: confident + asked about the unwoken")
|
||||||
|
|
||||||
|
# ---------------------------------- 5. Bramble recruit + North Door opens
|
||||||
|
open_dialogue("bramble_door")
|
||||||
|
need(advance_to_choices(), "bramble_door reached no choices")
|
||||||
|
choose_label("[OATH]") # graceful recruit
|
||||||
|
finish_dialogue()
|
||||||
|
pump(6)
|
||||||
|
shot("pt_05_bramble_door_open.png")
|
||||||
|
need("BRAMBLE" in GS.party, "Bramble not recruited")
|
||||||
|
need(GS.has("bramble_joined") and GS.has("door_open"),
|
||||||
|
"bramble_joined/door_open not set")
|
||||||
|
beat("Ser Bramble joins; North Door opens (door_open)")
|
||||||
|
|
||||||
|
# --------------------------------------- 6. shop dispute (side with Vera)
|
||||||
|
open_dialogue("shop_dispute")
|
||||||
|
need(advance_to_choices(), "shop_dispute reached no choices")
|
||||||
|
reveal()
|
||||||
|
shot("pt_06_shop_dispute.png")
|
||||||
|
labels = dialogue.current_choice_labels()
|
||||||
|
# [ALCHEMY] choice must be shown-but-disabled (no ALCHEMY in party)
|
||||||
|
need(any(("ALCHEMY" in l and not e) for l, e in labels),
|
||||||
|
"the [ALCHEMY] choice should be visible but locked")
|
||||||
|
choose_label("Come with me and prove it") # side with Vera
|
||||||
|
finish_dialogue()
|
||||||
|
need("VERA" in GS.party, "Vera not recruited")
|
||||||
|
need(GS.has("vera_joined_early") and GS.has("griselda_grudge"),
|
||||||
|
"vera_joined_early/griselda_grudge not set")
|
||||||
|
beat("Shop dispute resolved siding with Vera")
|
||||||
|
|
||||||
|
# ------------------------------------------- 7. Griselda's shop (buy bread)
|
||||||
|
GS.gold = 80
|
||||||
|
bread0 = GS.inventory.get("bread", 0)
|
||||||
|
open_dialogue("griselda_shop")
|
||||||
|
choose_label("see your wares") # -> n_shop (text node)
|
||||||
|
dialogue.advance() # fire its 'shop' action
|
||||||
|
pump(10)
|
||||||
|
need(shop._MENU is not None, "shop did not open")
|
||||||
|
shot("pt_07_shop_open.png")
|
||||||
|
gold0 = GS.gold
|
||||||
|
shop._MENU._buy("bread") # real buy path
|
||||||
|
need(GS.inventory.get("bread", 0) == bread0 + 1, "bread not bought")
|
||||||
|
need(GS.gold < gold0, "cogs not spent on bread")
|
||||||
|
shop._MENU._leave()
|
||||||
|
pump(6)
|
||||||
|
need(not w.frozen, "overworld should resume after leaving shop")
|
||||||
|
beat("Griselda's shop open; bought Bread")
|
||||||
|
|
||||||
|
# ------------------------------------------------------- 8. ferry ride node
|
||||||
|
open_dialogue("odd_ferry_1")
|
||||||
|
reveal()
|
||||||
|
shot("pt_08_ferry.png")
|
||||||
|
need(advance_to_choices(), "ferry reached no choices")
|
||||||
|
choose_label("Take the ferry") # req door_open (met)
|
||||||
|
# advance to Odd's mid-ride question, answer it, ride triggers goto_area
|
||||||
|
need(advance_to_choices(), "ferry ride question missing")
|
||||||
|
choose_label("For the town")
|
||||||
|
finish_dialogue() # n6 -> goto_area:gearwood
|
||||||
|
pump(40) # fade + enter_area(gearwood)
|
||||||
|
beat("Ferry ride with Odd -> Gearwood")
|
||||||
|
|
||||||
|
# --------------------------------------------- 9. Gearwood arrival banner
|
||||||
|
w = overworld.WORLD
|
||||||
|
need(w.area_id == "gearwood", "did not arrive in gearwood")
|
||||||
|
pump(8)
|
||||||
|
shot("pt_09_gearwood_arrival.png")
|
||||||
|
beat("Gearwood arrival banner")
|
||||||
|
|
||||||
|
# -------------------------------- 10. real battle vs gw_bats via UI loop
|
||||||
|
level_party_to(3) # stand in for early encounters
|
||||||
|
GS.current_area = "gearwood"
|
||||||
|
won = {"v": False}
|
||||||
|
scr = battle.start_battle("gw_bats", on_victory=lambda: won.__setitem__("v", True))
|
||||||
|
battle_ui_win(scr, shot_name="pt_10_battle_gw_bats.png")
|
||||||
|
need(scr.core.result == "victory", "gw_bats battle not won (%r)"
|
||||||
|
% scr.core.result)
|
||||||
|
need(won["v"], "gw_bats on_victory callback never fired")
|
||||||
|
need(mcrfpy.current_scene is w.scene, "battle did not restore the overworld")
|
||||||
|
beat("gw_bats battle WON through the command menu + target picker")
|
||||||
|
|
||||||
|
# ------------------------------------ 11. Moth shrine (name 'small things')
|
||||||
|
open_dialogue("moth_shrine")
|
||||||
|
need(advance_to_choices(), "moth_shrine reached no choices")
|
||||||
|
reveal()
|
||||||
|
shot("pt_11_moth_shrine.png")
|
||||||
|
choose_label("small things") # moth_light_name_small
|
||||||
|
finish_dialogue()
|
||||||
|
need(GS.has("moth_light_name_small"), "moth light-name not recorded")
|
||||||
|
need("MOTH" in GS.roster, "Moth not recruited")
|
||||||
|
beat("Moth's light named for small things; Moth joins the roster")
|
||||||
|
|
||||||
|
# -------------------- (bridge) open the shrine chest + descend the stair
|
||||||
|
open_dialogue("shrine_chest")
|
||||||
|
finish_dialogue()
|
||||||
|
need(GS.has("shrine_chest_open") and GS.inventory.get("brass_key", 0) >= 1,
|
||||||
|
"shrine chest did not yield the brass key")
|
||||||
|
open_dialogue("gearwood_stair")
|
||||||
|
need(advance_to_choices(), "stair reached no choices")
|
||||||
|
choose_label("Unlock it with the brass key")
|
||||||
|
finish_dialogue() # -> act 2, goto_area:undercroft
|
||||||
|
pump(40)
|
||||||
|
|
||||||
|
# ---------------------------------------------- 12. Undercroft with FOV
|
||||||
|
w = overworld.WORLD
|
||||||
|
need(w.area_id == "undercroft", "did not descend into the undercroft")
|
||||||
|
need(w.fov is not None, "undercroft should build an FOV overlay")
|
||||||
|
need(GS.act == 2, "act should be 2 in the undercroft")
|
||||||
|
pump(10)
|
||||||
|
shot("pt_12_undercroft_fov.png")
|
||||||
|
beat("Undercroft with fog-of-war (FOV)")
|
||||||
|
|
||||||
|
# --------------------------------------------- 13. Cantor wake (write CANTOR)
|
||||||
|
open_dialogue("cantor_wake")
|
||||||
|
need(advance_to_choices(), "cantor_wake reached no choices")
|
||||||
|
reveal()
|
||||||
|
shot("pt_13_cantor_wake.png")
|
||||||
|
choose_label("Write CANTOR")
|
||||||
|
finish_dialogue()
|
||||||
|
need("CANTOR" in GS.roster, "Cantor not recruited")
|
||||||
|
need(GS.has("cantor_joined"), "cantor_joined not set")
|
||||||
|
beat("Cantor woken by writing his name")
|
||||||
|
|
||||||
|
# ---------------------- 14. Gatekeeper boss WON incl. the [OATH] Talk option
|
||||||
|
need("BRAMBLE" in GS.party, "Bramble must be active for the OATH talk")
|
||||||
|
level_party_to(6)
|
||||||
|
GS.grant("red_tonic", 3)
|
||||||
|
GS.current_area = "undercroft"
|
||||||
|
battle.DEFAULT_AUTOPILOT = boss_policy # auto-drive the dialogue-started boss
|
||||||
|
open_dialogue("gatekeeper_pre")
|
||||||
|
need(advance_to_choices(), "gatekeeper_pre reached no choices")
|
||||||
|
choose_label("going through") # -> battle:boss_gatekeeper
|
||||||
|
finish_dialogue() # advance n6 -> starts the battle
|
||||||
|
pump(4)
|
||||||
|
scr = _active_battle()
|
||||||
|
need(scr is not None, "gatekeeper battle did not start")
|
||||||
|
battle_auto_win(scr, shot_name="pt_14_gatekeeper_battle.png")
|
||||||
|
battle.DEFAULT_AUTOPILOT = None
|
||||||
|
need(scr.core.result == "victory", "gatekeeper not defeated (%r)"
|
||||||
|
% scr.core.result)
|
||||||
|
need(GS.has("gatekeeper_relieved"), "the [OATH] Talk option was not used")
|
||||||
|
need(GS.has("gatekeeper_slain"), "gatekeeper_post did not run")
|
||||||
|
beat("Gatekeeper WON using [OATH] Relieve; the vale reverts")
|
||||||
|
|
||||||
|
# gatekeeper_post is now open on the overworld stack; it ends by fading to
|
||||||
|
# the greyed Hollowbrook (act 3), which auto-runs the Bell #3 arrival scene.
|
||||||
|
finish_dialogue()
|
||||||
|
pump(45)
|
||||||
|
|
||||||
|
# ---------------------------------------- 15. grey Hollowbrook (Bell #3)
|
||||||
|
w = overworld.WORLD
|
||||||
|
need(w.area_id == "hollowbrook" and GS.act == 3, "did not revert to hollowbrook")
|
||||||
|
need(w._grey_factor() is not None, "grey wash should be active on arrival")
|
||||||
|
need(dialogue.CURRENT is not None, "the Bell #3 arrival scene did not auto-run")
|
||||||
|
reveal()
|
||||||
|
shot("pt_15_grey_hollowbrook.png")
|
||||||
|
finish_dialogue()
|
||||||
|
need(GS.has("bell_3_done"), "bell_3 arrival did not complete")
|
||||||
|
beat("Reverted Hollowbrook; Bell's apology (bell_3_done)")
|
||||||
|
|
||||||
|
# -------------------------------- 16. one rewake (Quill) -> partial color
|
||||||
|
full_wash = w._grey_factor()
|
||||||
|
open_dialogue("rewake_quill")
|
||||||
|
need(advance_to_choices(), "rewake_quill reached no choices")
|
||||||
|
choose_label("What is a door FOR") # req quill_confident
|
||||||
|
finish_dialogue()
|
||||||
|
pump(10)
|
||||||
|
need(GS.has("rewoke_quill"), "rewoke_quill not set")
|
||||||
|
eased = w._grey_factor()
|
||||||
|
need(eased is not None and eased < full_wash - 0.1,
|
||||||
|
"color should return as Quill rewakes (%.2f -> %.2f)" % (full_wash, eased))
|
||||||
|
shot("pt_16_rewake_quill_partial.png")
|
||||||
|
beat("Quill rewoken; color begins to return")
|
||||||
|
|
||||||
|
# ---------------------- 17. all three rewoken + Nyx recruited at the plinth
|
||||||
|
open_dialogue("rewake_griselda")
|
||||||
|
need(advance_to_choices(), "rewake_griselda reached no choices")
|
||||||
|
choose_label("put me in your LEDGER") # req party VERA (active)
|
||||||
|
finish_dialogue() # -> hold the line
|
||||||
|
need(GS.has("rewoke_griselda"), "rewoke_griselda not set")
|
||||||
|
|
||||||
|
open_dialogue("rewake_odd")
|
||||||
|
need(advance_to_choices(), "rewake_odd reached no choices")
|
||||||
|
choose_label("NEW route into his hand") # press the fare
|
||||||
|
need(advance_to_choices(), "rewake_odd follow-up choice missing")
|
||||||
|
choose_label("I'll write you one") # odd_promised (+1)
|
||||||
|
finish_dialogue()
|
||||||
|
need(GS.has("rewoke_odd") and GS.has("odd_promised"), "odd rewake incomplete")
|
||||||
|
need(GS.has("town_rewoken"), "town_rewoken should set after all 3 rewakes")
|
||||||
|
pump(10)
|
||||||
|
|
||||||
|
# Nyx stands on the empty plinth now; we asked about the unwoken in beat 4
|
||||||
|
open_dialogue("nyx_plinth")
|
||||||
|
need(advance_to_choices(), "nyx_plinth reached no choices")
|
||||||
|
choose_label("I've been looking for you") # req asked_unwoken -> warm join
|
||||||
|
finish_dialogue()
|
||||||
|
need("NYX" in GS.roster and GS.has("nyx_joined"), "Nyx not recruited")
|
||||||
|
pump(6)
|
||||||
|
shot("pt_17_all_rewoken_nyx.png")
|
||||||
|
beat("All three rewoken + Nyx joins at the plinth (town_rewoken)")
|
||||||
|
|
||||||
|
# -------------------------------------------------- 18. Study arrival
|
||||||
|
open_dialogue("odd_ferry_study")
|
||||||
|
need(advance_to_choices(), "odd_ferry_study reached no choices")
|
||||||
|
choose_label("pale channel to the Study")
|
||||||
|
finish_dialogue() # goto_area:study
|
||||||
|
pump(45)
|
||||||
|
w = overworld.WORLD
|
||||||
|
need(w.area_id == "study", "did not reach the Study")
|
||||||
|
pump(8)
|
||||||
|
shot("pt_18_study_arrival.png")
|
||||||
|
beat("The Maker's Study (paper palette)")
|
||||||
|
|
||||||
|
# ----- put the recommended team forward for the finale (REAL party swap) -
|
||||||
|
pm = party_menu.open_menu(w.scene, w.stack, on_close=lambda: None)
|
||||||
|
if "MOTH" not in GS.party:
|
||||||
|
pm._do_swap("BRAMBLE", "MOTH")
|
||||||
|
if "CANTOR" not in GS.party:
|
||||||
|
pm._do_swap("VERA", "CANTOR")
|
||||||
|
pm.destroy()
|
||||||
|
pump(4)
|
||||||
|
need("MOTH" in GS.party and "CANTOR" in GS.party,
|
||||||
|
"party swap for the finale failed: %s" % GS.party)
|
||||||
|
|
||||||
|
# --------------------- 19. Custodian boss WON using a Talk option
|
||||||
|
need(GS.points >= 8, "need >= 8 Story Points for the finale talk options")
|
||||||
|
level_party_to(8)
|
||||||
|
GS.grant("red_tonic", 4)
|
||||||
|
GS.grant("blue_draught", 3)
|
||||||
|
GS.current_area = "study"
|
||||||
|
battle.DEFAULT_AUTOPILOT = boss_policy
|
||||||
|
open_dialogue("custodian_pre")
|
||||||
|
need(advance_to_choices(), "custodian_pre reached no choices")
|
||||||
|
choose_label("Pending isn't perfect") # -> battle:boss_custodian
|
||||||
|
finish_dialogue()
|
||||||
|
pump(4)
|
||||||
|
scr = _active_battle()
|
||||||
|
need(scr is not None, "custodian battle did not start")
|
||||||
|
battle_auto_win(scr, shot_name="pt_19_custodian_battle.png")
|
||||||
|
battle.DEFAULT_AUTOPILOT = None
|
||||||
|
need(scr.core.result == "victory", "custodian not defeated (%r)"
|
||||||
|
% scr.core.result)
|
||||||
|
need(len(scr.core.talk_used) >= 1, "no Talk option was used vs the Custodian")
|
||||||
|
print(" custodian talk options used: %s"
|
||||||
|
% sorted(scr.core.talk_used), flush=True)
|
||||||
|
beat("Custodian WON using Talk (%s)" % ",".join(sorted(scr.core.talk_used)))
|
||||||
|
|
||||||
|
# --------------------------------------- 20. epilogue + final WRITTEN card
|
||||||
|
# custodian_post is open on the overworld stack; choose to read it the Book
|
||||||
|
need(dialogue.CURRENT is not None, "custodian_post did not run")
|
||||||
|
need(advance_to_choices(), "custodian_post reached no choices")
|
||||||
|
choose_label("let it read") # req points >= 8
|
||||||
|
finish_dialogue() # -> action epilogue
|
||||||
|
pump(30)
|
||||||
|
ep = epilogue.CURRENT
|
||||||
|
need(ep is not None, "epilogue did not start")
|
||||||
|
need(ep.pages, "epilogue collected no pages")
|
||||||
|
shot("pt_20a_epilogue_page.png")
|
||||||
|
# page through to the final card, injecting a keypress per page
|
||||||
|
for _ in range(len(ep.pages) + 4):
|
||||||
|
if ep.final:
|
||||||
|
break
|
||||||
|
ep.scene.on_key(K.ENTER, P)
|
||||||
|
pump(20)
|
||||||
|
need(ep.final, "epilogue never reached the final card")
|
||||||
|
pump(120) # stamp + WRITTEN thunk timers
|
||||||
|
shot("pt_20b_written.png")
|
||||||
|
need(GS.has("book_read_custodian"), "book_read_custodian not set")
|
||||||
|
beat("Epilogue read; UNWRITTEN stamped WRITTEN")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- summary
|
||||||
|
print("", flush=True)
|
||||||
|
print("=== PLAYTHROUGH COMPLETE ===", flush=True)
|
||||||
|
print("Story Points: %d" % GS.points, flush=True)
|
||||||
|
print("Roster: %s" % sorted(GS.roster), flush=True)
|
||||||
|
print("Active party: %s" % GS.party, flush=True)
|
||||||
|
key_flags = ["quill_confident", "asked_unwoken", "bramble_joined",
|
||||||
|
"door_open", "vera_joined_early", "griselda_grudge",
|
||||||
|
"moth_light_name_small", "moth_joined", "cantor_joined",
|
||||||
|
"gatekeeper_relieved", "gatekeeper_slain", "bell_3_done",
|
||||||
|
"rewoke_quill", "rewoke_griselda", "rewoke_odd", "odd_promised",
|
||||||
|
"town_rewoken", "nyx_joined", "book_read_custodian"]
|
||||||
|
have = [f for f in key_flags if GS.has(f)]
|
||||||
|
print("Flags set (%d/%d): %s" % (len(have), len(key_flags), have), flush=True)
|
||||||
|
missing = [f for f in key_flags if not GS.has(f)]
|
||||||
|
need(not missing, "expected story flags missing: %s" % missing)
|
||||||
|
need(GS.points >= 12, "story points unexpectedly low: %d" % GS.points)
|
||||||
|
need(len(GS.roster) == 6, "expected all 6 recruited, got %s" % sorted(GS.roster))
|
||||||
|
print("PASS playthrough", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_battle():
|
||||||
|
"""The BattleScreen that start_battle just created is referenced by the
|
||||||
|
dialogue hook only transiently; grab it via current_scene's owner."""
|
||||||
|
return battle._LAST_SCREEN
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
317
games/unwritten/tests/test_battle_sim.py
Normal file
317
games/unwritten/tests/test_battle_sim.py
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
"""UNWRITTEN - battle simulation + screenshots. Owner: Agent B.
|
||||||
|
|
||||||
|
Runs 225 scripted headless battles across every pack (both bosses included)
|
||||||
|
with a level-appropriate party and an auto command policy, asserting:
|
||||||
|
- no exceptions,
|
||||||
|
- victory is possible for every pack,
|
||||||
|
- XP / gold / loot are actually granted,
|
||||||
|
then prints a balance report (win rate + avg rounds per pack) for Fable.
|
||||||
|
|
||||||
|
Finally builds the real BattleScreen and screenshots three states:
|
||||||
|
battle_1.png command menu open on a player turn vs gw_mixed
|
||||||
|
battle_2.png mid-action, floating damage number
|
||||||
|
battle_3.png victory panel
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_battle_sim.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import random
|
||||||
|
|
||||||
|
GAMEROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, GAMEROOT)
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
|
|
||||||
|
from systems.state import GS
|
||||||
|
from systems import battle
|
||||||
|
from systems.battle import BattleCore
|
||||||
|
from data import characters as chardata
|
||||||
|
from data import items as itemdata
|
||||||
|
|
||||||
|
SHOTS = os.path.join(GAMEROOT, "tests", "shots")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- party setup
|
||||||
|
_WEAPON = {
|
||||||
|
1: {"PIP": "knife", "BRAMBLE": "worn_cudgel", "MOTH": "wick_staff",
|
||||||
|
"VERA": "wick_staff", "CANTOR": "worn_cudgel", "NYX": "knife"},
|
||||||
|
3: {"PIP": "short_sword", "BRAMBLE": "short_sword", "MOTH": "wick_staff",
|
||||||
|
"VERA": "wick_staff", "CANTOR": "short_sword", "NYX": "short_sword"},
|
||||||
|
5: {"PIP": "twin_axe", "BRAMBLE": "broad_blade", "MOTH": "tide_rod",
|
||||||
|
"VERA": "tide_rod", "CANTOR": "broad_blade", "NYX": "twin_axe"},
|
||||||
|
}
|
||||||
|
_TRINKET = {1: "river_stone", 3: "river_stone", 5: "waxed_charm"}
|
||||||
|
|
||||||
|
|
||||||
|
def _gear_tier(level):
|
||||||
|
if level >= 5:
|
||||||
|
return 5
|
||||||
|
if level >= 3:
|
||||||
|
return 3
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def setup(comp, level, points=0, act=1):
|
||||||
|
GS.reset()
|
||||||
|
GS.act = act
|
||||||
|
GS.points = points
|
||||||
|
tier = _gear_tier(level)
|
||||||
|
for cid in comp:
|
||||||
|
GS.recruit(cid, level)
|
||||||
|
cs = GS.roster[cid]
|
||||||
|
cs.weapon = _WEAPON[tier].get(cid)
|
||||||
|
cs.trinket = _TRINKET[tier]
|
||||||
|
cs.full_heal()
|
||||||
|
GS.gold = 0
|
||||||
|
GS.grant("bread", 5)
|
||||||
|
GS.grant("red_tonic", 2)
|
||||||
|
GS.grant("blue_draught", 2)
|
||||||
|
GS.grant("bomb_flask", 1)
|
||||||
|
GS.grant("chalk", 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- auto policy
|
||||||
|
def policy(core, actor):
|
||||||
|
skills = chardata.learned_skills(actor.char_id, actor.cstate.level)
|
||||||
|
enemies = core.alive_enemies()
|
||||||
|
allies = core.alive_party()
|
||||||
|
|
||||||
|
# 1. heal a badly hurt ally
|
||||||
|
hurt = [a for a in allies if a.hp < a.max_hp * 0.45]
|
||||||
|
if hurt:
|
||||||
|
tgt = min(hurt, key=lambda c: c.hp / float(c.max_hp))
|
||||||
|
for s in skills:
|
||||||
|
if s["special"] in ("kindle", "tonic_toss") and actor.sp >= s["sp"]:
|
||||||
|
if s["special"] == "tonic_toss" and tgt.char_id == "NYX":
|
||||||
|
continue
|
||||||
|
return {"type": "skill", "skill": s, "target": tgt}
|
||||||
|
|
||||||
|
# 2. occasionally exercise a boss talk option
|
||||||
|
if core.is_boss and random.random() < 0.35:
|
||||||
|
opts = [o for o in core.boss_talk_options() if o["enabled"]]
|
||||||
|
if opts:
|
||||||
|
return {"type": "talk", "talk": opts[0]["id"]}
|
||||||
|
|
||||||
|
# 3. occasionally set up buffs (exercise shieldwall / second shadow)
|
||||||
|
for s in skills:
|
||||||
|
if s["special"] == "shieldwall" and not core.party_guard_active \
|
||||||
|
and actor.sp >= s["sp"] and random.random() < 0.25:
|
||||||
|
return {"type": "skill", "skill": s, "target": None}
|
||||||
|
if s["special"] == "second_shadow" and not actor.guaranteed_crit \
|
||||||
|
and actor.sp >= s["sp"] and random.random() < 0.30:
|
||||||
|
return {"type": "skill", "skill": s, "target": None}
|
||||||
|
|
||||||
|
# 4. offensive skill (prefer AoE with multiple foes)
|
||||||
|
dmg = [s for s in skills
|
||||||
|
if s["kind"] in ("phys", "magic") and actor.sp >= s["sp"]]
|
||||||
|
if dmg and random.random() < 0.6:
|
||||||
|
aoe = [s for s in dmg if s["target"] == "all_enemies"]
|
||||||
|
if aoe and len(enemies) >= 2:
|
||||||
|
s = random.choice(aoe)
|
||||||
|
else:
|
||||||
|
s = random.choice(dmg)
|
||||||
|
tgt = min(enemies, key=lambda e: e.hp) if enemies else None
|
||||||
|
return {"type": "skill", "skill": s, "target": tgt}
|
||||||
|
|
||||||
|
# 5. basic attack, focus the weakest enemy
|
||||||
|
if enemies:
|
||||||
|
return {"type": "attack", "target": min(enemies, key=lambda e: e.hp)}
|
||||||
|
return {"type": "guard"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- one battle
|
||||||
|
def run_once(pack_id, comp, level, points=0, act=1):
|
||||||
|
setup(comp, level, points, act)
|
||||||
|
core = BattleCore(pack_id)
|
||||||
|
core.begin_round()
|
||||||
|
rounds = 1
|
||||||
|
steps = 0
|
||||||
|
while True:
|
||||||
|
na = core.next_actor()
|
||||||
|
if na is None:
|
||||||
|
core.end_round()
|
||||||
|
if core.check_end():
|
||||||
|
break
|
||||||
|
core.begin_round()
|
||||||
|
rounds += 1
|
||||||
|
if rounds > battle._MAX_ROUNDS:
|
||||||
|
core.result = "defeat"
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
actor, forced = na
|
||||||
|
if forced is not None:
|
||||||
|
action = forced
|
||||||
|
elif actor.side == "enemy":
|
||||||
|
action = core.ai_action(actor)
|
||||||
|
else:
|
||||||
|
action = policy(core, actor)
|
||||||
|
result = core.resolve(actor, action)
|
||||||
|
if result["flee"]:
|
||||||
|
core.result = "flee"
|
||||||
|
break
|
||||||
|
if core.check_end():
|
||||||
|
break
|
||||||
|
steps += 1
|
||||||
|
if steps > 6000:
|
||||||
|
core.result = "defeat"
|
||||||
|
break
|
||||||
|
|
||||||
|
xp = gold = 0
|
||||||
|
drops = []
|
||||||
|
if core.result == "victory":
|
||||||
|
xp, gold, drops = core.collect_rewards()
|
||||||
|
GS.xp_gain(xp)
|
||||||
|
GS.add_gold(gold)
|
||||||
|
for d in drops:
|
||||||
|
GS.grant(d, 1)
|
||||||
|
return core.result, rounds, xp, gold, drops
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sim config
|
||||||
|
# pack_id -> (party comp, level, points, act, label for report)
|
||||||
|
SIM = [
|
||||||
|
("gw_bats", (["PIP", "BRAMBLE", "MOTH"], 1, 0, 1)),
|
||||||
|
("gw_mixed", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)),
|
||||||
|
("gw_mixed2", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)),
|
||||||
|
("gw_scarabs", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)),
|
||||||
|
("gw_slimes", (["PIP", "BRAMBLE", "MOTH"], 1, 0, 1)),
|
||||||
|
("uc_bones", (["PIP", "BRAMBLE", "CANTOR"], 3, 0, 2)),
|
||||||
|
("uc_bones2", (["PIP", "BRAMBLE", "MOTH"], 4, 0, 2)),
|
||||||
|
("uc_chimes", (["PIP", "CANTOR", "MOTH"], 4, 0, 2)),
|
||||||
|
("uc_echoes", (["PIP", "BRAMBLE", "MOTH"], 3, 0, 2)),
|
||||||
|
("uc_mixed", (["PIP", "CANTOR", "MOTH"], 4, 0, 2)),
|
||||||
|
("uc_mimic", (["PIP", "BRAMBLE", "MOTH"], 4, 0, 2)),
|
||||||
|
("st_echoes", (["PIP", "MOTH", "CANTOR"], 5, 0, 3)),
|
||||||
|
("st_echoes2", (["PIP", "MOTH", "CANTOR"], 6, 0, 3)),
|
||||||
|
("boss_gatekeeper", (["PIP", "BRAMBLE", "MOTH"], 5, 4, 2)),
|
||||||
|
("boss_custodian", (["PIP", "MOTH", "CANTOR"], 7, 8, 3)),
|
||||||
|
]
|
||||||
|
TRIALS = 15
|
||||||
|
|
||||||
|
|
||||||
|
def run_sim():
|
||||||
|
random.seed(20260703)
|
||||||
|
report = []
|
||||||
|
total_xp = total_gold = total_drops = 0
|
||||||
|
total_battles = 0
|
||||||
|
for pack_id, (comp, level, points, act) in SIM:
|
||||||
|
wins = 0
|
||||||
|
round_sum = 0
|
||||||
|
win_rounds = 0
|
||||||
|
for _ in range(TRIALS):
|
||||||
|
res, rounds, xp, gold, drops = run_once(
|
||||||
|
pack_id, comp, level, points, act)
|
||||||
|
total_battles += 1
|
||||||
|
total_xp += xp
|
||||||
|
total_gold += gold
|
||||||
|
total_drops += len(drops)
|
||||||
|
round_sum += rounds
|
||||||
|
if res == "victory":
|
||||||
|
wins += 1
|
||||||
|
win_rounds += rounds
|
||||||
|
win_rate = 100.0 * wins / TRIALS
|
||||||
|
avg_rounds = (win_rounds / wins) if wins else round_sum / TRIALS
|
||||||
|
report.append((pack_id, level, win_rate, avg_rounds, wins))
|
||||||
|
return report, total_battles, total_xp, total_gold, total_drops
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(report, total_battles, total_xp, total_gold, total_drops):
|
||||||
|
print("", flush=True)
|
||||||
|
print("=" * 64, flush=True)
|
||||||
|
print("UNWRITTEN - BATTLE BALANCE REPORT (%d battles, %d per pack)"
|
||||||
|
% (total_battles, TRIALS), flush=True)
|
||||||
|
print("=" * 64, flush=True)
|
||||||
|
print("%-18s %3s %8s %10s" % ("pack", "Lv", "win%", "avg rounds"),
|
||||||
|
flush=True)
|
||||||
|
print("-" * 64, flush=True)
|
||||||
|
for pack_id, level, wr, ar, wins in report:
|
||||||
|
print("%-18s %3d %7.0f%% %10.1f" % (pack_id, level, wr, ar), flush=True)
|
||||||
|
print("-" * 64, flush=True)
|
||||||
|
print("totals: XP granted %d | cogs granted %d | drops %d"
|
||||||
|
% (total_xp, total_gold, total_drops), flush=True)
|
||||||
|
print("=" * 64, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_sim(report, total_xp, total_gold, total_drops):
|
||||||
|
problems = []
|
||||||
|
for pack_id, level, wr, ar, wins in report:
|
||||||
|
if wins == 0:
|
||||||
|
problems.append("pack %s never won (win rate 0%%)" % pack_id)
|
||||||
|
if total_xp <= 0:
|
||||||
|
problems.append("no XP was ever granted")
|
||||||
|
if total_gold <= 0:
|
||||||
|
problems.append("no gold was ever granted")
|
||||||
|
if total_drops <= 0:
|
||||||
|
problems.append("no loot drops occurred")
|
||||||
|
if problems:
|
||||||
|
for p in problems:
|
||||||
|
print("FAIL: %s" % p, flush=True)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- screenshots
|
||||||
|
def step(seconds):
|
||||||
|
n = int(seconds / (1.0 / 60.0))
|
||||||
|
for _ in range(max(1, n)):
|
||||||
|
mcrfpy.step(1.0 / 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
def take_screenshots():
|
||||||
|
if not os.path.isdir(SHOTS):
|
||||||
|
os.makedirs(SHOTS)
|
||||||
|
random.seed(7)
|
||||||
|
|
||||||
|
# --- shot 1: command menu open vs gw_mixed ---
|
||||||
|
setup(["PIP", "BRAMBLE", "MOTH"], 2)
|
||||||
|
GS.current_area = "gearwood"
|
||||||
|
screen = battle.start_battle("gw_mixed")
|
||||||
|
tries = 0
|
||||||
|
while screen.state != "command" and tries < 400:
|
||||||
|
step(0.05)
|
||||||
|
tries += 1
|
||||||
|
step(0.05)
|
||||||
|
automation.screenshot(os.path.join(SHOTS, "battle_1.png"))
|
||||||
|
print("shot 1 (command menu, state=%s) saved" % screen.state, flush=True)
|
||||||
|
|
||||||
|
# --- shot 2: mid-action floating damage ---
|
||||||
|
actor = screen.cur_actor
|
||||||
|
target = screen.core.alive_enemies()[0]
|
||||||
|
if actor is not None and actor.side == "party":
|
||||||
|
screen._commit(actor, {"type": "attack", "target": target})
|
||||||
|
step(0.15)
|
||||||
|
automation.screenshot(os.path.join(SHOTS, "battle_2.png"))
|
||||||
|
print("shot 2 (mid-action) saved", flush=True)
|
||||||
|
|
||||||
|
# --- shot 3: victory panel ---
|
||||||
|
for e in screen.core.enemies:
|
||||||
|
e.hp = 0
|
||||||
|
screen.core.result = "victory"
|
||||||
|
screen._on_battle_end()
|
||||||
|
step(0.1)
|
||||||
|
automation.screenshot(os.path.join(SHOTS, "battle_3.png"))
|
||||||
|
print("shot 3 (victory panel) saved", flush=True)
|
||||||
|
screen._teardown()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- main
|
||||||
|
def main():
|
||||||
|
report, total_battles, total_xp, total_gold, total_drops = run_sim()
|
||||||
|
print_report(report, total_battles, total_xp, total_gold, total_drops)
|
||||||
|
ok = assert_sim(report, total_xp, total_gold, total_drops)
|
||||||
|
|
||||||
|
take_screenshots()
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
print("PASS test_battle_sim", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("FAIL test_battle_sim", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
116
games/unwritten/tests/test_overworld.py
Normal file
116
games/unwritten/tests/test_overworld.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""UNWRITTEN - overworld / menus / epilogue headless test. Owner: Agent C.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_overworld.py
|
||||||
|
|
||||||
|
Drives the overworld through its meaningful states and screenshots each:
|
||||||
|
ow_hollowbrook ow_dialogue ow_undercroft ow_grey ow_grey_partial ow_epilogue
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
|
|
||||||
|
import main
|
||||||
|
from systems.state import GS
|
||||||
|
from systems import overworld
|
||||||
|
from systems import epilogue
|
||||||
|
from systems import shop # noqa: F401 (registers HOOKS["shop"])
|
||||||
|
from systems import party_menu # noqa: F401 (registers HOOKS["swap_menu"])
|
||||||
|
|
||||||
|
_SHOTS = os.path.join(_ROOT, "tests", "shots")
|
||||||
|
|
||||||
|
|
||||||
|
def _shot(name):
|
||||||
|
os.makedirs(_SHOTS, exist_ok=True)
|
||||||
|
automation.screenshot(os.path.join(_SHOTS, name))
|
||||||
|
print(" shot", name, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _pump(n):
|
||||||
|
for _ in range(n):
|
||||||
|
mcrfpy.step(1.0 / 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
def main_test():
|
||||||
|
# encounters have no data.enemies yet (Agent B, parallel): use a placeholder
|
||||||
|
overworld.TEST_ENEMY_FALLBACK = 86
|
||||||
|
|
||||||
|
# ---- 1. Hollowbrook, Act 1, fresh -------------------------------------
|
||||||
|
main._preset_act1()
|
||||||
|
overworld.enter_area("hollowbrook", "start")
|
||||||
|
w = overworld.WORLD
|
||||||
|
assert w is not None and w.area_id == "hollowbrook", "world not built"
|
||||||
|
assert GS.current_area == "hollowbrook", "GS.current_area not set"
|
||||||
|
# a few real movement steps
|
||||||
|
for _ in range(3):
|
||||||
|
w.try_step(0, -1)
|
||||||
|
_pump(10)
|
||||||
|
w.try_step(-1, 0)
|
||||||
|
_pump(20)
|
||||||
|
_shot("ow_hollowbrook.png")
|
||||||
|
print("PASS hollowbrook build + movement", flush=True)
|
||||||
|
|
||||||
|
# ---- 2. Bump SER BRAMBLE -> dialogue opens ----------------------------
|
||||||
|
assert "BRAMBLE" in w.npc_entities, "Bramble NPC missing in Act 1"
|
||||||
|
w.player.grid_pos = (11, 5)
|
||||||
|
w.player.draw_pos = (11, 5)
|
||||||
|
w.try_step(0, -1) # step up into Bramble at (11,4)
|
||||||
|
assert w.frozen, "bumping an NPC should freeze movement (dialogue open)"
|
||||||
|
_pump(40) # let the box slide up + typewriter run
|
||||||
|
_shot("ow_dialogue.png")
|
||||||
|
print("PASS bramble bump opens dialogue", flush=True)
|
||||||
|
|
||||||
|
# ---- 3. Undercroft with FOV -------------------------------------------
|
||||||
|
main._preset_act2()
|
||||||
|
overworld.enter_area("undercroft", "enter")
|
||||||
|
w2 = overworld.WORLD
|
||||||
|
assert w2.fov is not None, "undercroft should build an FOV overlay"
|
||||||
|
assert w2.grid.perspective is w2.player, "grid.perspective not the player"
|
||||||
|
w2.try_step(1, 0)
|
||||||
|
_pump(15)
|
||||||
|
w2.try_step(0, 1)
|
||||||
|
_pump(20)
|
||||||
|
_shot("ow_undercroft.png")
|
||||||
|
print("PASS undercroft FOV", flush=True)
|
||||||
|
|
||||||
|
# ---- 4. Act 3 greyed Hollowbrook --------------------------------------
|
||||||
|
main._preset_act3()
|
||||||
|
assert not GS.has("rewoke_quill"), "preset should start with no rewakes"
|
||||||
|
overworld.enter_area("hollowbrook", "from_ferry")
|
||||||
|
w3 = overworld.WORLD
|
||||||
|
assert w3._grey_factor() is not None, "grey wash should be active in Act 3"
|
||||||
|
_pump(20)
|
||||||
|
_shot("ow_grey.png")
|
||||||
|
print("PASS grey town (full wash)", flush=True)
|
||||||
|
|
||||||
|
# ---- 5. Two rewakes -> color returns ----------------------------------
|
||||||
|
GS.add_flag("rewoke_quill")
|
||||||
|
GS.add_flag("rewoke_griselda")
|
||||||
|
overworld.refresh_area()
|
||||||
|
f = w3._grey_factor()
|
||||||
|
assert f is not None and abs(f - 0.24) < 0.01, "wash should ease to ~0.24"
|
||||||
|
_pump(15)
|
||||||
|
_shot("ow_grey_partial.png")
|
||||||
|
print("PASS grey partial (color returning)", flush=True)
|
||||||
|
|
||||||
|
# ---- 6. Epilogue page 1 -----------------------------------------------
|
||||||
|
# a real Moth recruit always records a light-name; the act-3 preset omits it
|
||||||
|
GS.add_flag("moth_light_name_small")
|
||||||
|
ep = epilogue.start_epilogue()
|
||||||
|
assert GS.has("nyx_missed"), "epilogue must set nyx_missed when Nyx absent"
|
||||||
|
_pump(40) # 240ms schedule + fade-in
|
||||||
|
assert ep.pages, "epilogue collected no pages"
|
||||||
|
_shot("ow_epilogue.png")
|
||||||
|
print("PASS epilogue renders", flush=True)
|
||||||
|
|
||||||
|
print("PASS test_overworld", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main_test()
|
||||||
227
games/unwritten/tests/test_script_integrity.py
Normal file
227
games/unwritten/tests/test_script_integrity.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
"""UNWRITTEN - script integrity walker. Owner: Agent A.
|
||||||
|
|
||||||
|
Walks every scene in data/script_act1|2|3 plus data/epilogue.PAGES and asserts:
|
||||||
|
- every 'next' and choice target node exists within its scene
|
||||||
|
- every req and effect tuple is a form the dialogue runner supports
|
||||||
|
- every speaker id has a portrait entry (core.assets.PORTRAITS)
|
||||||
|
- every action string parses
|
||||||
|
- collects the battle pack ids referenced (for Agent B)
|
||||||
|
|
||||||
|
Protects the authored content: run after ANY change to the scripts.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_script_integrity.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from core import assets
|
||||||
|
from data import script_act1, script_act2, script_act3, epilogue
|
||||||
|
|
||||||
|
REQ_KINDS = {"flag", "flag_not", "tag", "party", "points", "item"}
|
||||||
|
EFFECT_KINDS = {"flag", "points", "gold", "item", "act", "key_item_remove"}
|
||||||
|
ACTION_NAMES = {"end", "shop", "heal_party", "swap_menu", "recruit",
|
||||||
|
"battle", "goto_area", "epilogue", "act"}
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
battle_packs = set()
|
||||||
|
counts = {"scenes": 0, "nodes": 0, "choices": 0, "effects": 0, "reqs": 0,
|
||||||
|
"actions": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def err(where, msg):
|
||||||
|
errors.append("%s: %s" % (where, msg))
|
||||||
|
|
||||||
|
|
||||||
|
def check_req(where, req):
|
||||||
|
if req is None:
|
||||||
|
return
|
||||||
|
counts["reqs"] += 1
|
||||||
|
if not isinstance(req, tuple) or len(req) < 1:
|
||||||
|
err(where, "malformed req %r" % (req,))
|
||||||
|
return
|
||||||
|
kind = req[0]
|
||||||
|
if kind not in REQ_KINDS:
|
||||||
|
err(where, "unknown req kind %r" % (kind,))
|
||||||
|
return
|
||||||
|
if kind == "points":
|
||||||
|
if len(req) != 2 or not isinstance(req[1], int):
|
||||||
|
err(where, "points req needs int arg: %r" % (req,))
|
||||||
|
else:
|
||||||
|
if len(req) != 2 or not isinstance(req[1], str):
|
||||||
|
err(where, "%s req needs str arg: %r" % (kind, req))
|
||||||
|
|
||||||
|
|
||||||
|
def check_effects(where, effects):
|
||||||
|
if effects is None:
|
||||||
|
return
|
||||||
|
if not isinstance(effects, (list, tuple)):
|
||||||
|
err(where, "effects must be a list: %r" % (effects,))
|
||||||
|
return
|
||||||
|
for e in effects:
|
||||||
|
counts["effects"] += 1
|
||||||
|
if not isinstance(e, tuple) or len(e) < 1:
|
||||||
|
err(where, "malformed effect %r" % (e,))
|
||||||
|
continue
|
||||||
|
kind = e[0]
|
||||||
|
if kind not in EFFECT_KINDS:
|
||||||
|
err(where, "unknown effect kind %r" % (kind,))
|
||||||
|
continue
|
||||||
|
if kind in ("flag", "key_item_remove"):
|
||||||
|
if len(e) != 2 or not isinstance(e[1], str):
|
||||||
|
err(where, "%s effect needs str arg: %r" % (kind, e))
|
||||||
|
elif kind in ("points", "gold", "act"):
|
||||||
|
if len(e) != 2 or not isinstance(e[1], int):
|
||||||
|
err(where, "%s effect needs int arg: %r" % (kind, e))
|
||||||
|
elif kind == "item":
|
||||||
|
if len(e) != 3 or not isinstance(e[1], str) or not isinstance(e[2], int):
|
||||||
|
err(where, "item effect needs (id:str, n:int): %r" % (e,))
|
||||||
|
|
||||||
|
|
||||||
|
def check_action(where, action):
|
||||||
|
counts["actions"] += 1
|
||||||
|
if not isinstance(action, str):
|
||||||
|
err(where, "action must be a str: %r" % (action,))
|
||||||
|
return
|
||||||
|
parts = action.split(":")
|
||||||
|
name = parts[0]
|
||||||
|
if name not in ACTION_NAMES:
|
||||||
|
err(where, "unknown action %r" % (action,))
|
||||||
|
return
|
||||||
|
if name in ("end", "shop", "heal_party", "swap_menu", "epilogue"):
|
||||||
|
if len(parts) != 1:
|
||||||
|
err(where, "action %r takes no args" % (action,))
|
||||||
|
elif name == "recruit":
|
||||||
|
if len(parts) != 3:
|
||||||
|
err(where, "recruit needs CHARID:LEVEL: %r" % (action,))
|
||||||
|
else:
|
||||||
|
if parts[1] not in assets.PORTRAITS:
|
||||||
|
err(where, "recruit unknown char %r" % (parts[1],))
|
||||||
|
try:
|
||||||
|
int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
err(where, "recruit level not int: %r" % (action,))
|
||||||
|
elif name == "battle":
|
||||||
|
if len(parts) != 2:
|
||||||
|
err(where, "battle needs pack id: %r" % (action,))
|
||||||
|
else:
|
||||||
|
battle_packs.add(parts[1])
|
||||||
|
elif name == "goto_area":
|
||||||
|
if len(parts) != 3:
|
||||||
|
err(where, "goto_area needs area:spawn: %r" % (action,))
|
||||||
|
elif name == "act":
|
||||||
|
if len(parts) != 2 or not parts[1].isdigit():
|
||||||
|
err(where, "act needs int: %r" % (action,))
|
||||||
|
|
||||||
|
|
||||||
|
def walk_scenes(modname, scenes):
|
||||||
|
for scene_id, scene in scenes.items():
|
||||||
|
counts["scenes"] += 1
|
||||||
|
where0 = "%s/%s" % (modname, scene_id)
|
||||||
|
nodes = scene.get("nodes", {})
|
||||||
|
start = scene.get("start")
|
||||||
|
if start not in nodes:
|
||||||
|
err(where0, "start node %r missing" % (start,))
|
||||||
|
for node_id, node in nodes.items():
|
||||||
|
counts["nodes"] += 1
|
||||||
|
where = "%s/%s" % (where0, node_id)
|
||||||
|
|
||||||
|
speaker = node.get("speaker", "NARRATOR")
|
||||||
|
if speaker not in assets.PORTRAITS:
|
||||||
|
err(where, "speaker %r has no portrait entry" % (speaker,))
|
||||||
|
|
||||||
|
check_effects(where, node.get("effects"))
|
||||||
|
|
||||||
|
action = node.get("action")
|
||||||
|
if action is not None:
|
||||||
|
check_action(where, action)
|
||||||
|
|
||||||
|
has_choices = "choices" in node
|
||||||
|
has_next = "next" in node
|
||||||
|
if has_choices:
|
||||||
|
for i, c in enumerate(node["choices"]):
|
||||||
|
counts["choices"] += 1
|
||||||
|
cw = "%s/choice[%d]" % (where, i)
|
||||||
|
check_req(cw, c.get("req"))
|
||||||
|
check_effects(cw, c.get("effects"))
|
||||||
|
tgt = c.get("next")
|
||||||
|
if tgt is None:
|
||||||
|
err(cw, "choice has no 'next'")
|
||||||
|
elif tgt not in nodes:
|
||||||
|
err(cw, "choice target %r missing" % (tgt,))
|
||||||
|
if has_next:
|
||||||
|
tgt = node["next"]
|
||||||
|
if tgt not in nodes:
|
||||||
|
err(where, "next target %r missing" % (tgt,))
|
||||||
|
|
||||||
|
# a node must resolve somehow: choices, next, or a terminal action
|
||||||
|
if not has_choices and not has_next and action is None:
|
||||||
|
err(where, "dead-end node (no choices, no next, no action)")
|
||||||
|
|
||||||
|
|
||||||
|
def walk_epilogue():
|
||||||
|
valid = 0
|
||||||
|
for i, entry in enumerate(epilogue.PAGES):
|
||||||
|
where = "epilogue/PAGES[%d]" % (i,)
|
||||||
|
if not isinstance(entry, tuple) or len(entry) != 2:
|
||||||
|
err(where, "page must be (condition, text): %r" % (entry,))
|
||||||
|
continue
|
||||||
|
cond, text = entry
|
||||||
|
if cond is not None:
|
||||||
|
if not isinstance(cond, tuple) or cond[0] not in ("flag", "flag_not", "points"):
|
||||||
|
err(where, "unsupported page condition %r" % (cond,))
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
err(where, "page text must be a non-empty str")
|
||||||
|
valid += 1
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
walk_scenes("act1", script_act1.SCENES)
|
||||||
|
walk_scenes("act2", script_act2.SCENES)
|
||||||
|
walk_scenes("act3", script_act3.SCENES)
|
||||||
|
pages = walk_epilogue()
|
||||||
|
|
||||||
|
# cross-check: no duplicate scene ids across files
|
||||||
|
all_ids = (list(script_act1.SCENES) + list(script_act2.SCENES) +
|
||||||
|
list(script_act3.SCENES))
|
||||||
|
dupes = set(x for x in all_ids if all_ids.count(x) > 1)
|
||||||
|
if dupes:
|
||||||
|
err("global", "duplicate scene ids across script files: %s" % sorted(dupes))
|
||||||
|
|
||||||
|
# battle packs referenced by the overworld maps (for Agent B's full list)
|
||||||
|
map_packs = set()
|
||||||
|
try:
|
||||||
|
from data import maps
|
||||||
|
for area in maps.AREAS.values():
|
||||||
|
for enc in area.get("encounters", []):
|
||||||
|
map_packs.add(enc["pack"])
|
||||||
|
except Exception as e:
|
||||||
|
print("note: could not read data/maps.py encounters (%s)" % (e,), flush=True)
|
||||||
|
|
||||||
|
print("--- UNWRITTEN script integrity ---", flush=True)
|
||||||
|
print("scenes=%(scenes)d nodes=%(nodes)d choices=%(choices)d "
|
||||||
|
"effects=%(effects)d reqs=%(reqs)d actions=%(actions)d"
|
||||||
|
% counts, flush=True)
|
||||||
|
print("epilogue pages=%d" % pages, flush=True)
|
||||||
|
print("battle packs referenced in SCRIPTS: %s"
|
||||||
|
% (sorted(battle_packs) if battle_packs else "(none)"), flush=True)
|
||||||
|
print("battle packs referenced in MAPS: %s"
|
||||||
|
% (sorted(map_packs) if map_packs else "(none)"), flush=True)
|
||||||
|
print("ALL battle packs (for Agent B): %s"
|
||||||
|
% sorted(battle_packs | map_packs), flush=True)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("FAIL test_script_integrity (%d problems):" % len(errors), flush=True)
|
||||||
|
for e in errors:
|
||||||
|
print(" " + e, flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("PASS test_script_integrity", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
104
games/unwritten/tests/test_ui_kit.py
Normal file
104
games/unwritten/tests/test_ui_kit.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""UNWRITTEN - UI kit headless test. Owner: Agent A.
|
||||||
|
|
||||||
|
Builds a scene, exercises every widget (DialogueBox with a real node from
|
||||||
|
script_act1 'intro', a MenuList with a disabled row, a Bar at 30%, a Toast, a
|
||||||
|
TitleBanner), drives mcrfpy.step to advance the typewriter, and screenshots two
|
||||||
|
meaningful states.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_ui_kit.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
from core import palette, ui
|
||||||
|
from data import script_act1
|
||||||
|
|
||||||
|
SHOTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
|
||||||
|
|
||||||
|
|
||||||
|
def step_n(n):
|
||||||
|
for _ in range(n):
|
||||||
|
mcrfpy.step(1.0 / 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(SHOTS, exist_ok=True)
|
||||||
|
scene = mcrfpy.Scene("uikit")
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
ch = scene.children
|
||||||
|
|
||||||
|
# background
|
||||||
|
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=palette.INK)
|
||||||
|
ch.append(bg)
|
||||||
|
|
||||||
|
# --- MenuList with one disabled row (top-left) ---
|
||||||
|
menu = ui.MenuList(
|
||||||
|
ch, (60, 150), 300,
|
||||||
|
items=[
|
||||||
|
("Attack", "attack", True),
|
||||||
|
("Skill", "skill", True),
|
||||||
|
("Item", "item", False, "no items"),
|
||||||
|
("Guard", "guard", True),
|
||||||
|
],
|
||||||
|
title="COMMAND")
|
||||||
|
|
||||||
|
# --- Bar at 30% (right) ---
|
||||||
|
bar_panel = ui.Panel(ch, (600, 150), (340, 70), outline_color=palette.OUTLINE)
|
||||||
|
ui.Label(bar_panel.children, "HP", (16, 14), color=palette.PARCH,
|
||||||
|
size=palette.SMALL_SIZE)
|
||||||
|
hp_bar = ui.Bar(bar_panel.children, (16, 34), (300, 22), palette.BLOOD,
|
||||||
|
cur=30, maxv=100)
|
||||||
|
|
||||||
|
# --- Toast (top-right) ---
|
||||||
|
ui.Toast(ch, "+1 Story Point", color=palette.GOLD)
|
||||||
|
|
||||||
|
# --- TitleBanner (top center) ---
|
||||||
|
ui.TitleBanner(ch, "THE GEARWOOD", cx=512, cy=70)
|
||||||
|
|
||||||
|
# --- DialogueBox: a real NARRATOR node from 'intro', mid-typewriter ---
|
||||||
|
box = ui.DialogueBox(ch)
|
||||||
|
intro_n1 = script_act1.SCENES["intro"]["nodes"]["n1"]
|
||||||
|
box.show_node("NARRATOR", intro_n1["text"], on_advance=lambda: None)
|
||||||
|
|
||||||
|
# let the entrance settle and reveal part of the body
|
||||||
|
step_n(16)
|
||||||
|
ok1 = mcrfpy.automation.screenshot(os.path.join(SHOTS, "uikit_1.png"))
|
||||||
|
assert ok1, "screenshot 1 failed"
|
||||||
|
partial = box.body.text
|
||||||
|
assert 0 < len(partial) < len(box._full), \
|
||||||
|
"typewriter should be mid-reveal (%d/%d)" % (len(partial), len(box._full))
|
||||||
|
|
||||||
|
# --- DialogueBox choice mode: a real node with a disabled [ALCHEMY] choice ---
|
||||||
|
disp = script_act1.SCENES["shop_dispute"]["nodes"]["n3"]
|
||||||
|
# empty active party -> the [ALCHEMY] tag choice is locked (disabled), the
|
||||||
|
# two plain choices are enabled. This is the design pillar: shown but DIM.
|
||||||
|
choices = []
|
||||||
|
for c in disp["choices"]:
|
||||||
|
enabled = c.get("req") is None
|
||||||
|
choices.append((c["label"], enabled))
|
||||||
|
box.show_node("VERA", disp["text"], choices=choices,
|
||||||
|
on_choice=lambda i: None)
|
||||||
|
|
||||||
|
# fully reveal the prompt so the choice list appears
|
||||||
|
step_n(180)
|
||||||
|
disabled_count = sum(1 for _lbl, en in choices if not en)
|
||||||
|
assert disabled_count == 1, "expected exactly one disabled choice"
|
||||||
|
assert len(box.choice_caps) == len(choices), "all choices should render"
|
||||||
|
ok2 = mcrfpy.automation.screenshot(os.path.join(SHOTS, "uikit_2.png"))
|
||||||
|
assert ok2, "screenshot 2 failed"
|
||||||
|
|
||||||
|
box.destroy()
|
||||||
|
menu.destroy()
|
||||||
|
|
||||||
|
print("uikit_1.png:", os.path.join(SHOTS, "uikit_1.png"), flush=True)
|
||||||
|
print("uikit_2.png:", os.path.join(SHOTS, "uikit_2.png"), flush=True)
|
||||||
|
print("PASS test_ui_kit", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue