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