-
Notifications
You must be signed in to change notification settings - Fork 0
UI Components
All three widgets draw into the game's viewport, each into its own named group, so they compose over a renderer that clears the whole surface every frame. None of them handle input — you drive them from your own key handling, which is what lets you use the same code for keyboard and controller.
from texastoast.ui import DialogueBox, Menu, HUDSince 0.4.0 the widgets take the renderer and inherit its dimensions:
renderer = CanvasRenderer(game.canvas, 400, 300)
dialogue = DialogueBox(renderer)
menu = Menu(renderer)
hud = HUD(renderer)The pre-0.4 form — a bare canvas plus explicit width and height — still works unchanged:
dialogue = DialogueBox(game.canvas, 400, 300)Passing the renderer is preferred because the size is then stated once. When a widget and the renderer disagree about the viewport, the widget draws its box against the wrong edges.
This is the contract, and it changed in 0.3.0. Call render() on all three
from your render(), and update(dt) on the dialogue from your update():
def update(dt):
dialogue.update(dt) # advances the typewriter
...
def render():
renderer.clear()
... # world
hud.render() # then overlays, in the order you want them stacked
dialogue.render()
menu.render()render() is safe to call every frame whether or not the widget is showing — it
is a no-op when inactive, so there is no need to branch.
Before 0.3.0 DialogueBox drew once from show() and drove its typewriter from
its own canvas.after() timer. Any renderer that cleared the canvas wiped the
box off screen while it still reported active, so input stayed captured and
the game looked frozen behind an invisible modal. Two consequences of the fix
are worth knowing: the typewriter advances by dt rather than wall clock, and
it no longer runs while the game loop is stopped.
A bottom-of-screen text box with typewriter output and an optional speaker name.
dialogue = DialogueBox(renderer, box_height=100, speed=0.03)
dialogue.show("Welcome, traveler!", speaker="Old Wizard",
on_complete=lambda: print("done"))
dialogue.update(dt) # from update() — advances the typewriter
dialogue.render() # from render() — safe when inactive
dialogue.active # a box is on screen
dialogue.waiting # all text is revealed, waiting for dismissal
dialogue.displayed # the text revealed so farspeed is seconds per character; speed=0 reveals everything at once.
dismiss() does the two-stage thing players expect:
- while text is still typing, it completes the text immediately
- once fully revealed, it closes the box and fires
on_complete
So a single key can drive the whole interaction:
def on_key(event):
if dialogue.active and event.keysym in ("z", "Return", "space"):
dialogue.dismiss()
return
...Typing is driven by your game loop, through update(dt) — so a paused loop is a
paused typewriter. If you want dialogue to keep animating while the rest of the
game is frozen, keep calling dialogue.update(dt) and skip the rest of your
update instead:
def update(dt):
dialogue.update(dt) # always
if dialogue.active or paused:
return # world is frozen, dialogue is not
...That is the pattern examples/game_template.py uses.
A centered list with keyboard-driven selection.
menu = Menu(renderer)
menu.show(
["Resume", "Settings", "Quit"],
on_select=lambda index, label: handle(label),
on_cancel=lambda: menu.hide(),
title="PAUSED",
selected=0,
)
menu.move_up()
menu.move_down()
menu.confirm() # hides, then calls on_select
menu.cancel() # hides, then calls on_cancel
menu.hide() # hides, calls nothing
menu.render() # from render() — safe when inactive
menu.active
menu.selected_index
Menu.render()was calledMenu._draw()before 0.3.0, and the menu drew itself fromshow(). It is now yours to call every frame.
Items can be greyed out, and the selection skips them:
menu.set_enabled(1, False) # "Settings" is now unselectableTwo behaviors to be aware of:
-
show([])does nothing. An empty item list returns early and leaves the menu inactive, so check before calling if the list is built dynamically. -
confirm()hides the menu before callingon_select. If your handler tracks apausedflag, remember to clear it — otherwise an unhandled option leaves the game paused with no menu on screen. Handle every label, including the ones that do nothing yet.
def on_select(index, label):
global paused
paused = False # always clear it
if label == "Quit":
game.quit()Screen-space stat bars and text that ignore the camera.
hud = HUD(renderer)
hud.add_stat("hp", "HP", value=100, max_value=100, color="#e94560")
hud.set_stat("hp", 75) # clamped to 0..max_value
hud.add_text("score", "Score: 0", x=300, y=8, fill="#fdd835")
hud.set_text("score", "Score: 120")
hud.remove_text("score")
hud.clear() # drop every stat and text
hud.render() # draw — call once per frameStats are laid out top-left in insertion order. Extra keyword arguments to
add_text go to tkinter's create_text.
set_stat and set_text silently do nothing if the key does not exist, so
a typo shows up as a value that never changes rather than an error. Register
with add_* first.
For text that appears and disappears — a "Press Z to talk" hint — add_text and
remove_text are the pair to use; calling add_text every frame works but
rebuilds the entry each time.
Each widget owns one named group — "dialogue", "menu", "hud" — and clears
only that group when it redraws. This is what lets three widgets share a surface
that the renderer wipes every frame without any of them erasing the others.
Within a frame, draw order is z-order: later calls paint on top. That is the
whole layering model, which is why the call order in your render() decides
what a dialogue box covers.
The surface itself is the UISurface protocol
(Rendering and Camera), so a widget
does not know it is drawing on tkinter. A practical benefit: you can test widget
logic with no display at all by passing any object with the protocol's methods.
class FakeSurface:
width, height = 640, 480
def __init__(self):
self.groups = {}
def begin_group(self, group):
self.groups[group] = []
def clear_group(self, group):
self.groups.pop(group, None)
def ui_rect(self, x, y, w, h, *, fill, outline="", outline_width=0, group=""):
self.groups.setdefault(group, []).append(("rect", fill))
def ui_text(self, x, y, text, *, fill, font=None, anchor="nw",
width=None, group=""):
self.groups.setdefault(group, []).append(("text", text))
surface = FakeSurface()
menu = Menu(surface)
menu.show(["Resume", "Quit"])
menu.render()
assert ("text", "> Resume") in surface.groups["menu"]That is exactly how tests/test_ui_surface.py covers these widgets, which is
why most of the UI suite no longer needs a display.
texastoast · PyPI · Apache-2.0