Menus, HUDs and Text¶
This page describes part of the acron Python framework that comes with
the acron wheel. It has no C++ counterpart; a C++ game uses RmlUi
directly, as described in RmlUi from C++.
A game’s interface usually has two halves. The half made of words – menus,
settings screens, panels, lists – is an RmlUi document, described in
RmlUi. The half that is drawn – a minimap, a compass tape, a dial –
is pixels on a card in aspect2d. The acron framework has a module
for each half and one that keeps them lined up:
|
the configuration a window needs before |
|
an RmlUi layer for one window, and a keyboard-driven menu on it |
|
the drawn half: a pixel frame on |
|
one set of panel numbers that both halves read |
|
a string table with locales, for every word the interface shows |
The modules supply the mechanism; the documents, style sheets, colours and button ids are the game’s.
Before the window opens¶
Some settings only take effect if they are loaded before ShowBase
creates the window. acron.window.apply_prc() loads them in one call:
from acron import window
from direct.showbase.ShowBase import ShowBase
window.apply_prc("My Game") # before ShowBase
base = ShowBase()
window.fit_window(base) # after: real pixels on a HiDPI display
apply_prc(title, *, stencil=True, msaa=False, dpi_aware=True, npot=True,
absolute_img_src=False, name="acron-window") sets:
the window title;
a stencil buffer (
framebuffer-stencil true), which RmlUi needs to clip its elements;no multisampling, unless
msaa=True;textures-power-2 none, so textures keep their size;dpi-aware true, so a HiDPI (Retina) window is backed by device pixels rather than a stretched low-resolution buffer;with
absolute_img_src=True, the filesystem root on the model path, so an RmlUi<img src="/absolute/path.png">is found.
A game that writes the rest of its configuration itself still needs the
stencil line: acron.ui.apply_prc() loads just that.
On a dpi-aware window the requested win-size is in device pixels, so
on a 2x display the window would open at half its intended size.
fit_window() re-requests it at its size times the display
zoom; it does nothing for an offscreen buffer or a 1x display.
acron.window.blocking_frame(base, *hidden)
renders one frame immediately, from inside a long call such as a bake, with
the given NodePaths hidden for that frame. It is how a loading bar advanced
from a progress callback gets on screen; hide render while the scene is
half-built.
An RmlUi layer¶
acron.ui.RmlUI creates an RmlRegion on a window, its
context, and (unless interactive=False) an RmlInputHandler
under the mouse watcher. Documents are authored in a fixed pixel space,
authored_height pixels tall (1080 by default), and the context’s dp ratio
maps that onto the window; it is retargeted when the window is resized.
from acron.ui import RmlUI
ui = RmlUI(base, name="menu")
ui.load_fonts("fonts/Inter-Regular.ttf")
# A data model must exist before a document that names it is loaded.
ui.make_model("menu", start=lambda *args: ui.defer(start_game),
quit=ui.defer_exit)
doc = ui.load_document("ui/menu.rml")
doc.show()
The keyword arguments are name="ui", authored_height=1080.0,
watch_resize=True, scale=1.0 and interactive=True:
scalemultiplies the dp ratio – a HUD-size setting.set_scale()changes it while the game runs. A game that scales its HUD but not its menus gives each its ownRmlUI.interactive=Falsecreates no input handler, for a context whose documents take no input, such as a HUD.watch_resize=Falseis for a region that does not cover the whole window (a letterboxed view): callset_window_height()with the region’s pixel height yourself.Context names are global to the process. A name that was used before gets a number appended (
menu2,menu3);nameis the name the context got.
The other methods:
|
load font faces into this context (fonts are per context) |
|
create a data model with its event callbacks bound |
|
load a document (hidden until |
|
call |
|
attach or detach the input handler; two interactive contexts on one window both receive every click, so the one underneath detaches |
|
the draw order among the window’s display regions |
|
run |
|
show or hide the system cursor |
|
unload every document, remove the input handler and the region |
Do not change the scene graph or raise SystemExit inside an RmlUi
event callback. Changing the documents themselves (visibility, properties,
text) is safe there; anything else goes through defer, and quitting
through defer_exit.
acron.ui.event_string_arg() and acron.ui.event_number_arg() pick
the string or numeric argument out of a data-model event callback’s
*args. acron.ui.ChangedWrites applies a value only when it
differs from the last one applied under the same key – RmlUi lays the
document out again on every property set, so a HUD that writes the same text
every frame should go through it:
from acron.ui import ChangedWrites
writes = ChangedWrites()
ammo = doc.get_element_by_id("ammo")
def update_hud(task):
writes.set("ammo", str(player.ammo), ammo.set_inner_rml)
return task.cont
acron.ui.escape_rml() escapes text that comes from data before it goes
into markup.
A menu with screens¶
acron.ui.StateMenu is one menu document with several screens. Each
state names its buttons, in navigation order; a button is an element with the
id btn_<name>. show() displays one state’s
buttons and hides the rest, and while the menu is open the arrow keys move the
selection (the selected button gets the class selected) and Enter
activates it. The mouse keeps working through RmlUi.
from acron.ui import StateMenu
class PauseMenu(StateMenu):
def on_activate(self, bid):
if bid == "resume":
self.hide()
elif bid == "options":
self.show("options")
elif bid == "quit":
self.ui.defer_exit()
menu = PauseMenu(ui, doc, {
"main": ["resume", "options", "quit"],
"options": ["audio", "video", "back"],
}, pause=game.set_paused)
menu.show("main")
on_activate() must be overridden;
on_show() is called with the state on every
show(), for titles and labels. pause is called with True when the
menu shows and False when it hides. nav_events (default
("arrow_up", "arrow_down")) and activate_event (default "enter")
name the events it listens to.
The drawn half¶
acron.hud draws what RCSS cannot: instruments that change every frame,
rotate, or are made of lines. Everything works in authored pixels, y
growing down, the coordinates the design was drawn in.
A pixel frame¶
acron.hud.PixelFrame(base, *, authored_height=1080.0, scale=1.0) is a pixel space on aspect2d, anchored to window
corners, so a panel pinned to the bottom right keeps its margin when the
window changes shape. The anchors are tl, tr, bl, br,
tc, bc, lc, rc and c (the centre).
from acron.hud import PixelFrame
hud = PixelFrame(base)
score = hud.label("0", 40, 60, "tr", size=32, align="right")
score.node().set_text("1200")
place(np, x, y, corner) puts a node at a pixel position, card(width,
height, x, y, corner) makes a card with its top-left corner there, and
label(text, x, y, corner, size=12.0, align="left", color=..., font=None)
makes a text node whose y is the text’s baseline. set_scale resizes
the whole frame (the HUD-size setting); hide, show and destroy act
on every anchor.
Painting¶
acron.hud.Painter is the drawing interface an instrument is written
against: clear, fill_rect, stroke_rect, line, polyline,
lines (many segments of one colour in a single call), polygon,
disc, arc, text and blit. Colours are (r, g, b) or (r,
g, b, a) from 0 to 1; angles are in radians, measured from +x towards +y,
which is down.
Two classes implement it:
acron.hud.Raster(width, height)draws into an RGBA pixel buffer with light anti-aliasing and shows it on a card.attach(frame, x, y, corner)mounts it on aPixelFrame; nothing reaches the GPU untilupload(), which does nothing when nothing was drawn. Text goes through aLabelPoolof text nodes in the raster’s coordinates: callbegin_text()before a frame’s text andend_text()after it.pixels()returns the image as floats, for a test.Rasterneeds numpy, which the web runtime does not have; the rest of the module works without it.acron.hud.RecordPainterwrites the calls down instead (ops, and filtered views such asstrokes,textsanddiscs), so the layout of an instrument can be tested with no window and no GPU.
import math
from acron.hud import Raster, relative_bearing, tape_offset
tape = Raster(400, 40)
tape.attach(hud, -200, 20, "tc")
def draw_compass(heading):
tape.clear((0, 0, 0, 0.4))
tape.begin_text()
for bearing in range(0, 360, 15):
x = tape_offset(bearing, heading, span=90, width=400)
if x is None:
continue
tall = bearing % 45 == 0
tape.line(200 + x, 0, 200 + x, 14 if tall else 7, (1, 1, 1))
if tall:
tape.text(str(bearing), 200 + x, 34, size=12, align="center")
tape.end_text()
tape.upload()
~acron.hud.relative_bearing(bearing, heading) is bearing - heading wrapped into the range
-180 to 180 degrees; ~acron.hud.tape_offset(bearing, heading, span,
width) is where a bearing falls on a tape showing
span degrees across width pixels, measured from the centre, or
None when it is off the tape.
A map view¶
acron.hud.MapView(parent, width, height) shows a
baked top-down map texture on a card and windows it with a texture transform,
so panning, zooming and turning the map cost nothing per frame.
set_map(tex, cx, cz, half) binds the texture and the square of world it
covers (centre (cx, cz), half units to each edge, north at the top).
focus(x, z, span, heading=0.0) shows span units of world centred on
(x, z); passing the player’s heading makes it a heading-up radar.
world_to_local(x, z) gives the card pixel of a world position, for
markers drawn on a Raster over the map.
The map frame is a Y-up ground plane: world (x, z), north towards -z.
A Z-up game, whose ground plane is x/y with north towards +y,
passes z = -y. node= shows the map on a node of your own (a radar
disc) instead of a generated card; its UVs must span 0 to 1.
Shaped chips¶
RCSS cannot clip an element to a shape, and RmlUi loads images from files.
acron.hud.hex_chip_png(cache_dir, name, rgba, px=64) writes a hexagonal chip of a colour (rgba in
0-255) to a PNG in cache_dir the first time it is asked for, and returns
its absolute path for an <img src>. The colour is part of the file name,
so a changed palette never shows old pixels.
Lining the halves up¶
acron.layout.Box(corner, x, y, w, h) is one panel
in authored pixels, y down, measured from a PixelFrame anchor (a
bottom-anchored panel has a negative y). Both halves read the same box:
rml() gives the absolute-position properties for an RmlUi element and
pixel_xy() the position for a card. One authored pixel is one dp in
an RmlUI with the same authored_height, so a HUD-size setting
scales both together.
from acron.layout import Box
RADAR = Box("br", 24, -224, 200, 200)
panel = doc.get_element_by_id("radar-frame")
for prop, value in RADAR.rml().items():
panel.set_property(prop, value)
radar = Raster(200, 200)
radar.attach(hud, *RADAR.pixel_xy(), RADAR.corner)
Strings and locales¶
acron.strings.StringTable(reference, *, markup=(), locale_dir=None) holds every string the interface shows, by key.
The reference table is in the language the game was written in; a locale
file, <lang>.json in locale_dir, is merged over it, and a key the
locale lacks falls back to the reference.
from acron.strings import StringTable
strings = StringTable({
"menu.start": "Start",
"hud.ammo": "Ammo: {count}",
"help.keys": "Press <b>Space</b> to jump",
}, markup={"help.keys"}, locale_dir="locales")
strings.load_locale("de")
strings.tr("hud.ammo", count=12)
strings.fill(doc, {"start": "menu.start", "help": "help.keys"})
tr(key, **fields)is the string in the active table with its fields filled in.rml(key, **fields)is the same, escaped for RmlUi unless the key is inmarkup.fill(doc, {element_id: key})writes strings into a document’s elements; an element the document does not have is skipped.load_locale(lang)merges a locale over the reference (Noneor an unknown language goes back to the reference) and returns the locale’s whole dictionary;reset()goes back to the reference;localeis the language loaded;available()lists the languages inlocale_dir.
The module-level functions work on the files directly:
~acron.strings.read_locale(path) reads one
locale file, keeping only its string entries (None if it is missing or is
not a JSON object), ~acron.strings.available(directory) lists the languages a directory holds, and
~acron.strings.check(reference, locale) returns
the keys a locale is missing and the keys it has that the reference no longer
does – what a translator needs to be told.