McRogueFace/games/unwritten/systems/overworld.py
John McCardle 80908f771c UNWRITTEN: complete short-form RPG at games/unwritten/
A 30-45 minute turn-based RPG built overnight as an engine showcase:
six recruitable characters (party of three), four recurring NPCs,
choice-driven dialogue with visible-but-locked skill checks, shops,
loot, levels, two bosses with in-battle Talk options, the reverted
grey-town beat, and an epilogue assembled from the player's actual
choices. 33 dialogue scenes / 171 nodes. Terrain is ColorLayers only;
kenney_tinydungeon supplies characters, items, and props.

Creative direction, story, dialogue, maps, and systems design by
Fable; implementation by four Opus subagents (framework, battle,
overworld, integration). Verified by tests/playthrough.py: a scripted
headless run of all three acts, 20/20 beats passing, plus per-system
unit tests (script integrity, UI kit, 225-battle balance sim,
overworld).

Run: cd build && ./mcrogueface --exec ../games/unwritten/main.py

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vxWJ6GY384SmZiMVXgrF4
2026-07-03 10:11:12 -04:00

816 lines
30 KiB
Python

"""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()