McRogueFace/games/unwritten/core/ui.py

582 lines
21 KiB
Python
Raw Normal View History

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