GG-UI — Widget Assets and the Designer
← GG-UI Design | UI Core | Styling | Interaction | Home
Game UI made as assets: small widgets combined into larger ones and then into a HUD, bound to game state by the assets themselves, styled at any level, animated, and edited in a designer. Code can still build any of it, and uses the same widgets.
Status: steps 1 to 7 are built, and the first part of step 8; the rest is designed. See StepsSteps.
The Model
| Piece | What it is |
|---|---|
| Widget asset | A .tnxwidget file: a tree of elements, the inputs it takes, the slots it leaves, the contexts it reads, its styles and animations |
| Element | A built-in element (Row, Label, Progress, Button, ...), a registered code widget, or a use of another widget asset |
| Input | A value a widget asset takes from whoever uses it: a number, text, a colour, an image |
| Slot | A named place in a widget asset that whoever uses it fills with elements |
| Context | A typed source of game state the asset reads: an entity type, a component, or a view model |
| HUD | The root of what one local player sees: a widget asset of kind hud, with regions things are added to |
| Region | A named container in a HUD that widgets are added to while the game runs |
A leaf widget knows nothing about the game: its parts bind to its inputs. A composed widget, usually the HUD, names the contexts it reads and wires its children's inputs to their fields. Game code's whole part is to supply the contexts when it sets the HUD.
{ "widget": "StatBar", "version": 1, "inputs": { "Value": "number", "Max": { "type": "number", "default": 1 }, "Label": "text", "Fill": "color" }, "tokens": { "bar-fill": "=Fill" }, "root": { "name": "Bar", "type": "Column", "gap": 2, "width": "grow", "children": [ { "name": "Caption", "type": "Label", "text": "=Label" }, { "name": "Meter", "type": "Progress", "width": "grow", "height": 8, "value": "=Value / Max", "style": { "accent": "@bar-fill" } } ] } }
{ "widget": "PlayerHud", "class": "PlayerHud", "kind": "hud", "version": 1, "root": { "name": "Health", "type": "Place", "anchor": "BottomLeft", "x": 24, "y": -24, "children": [ { "name": "HealthBar", "use": "StatBar", "Value": "=Player.CHealth.Value", "Max": "=Player.CHealth.Max", "Label": "Game.Hud.Health", "Fill": "@health" } ] } }
// The code behind it: what it reads, is given, and calls (Widget Classes, below). class PlayerHud : public TnxWidget<PlayerHud> { public: PlayerHud(TnxContextOf<EPlayer<>> player, TnxContextOf<MatchInfo> match) : Player(player), Match(match) {} TnxContextOf<EPlayer<>> Player; TnxContextOf<MatchInfo> Match; TNX_WIDGET(PlayerHud, Player, Match) }; TNX_REGISTER_WIDGET(PlayerHud) // The game, once the local player's body exists: Hud = TnxUI::SetHud<PlayerHud>(world, localSoul, Player->Body, MatchState);
Nothing runs per frame after that. Bindings read the published frame as all UI does: a changed value patches its primitive, and the HUD is right across rollback, time travel, and play in editor without anything of its own.
Game State in Code
What assets build on, and what hand-written UI uses directly.
// Reading: a field of an entity, or of the entity a ConstructView shows, from the frame on screen. ui.Label(TnxBind(hud.Wallet, &CWallet<>::Gold), { .Notation = NumberNotation::Suffix, .Decimals = 2 }); ui.Progress({}, TnxBind(Player->Body, &CHealth<>::Value) / TnxBind(Player->Body, &CHealth<>::Max)); // Acting: a command, sent from any thread, into the local player's input for the next frame. struct BuyUpgrade { uint8_t Tier = 0; TNX_COMMAND(BuyUpgrade, "Game.BuyUpgrade", Tier) }; TNX_REGISTER_COMMAND(BuyUpgrade) // so assets can send it by name TnxSim::Send(world, BuyUpgrade{ .Tier = 2 }); class Shop : public Construct<Shop> { public: using Commands = TnxHandles<BuyUpgrade>; void OnCommand(const BuyUpgrade& buy, uint8_t ownerID); // on Brain, before PrePhysics };
TnxBindnames the field by member pointer, so a typo doesn't compile; a member that isn't in its component's field list is reported with the line. Bindings of one entity combine with arithmetic and comparisons; combining two entities is reported, since a binding reads one.- A command is a small trivially copyable struct (no pointers or strings) with a name and its fields listed.
TNX_REGISTER_COMMANDrecords its name and layout, so an asset's{ "command": "Game.BuyUpgrade", "Tier": 2 }is checked when it is built (a misspelt command or field is a problem naming the line) and is sent with its fields filled by name. Each player has 63 bytes of commands a frame, a command taking its size plus five; what doesn't fit goes in the next frame, in order. - Where it goes: into the player's input for the next frame the simulation runs. It is recorded in the frame's rollback record and replayed on resimulation, carried in the input window to the Authority, and kept in its input log. A frame the Authority has to predict has no commands. A paused or stalled frame doesn't take them, so none are lost.
- Who gets it: each Construct listing its type, in the order sent, with the sending player's
ownerID. The handlers are registered at compile time like ticks, and a listed command without anOnCommanddoesn't compile. The Authority's run is the one that counts; an Owner's is its prediction. A handler checks what it is asked rather than trusting it.
Widget Assets
Data, built at runtime. A widget asset is JSON (the engine's format during R&D). Loading compiles it into a flat list of elements with their bindings, strings, and references resolved; building walks that list and calls the same ui.* functions code does. An asset costs what the same UI written in C++ costs.
- Each element has a
name, unique in its asset: the designer gives one (Label,Label2) and you rename it. Names, like every other name in a file (inputs, contexts, regions, tokens, classes, clips, commands), areTnxNames once loaded, so identity is an integer and the text stays readable. The name keys the element (so state, focus, and transitions stay with it), names it in animation tracks and messages, and lets a hot reload keep the tree. A dotted name reaches into an asset used inside:PlayerHud.HealthBar.Meter. - Each use of a widget asset is a component, with its inputs as its properties. A change to one instance's inputs re-runs that instance; the rest of the HUD is skipped.
- Hot reload: saving an asset recompiles it and re-runs the documents that use it, in the editor and during play. A file that fails to load is reported with its line and the old version stays.
- References to other assets (widget assets, images, sheets) can be by asset ID with the name beside it, as scene and prefab files write them:
"use": { "guid": "3f2a...", "name": "StatBar" }. The ID finds the asset under its current name; the name is for reading and for an ID the engine doesn't know. A rename while running is followed: assets referring to the renamed one by ID are read again. A plain name works everywhere too.
The File
Written mostly by the designer, read in diffs, and fixed by hand now and then.
| Rule | |
|---|---|
| Any order | Keys and sections may come in any order, and a key added anywhere is read. The order of children is their layout order |
| Values | Plain text is text ("Game.Hud.Health" is a string ID). "=..." is a formula: "=Value / Max", "=Player.CHealth.Value". "@name" is a token. "'=..." is text that starts with = |
| Formulas | Names are inputs or context fields; numbers; + - * / < > <= >= == !=, &&, \|\|, !; min, max, clamp, lerp, select. They compile to GGExpr, so a formula runs wherever a binding does, on the GPU too. A formula reads from one entity or context, plus constants; a list's where and sort may read the item and other contexts together (Item.CUpgrade.Cost <= Player.CWallet.Gold), worked out on the CPU when the list refreshes |
| Sizes | A number (UI units), grow, grow 2 (a weight), fit, 50% |
| Inputs | "Value": "number"; { "type": ..., "default": ... } when there is a default |
| A use | An asset's inputs are set as its properties: { "use": "StatBar", "Value": "=..." }. Inputs are PascalCase and built-in properties lowerCamel, so they never collide. Where it is used it takes width, height, layer, visible, and opacity, and its root fills that size; what lays out and styles its content (gap, padding, class, ...) is the asset's, and setting it on a use is reported |
| Notes | "note" on any element, shown in the designer; // comments are read too |
version | Lets the loader read older files as the format grows |
| Old names | Renamed types and keys still load, each reported with its new name: Switch is Switcher, Selector is Carousel, layer is zOrder, an image's region is crop, shrink is minScale, and a Spacer's weight is its grow size. The designer writes the new names. colour, greyscale, and centre are read as color, grayscale, and center, which are written |
Forgiving to read. The loader also takes trailing commas, // and /* */ comments, keys without quotes, and single words without quotes (type: Progress, width: grow). Saved files are plain JSON, with the comments that were written in them.
Errors say what to change, with the line: StatBar.tnxwidget line 7: there's no comma before "width", line 12: this ] closes a list, but the { from line 9 is still open: close it with } first.
Laid out as an outline when the designer saves: one element per line, children its last key, each child a tab deeper than its parent, closing brackets at the end of the last child's line. An element the designer didn't change keeps its keys in the order they were written, so a save shows only what changed. Hand-written files can be laid out any way.
Elements
| Kind | Elements |
|---|---|
| Layout | Row, Column, Stack, Grid, Place (anchored), Spacer, Scroll, SafeArea, ScaleBox |
| Choosing | Switcher (one child by a bound value), Repeat (one item a bound number of times) |
| Leaves | Label, RichText, ActionPrompt, Image, Swatch, Shape, Progress, Viewport |
| Controls | Button, Toggle, Slider, Number, TextField, Dropdown, Carousel, Tabs |
| Repeated | List and Tiles over a data source, with an item widget asset |
| Use | "use": "<widget asset>" with inputs and slot contents |
| Code widget | A widget written in C++ and registered with its properties, shown in the palette like the rest |
Properties are the elements' existing props (BoxProps, TextProps, ...), reflected so the designer can list and validate them (The Widget RegistryThe Widget Registry). A property that changes layout (size, padding, text) may be bound in a screen document; in a world-UI template only paint properties may be (Core).
Controls take content. A Button is a container that is clickable and has the button's states: its children are its content (an icon, a label, a badge), laid out like a Row's. "text" alone is the plain labelled button. Toggles and tab headers work the same way.
{ "widget": "IconButton", "version": 1, "inputs": { "Icon": "image", "Label": "text" }, "root": { "name": "Button", "type": "Button", "class": "icon-button", "children": [ { "name": "Icon", "use": "SpinningIcon", "Icon": "=Icon" }, { "name": "Caption", "type": "Label", "text": "=Label" } ] }, "triggers": [ { "when": { "hover": "Button" }, "play": "Wiggle" } ] }
What a button does is set where it is used ("on": { "click": ... } on the use), so one asset serves every place.
{ "name": "Lives", "type": "Repeat", "count": "=Player.CHealth.Value / 20", "max": 10, "item": { "name": "Heart", "type": "Image", "image": "ui/heart.png" } }, { "name": "Held", "type": "Switcher", "value": "=Player.CWeapon.Kind", "children": [ { "name": "Sword", "use": "SwordIcon" }, { "name": "Bow", "use": "BowIcon" } ] }, { "name": "Quality", "type": "Carousel", "options": [ "Game.Low", "Game.Medium", "Game.High" ], "value": "=Settings.Quality", "on": { "change": { "command": "Game.SetQuality" } } }, { "name": "Buy", "type": "Button", "tooltip": "ItemTooltip", "tooltipInputs": { "Item": "=Item" }, "children": [ ... ] }
- Switcher and Repeat build every case and item once and choose with a binding: the ones not shown are out of layout and drawing (
Present, in codeui.Present;Visiblehides but keeps the place), so a changing value runs nothing. A repeat's item reads its number as=Index;maxis how many are built. on.changeis a value control's event (Toggle,Carousel): each of its actions is given the new value as the fieldValue, unless it sets one.tooltipnames a widget asset shown as the element's tooltip, withtooltipInputs.SafeAreakeeps its content inside the platform's safe area (a notch, rounded corners, overscan).
{ "name": "Health", "type": "Progress", "value": "=Player.CHealth.Value / Player.CHealth.Max", "direction": "left", "trailing": { "delay": 0.4, "time": "=Player.CHealth.RegenDelay" } }, { "name": "Ammo", "type": "Progress", "value": "=Gun.CAmmo.Loaded / Gun.CAmmo.Size", "segments": "=Gun.CAmmo.Size" }, { "name": "Cooldown", "type": "Progress", "class": "ring", "value": "=Ability.CCooldown.Left", "radial": { "start": 0, "sweep": 360 } }, { "name": "Frame", "type": "Image", "image": "ui/panel.png", "slice": 12, "width": "grow", "height": 120 }, { "name": "Portrait", "type": "Image", "image": "ui/faces.png", "crop": [ 64, 0, 64, 64 ], "grayscale": "=Locked" }, { "name": "Quest", "type": "Label", "text": "=Title", "wrap": true, "maxLines": 2, "ellipsis": true }, { "name": "Name", "type": "Label", "text": "=Name", "minScale": 0.7 }, { "name": "Timer", "type": "Shape", "shape": "arc", "class": "ring", "sweep": "=Ability.CCooldown.Left * 360" }
- Progress fills toward
direction, insegments(pips), or round a ring (radial, its thickness the style'sborder-width), with afillImagecut to the value. Atrailingfill follows the value down afterdelayseconds, overtimeseconds; both may be bound, and a new drop restarts the wait. It is the style'strailcolour, or the accent at half strength. - Image:
slice(nine-slice borders in the image's pixels),tile,crop(a cell of a sheet, whose size is the element's natural size),flip(x,y,both),grayscale(bindable). - Label:
maxLinesandellipsiscut text to fit, keeping the whole text for screen readers;minScaledraws a line too wide for its place smaller, down to that fraction, before cutting it. Text over the scene takestext-outlineandtext-shadowfrom its style. - Shape:
lineandpolygon(points),circle,rect, andarc(start,sweepin degrees from the top), filled or with astroke. An arc whose sweep is bound follows it with no rebuild.
What the Elements Do
The elements a HUD is made of, and what each needs beyond what code-built UI has today:
| Element | Does |
|---|---|
Progress | A fill by a bound value: left, right, up, or down; radial (start angle and sweep, for cooldowns and stamina wheels); segments (a bound count of pips or notches); a fill image or sprite masked by the value; a trailing fill that follows the value down after a delay (health lost); a tween on the value |
Image | An image, atlas region, or render target: fit modes, tint, corner radius, slice (nine-slice borders that don't stretch), tile, a bindable crop (portraits, sheet cells), flipping, the flipbook frame, and grayscale (a locked item) |
Label | Text from a string ID, data, or a bound number: wrap, maxLines, ellipsis, minScale (scaled down to fit, to a minimum size, for long translations), and text outline and shadow styles for text over the scene |
RichText | Styled parts, inline images, and action prompts in one paragraph, from markup in the string (Rich TextRich Text) |
ActionPrompt | An input action's key, or its gamepad button while a gamepad is in use; it follows a rebind and the device. RichText embeds it inline |
Shape | A line, arc, circle, polygon, or rounded rectangle from data, with bindable parameters: an arc whose sweep is a cooldown is a radial timer. Code still uses ui.Canvas |
Viewport | A render target laid out like an image: a minimap, a character preview, an item inspected |
Switcher | Shows one child, chosen by a bound value (which weapon icon, which state's panel), with an optional clip between them |
Repeat | One item a bound number of times: hearts, ammo pips, stars |
SafeArea | Keeps its content inside the platform's safe area, at any aspect |
ScaleBox | Draws its content at its own layout, scaled to fit the box and centred: "fit" is contain (the default), width, or height. Nothing is laid out again when either size changes |
Carousel | One of a list of options, changed with left and right ("< Medium >"): the gamepad form of a dropdown |
List, Tiles | Items from a data table, a game's source, or every entity of a type, each built from a widget asset with the item as its context; filtered, sorted, selected, and activated (ListsLists) |
Tabs | Headers take content; switched with the action bound to next and previous tab (shoulder buttons by default) |
Any element can name a widget asset as its tooltip. The show action adds an asset to a region or layer of the HUD (Menu, Modal), or as a popup beside an element.
The Widget Registry
Every element type is in one registry (GGWidgetRegistry, UIWidgetRegistry.h): the built-in ones and the code widgets a game adds. The registry holds each type's properties, with each property's file key, kind, default, and Details group. The loader checks files against it, and the designer builds its palette and Details from it.
- Every element takes
name,note,visible,opacity,zOrder,tooltip,tooltipInputs, andstyle. A leaf is ordered, faded, and given a tooltip just as a box is. - And a transform:
"rotation": 12(degrees, clockwise),"scale": 1.2or[1.2, 1],"skew": 8or[8, 0](degrees: its top leaning right, its right side dropping), and"pivot": [0.5, 1](what it turns, skews, and scales about, as fractions of its size). It is drawn scaled, then skewed, then turned, with its content, and nothing is laid out again; hit testing follows it. A clip's tracks add to it. - A key that does nothing is reported. A key the file knows, set on an element that ignores it (a
gapon aLabel), is a problem that lists the keys that element does take. A key the file doesn't know is reported with the nearest one it does know. - Properties are reflected from the props structs.
GG_(REGISTER_ FIELDS UIReflect.h) lists a struct's fields, asTNX_REGISTER_FIELDSdoes for components. A field's key is its name (bWrapiswrap,MaxLinesismaxLines), its kind comes from its type, and its default comes from a default-made struct. Fields that are not listed, such as handlers, belong to code only.
Code Widgets
A widget written in C++ is a props struct with reflected fields and a function that builds it, registered once in a .cpp:
struct GaugeProps { float Value = 0.0f; GGColor Needle = GGColor::White(); bool bTicks = true; GG_REGISTER_FIELDS(GaugeProps, Value, Needle, bTicks) }; void BuildGauge(UI& ui, const GaugeProps& props) { ... } GG_REGISTER_WIDGET(Gauge, GaugeProps, BuildGauge)
An asset then writes it as any other element, { "name": "Speed", "type": "Gauge", "value": 0.5, "needle": "#FF0000", "ticks": false }. Its fields are read into its props, and a wrong key or value is reported with the field's name. It is built in a box of its own, so it takes everything every element takes. The designer lists it under Code widgets. A field's type needs GGFieldTraits (UIWidgetTraits.h): numbers, bool, std::string, GGSize, GGEdges, GGColor, and the layout enums have them.
Widget Classes
A widget asset is its layout, look, and animation. What it reads, is given, and does is a class's: the code behind it, named in the file ("class": "PlayerHud").
class PlayerHud : public TnxWidget<PlayerHud> { public: explicit PlayerHud(TnxContextOf<EPlayer<>> player) : Player(player) {} TnxContextOf<EPlayer<>> Player; // "=Player.CHealth.Value"; an entity type, or a view model TnxInput<float> Gold; // "=Gold"; float, int32_t, bool, std::string (a string ID), GGColor StatBar Health{ Player }; // { "name": "Health", "use": "StatBar" }: a widget member void OpenMap(); // { "call": "OpenMap" } void Buy(const ShopOffer& offer); // { "call": "Buy" } in a row of a list of offers TNX_WIDGET(PlayerHud, Player, Gold, Health, OpenMap, Buy) }; TNX_REGISTER_WIDGET(PlayerHud) TnxHud hud = TnxUI::SetHud<PlayerHud>(world, soul, Player->Body); // its asset, PlayerHud hud.Widget<PlayerHud>()->Gold = 1200.0f; // shown as it changes
- One list names what an asset sees (
TNX_), asWIDGET TNX_andVIEW_ MODEL TNX_COMMANDdo; what each is comes from its type. An asset naming the class has its contexts and inputs, and checks its calls against it, when it loads: a method it hasn't got is reported with the ones it has. The file declares neither. - A context it can't do without is a constructor parameter, so setting the HUD without it doesn't compile. One it can is a plain member, and an empty one is reported by name when the HUD is set (
TnxUI::).HudProblems - A call runs the method on the UI update, on the instance the asset was shown with (a HUD's). It takes nothing, or the item of the list row it was called from: a view model's record, or an
EntityHandle. To change game state it sends a command (TnxSim::Send); UI never writes state. - An input that is a number is read as it changes, with nothing built again; text and colour build the widget again when set. Set from any thread.
- A widget member is a member that is itself a widget class. It is the element of its name that uses an asset of that class (
{ "name": "Health", "use": "StatBar" }): that element is given what the member has, and its calls run on the member. Its contexts come from its owner's constructor, so a nested widget can't be left without one either, and the file gives it nothing. A use of an asset with a class that isn't a member of its user's class, or is in a widget with no class, is reported with the members there are. - A widget with no class is presentational: it is given inputs (
"inputs"in its file), and a list's row itsItem. Anything that reads game state or calls code has a class. - The designer reads the class and runs it. Inputs and Contexts picks it and shows what it declares, its methods, and its widget members; the actions editor offers its methods. The canvas makes an instance with the preview contexts when its constructor takes only contexts, shows what its code sets under the preview values, and runs its methods when its actions call them, in Interact and on event tracks. A change to the class takes a build and an editor restart until game code reloads (Code Reload).
Inputs
"inputs": { "Value": "number", "Label": "text", "Icon": "image", "Locked": { "type": "bool", "default": false } }
Types: number, bignumber, integer, bool, text (a string ID, or verbatim data), color, image, widget (an asset to use, for a part a user can swap). Whoever uses the asset sets each input to:
| Form | Example |
|---|---|
| A literal | "Label": "Game.Hud.Health", "Max": 100 |
| A context field | "Value": "=Player.CHealth.Value" |
| Its own input | "Value": "=Value" |
| A formula | "Fill": "=Player.CHealth.Value / Player.CHealth.Max" |
| A token | "Fill": "@health" |
A missing input takes its default; an input with no default that isn't set is a validation error.
Slots
{ "widget": "Panel", "version": 1, "inputs": { "Title": "text" }, "root": { "name": "Frame", "type": "Column", "class": "panel", "children": [ { "name": "Title", "type": "Label", "class": "panel-title", "text": "=Title" }, { "name": "Content", "slot": true } ] } }
{ "name": "Shop", "use": "Panel", "Title": "Game.Shop.Title", "slots": { "Content": [ { "name": "Upgrades", "use": "UpgradeList" } ] } }
A slot left empty shows its default children, if the asset gives any.
Lists
{ "name": "Abilities", "type": "List", "source": { "table": "Trainer.Abilities" }, "item": "AbilityRow", "where": "=Item.Level <= Level", "sort": "-=Item.Cost", "selection": "Chosen", "on": { "select": { "call": "Preview" }, "activate": { "command": "Game.LearnAbility", "Ability": "=Item.Id" } }, "slots": { "empty": [ { "name": "None", "type": "Label", "text": "Game.Trainer.NothingToLearn" } ] } }, { "name": "Details", "use": "AbilityDetails", "Ability": "=Chosen" }
sourceis where the items come from:
| Source | Items | Written |
|---|---|---|
| A data table | Its rows: fixed catalogues (a trainer's abilities, a vendor's stock) | { "table": "Trainer.Abilities" } |
| A game's source | What the game adds: entities, and records (view models) | "Shop.Offers" |
| Entities | Every entity of a type in the HUD's world, kept current as they spawn and go | { "entities": "EUpgrade" } |
itemis the widget asset each item is built from. It declares the contextItem(the table, entity type, or view model) and reads it like any context:=Item.Cost,=Item.CUpgrade.Tier.whereshows an item while it is true;sortorders them by a formula, ascending ("-=..."for the highest first). Both are formulas overItemand the asset's inputs and contexts. Values that change are read again a few times a second, and the list reorders only if the order changed.selectionnames a context for the selected item, which the rest of the asset reads (=Chosen.Cost) or passes on (=Chosen). Nothing selected reads as nothing. A selected item that no longer shows is unselected.- Events:
selectwhen the selection becomes an item;activateon a double click, or Enter (a gamepad's accept) on the focused item. Their fields readItem. - Slots:
headerandfooteraround the items,emptyinstead of them when there are none. - Keys: every item has one: a table's
keyfield, a record's first field, an entity's handle. Selection and focus follow the key, so they survive a re-sort, a reload, or a refill, and only an item whose key comes or goes is built or removed. Actions sendItem.Id, never a position, so a command means the same on the Authority whatever order its list is in. - Sizes:
rowHeightmakes every row that high and builds only those in view, for long lists.TilestaketileWidthandtileHeightand are always built that way.
A data table (.tnxtable, listed and created in the Content Browser) is rows of named values, read as constants:
{ "table": "Trainer.Abilities", "key": "Id", "fields": { "Id": "name", "Class": "integer", "Level": "integer", "Cost": "number", "Icon": "image", "Name": "text" }, "rows": [ { "Id": "Fireball", "Class": 1, "Level": 1, "Cost": 10, "Icon": "icons/fireball.png", "Name": "Game.Ability.Fireball" } ] }
Fields are number, integer, bool, text (a string ID), image (a path), name, and color. A name is an identifier: a key, compared and shown as itself, and sent in a command as its TnxName ID, into a uint32_t field. What changes while the game runs (learned, affordable) is a formula over the row and live data: "grayscale": "=Item.Cost > Player.CWallet.Gold".
A game's source is registered in code, per world, and filled again when it changes:
struct ShopOffer { uint32_t Id = 0; float Price = 0.0f; bool bSoldOut = false; TNX_VIEW_MODEL(ShopOffer, "Game.ShopOffer", Id, Price, bSoldOut) }; TNX_REGISTER_VIEW_MODEL(ShopOffer) TnxUI::Source("Shop.Offers", world, [this](TnxItems& items) { for (const ShopOffer& offer : Offers) items.Add(offer); // records for (EntityHandle crate : Crates) items.Add(crate); // or entities }); TnxUI::SourceChanged("Shop.Offers"); // any thread: what it would add is different
The fill runs on the thread that calls Source or SourceChanged. A record is kept as a view model from one fill to the next while its first field is the same, so a changed price repaints in place, with no rebuild. A record's fields are what a view model holds: numbers, flags, enums, and Fixed32; its text comes from a table or a string ID the item asset chooses by a field.
ui.List and ui.Tiles in code take OnSelect and OnActivate the same way.
Rich Text and Prompts
The markup is in the string tables, where translators can move it with the words:
Game.Shop.Deal = Deal <class damage>{amount}</class> <img Coin/> damage Game.Hint.Buy = Press <action Game.Buy/> to buy for <class price>{cost}</class>
{ "name": "Hint", "type": "RichText", "text": "Game.Hint.Buy", "args": { "cost": "=Item.Cost" }, "notation": "suffix" }, { "name": "Prompt", "type": "ActionPrompt", "action": "Game.Buy" }
ui.RichText(GGText("Game.Hint.Buy", { { "cost", TnxBind(item, &CUpgrade::Cost) } })); ui.ActionPrompt(BuyAction);
{ "name": "Help", "type": "RichText", "text": "Game.Help.Intro", "links": { "Wiki": { "call": "OpenWiki" }, "Shop": { "show": "ShopPanel", "in": "Menu" } } }
ui.RichText(GGText("Game.Help.Intro"), { .OnLink = [](const TnxName& link) { ... } });
| Markup | Is |
|---|---|
<class name>...</class> | What it holds, in that style class: colour, font, size, and a background with its padding. Classes nest |
<link Name>...</link> | A link: what it holds, in the link class, styled in its own state (link:hover, link:focus). What it does is the element's links entry for Name, or RichTextProps::OnLink in code |
<img Name/> | An image, by its texture asset's name, as high as the line of text it is in |
<action Name/> | An input action's prompt, as ActionPrompt |
{name} | An argument. Up to four may be bound: a change lays the paragraph out again and re-runs nothing |
<< | A literal <. An argument's own < is never read as markup |
The paragraph is shaped as one: its parts wrap and break into lines together, a part with a bigger font makes its line taller, and right-to-left text keeps its order. wrap, maxLines, ellipsis, and minScale work as they do on a Label. A mistake (a tag it doesn't have, an image or action that doesn't exist, a <class> never closed) is reported once, with the string ID, and the text shows as written. A screen reader is given the plain text with each prompt's key or button, and without the images.
A paragraph with links is clicked where a link is, and takes focus: Left and Right (or the gamepad's directions) move between its links before focus moves on, and Enter or the gamepad's accept activates the one it is on. Its parts' classes, links included, are in the style scopes the paragraph is in.
A prompt shows the action's key on a key-cap. While a gamepad is in use it shows the action's button: the game's picture of it if there is one, a texture asset named Pad.<Family>.<Button> (Pad.Xbox.South, Pad.PlayStation.North, Pad.Nintendo.East, Pad.Generic.Start) in a pad-glyph, else its label on a pad-cap (A, B, X, Y; ✕, ○, □, △; B, A, Y, X; LB, R1, ZL, ...). The device in use is whatever was last pressed: a gamepad button, or a key or the mouse. A prompt follows it and any rebind, with no re-run. An action's gamepad button is part of the action (Interaction):
inline constexpr GGInputAction BuyAction{ "Game.Buy", "Game.Action.Buy", "Game.Action.Buy.Tip", GGChord(GGKeys::E), {}, GGPad::South };
HUDs and Regions
Everything on a player's screen belongs to that player's HUD: placed in it, or added while the game runs to a region it declares. Nothing is shown loose, so every widget has a known parent, layer, owner, and lifetime.
{ "widget": "PlayerHud", "class": "PlayerHud", "kind": "hud", "version": 1, "root": { "name": "Screen", "type": "Stack", "children": [ { "name": "Health", "type": "Place", "anchor": "BottomLeft", "children": [ { "name": "HealthBar", "use": "ResourceBar", "Value": "=Player.CHealth.Value" } ] }, { "name": "Notifications", "type": "Column", "region": true, "anchor": "TopRight", "max": 4, "accepts": [ "Toast", "QuestUpdate" ] }, { "name": "Markers", "type": "Stack", "region": true } ] } }
Hud = TnxUI::SetHud<PlayerHud>(world, localSoul, Player->Body); const TnxWidgetRef note = Hud.Add("Notifications", "QuestUpdate", { { "Title", quest.Name } }); Hud.Remove(note); // or it removes itself, with a hide action
- A HUD is made as one asset (the Content Browser's New > HUD): the whole screen, laid out in the designer on the devices it will run on, with the regions things are added to. Its class (Widget Classes) is what it reads and calls.
- Setting it can't leave a context out: the ones it can't do without are its class's constructor parameters.
TnxUI::makes the class and shows the asset of its name (SetHud<T> SetHudAs<T>another asset of that class);TnxUI::by name shows a HUD with no class. Either says at once what is wrong (SetHud TnxUI::): an asset that isn't a HUD, one of another class, or a context left empty.HudProblems - One HUD per local player (each
Soulin its world), set withTnxUI::. Split screen gives each player theirs, in that player's part of the screen: the local players of one world share it, two one above the other and three or four in quarters, and a game whose views split another way places each withSetHud TnxHud::. A HUD fills its part and is clipped to it.SetArea - Its lifetime is a
ConstructLifetimetier (TnxHud::):SetLifetime World, the default, goes with its world;Levelalso with the level;Sessionoutlives its world, waits unshown for the next one (whereTnxHud::gives its class the contexts of the new world), and goes when its player leaves;OnNewWorld Persistentonly when it is removed. - Where it is drawn:
TnxUIrecords each HUD; whatever draws a player's view builds it over the image (TnxUI::). The game window's document does so for every local player; in the editor, each play view shows its world's, so play in editor shows every player's HUD as they see it and follows edits to its assets.BuildHuds - A region is a container element the HUD's author places and names (
"region": true): it arranges what is added as its type does (aColumnstacks them down, aStackon top of each other), with an optionalmax(the oldest goes;1replaces) and an optionalacceptslist of widget assets. Adding to a region the HUD hasn't got, or a widget it doesn't accept, is reported naming both. - Added widgets get their inputs and contexts from whoever adds them, as a
usedoes, and leave with an action ({ "hide": "self" }) orRemove. Theshowandtoastactions name the region they add to. - Layers every HUD has, bottom first:
Game(the HUD itself),Menu,Modal,Toast. A widget inModaltakes input focus and blocks what is under it; closing it gives focus back. A HUD can style and place these layers but not remove them, so menus and dialogs behave the same in every game. - World elements are the exception. A nameplate or an in-world panel belongs to its entity (
CWorldWidget), not a HUD, and comes and goes with it. Screen markers are HUD elements, added to a region likeMarkers. - In the designer, regions show placeholder content so a layout can be judged full: under Inputs and Contexts, each region is given copies of an asset it accepts, as many as it holds, or a placeholder that names it. The copies get the inputs typed for them there, as the game's
Addwould give them, and any of the HUD's contexts they read by the same name.
Contexts
A context is a typed source the asset reads, a member of its class (TnxContextOf<T>). Field paths are Context.Component.Field, picked in the designer from the context type's reflected components, so they are never typed by hand.
| Context type | Reads | Given as |
|---|---|---|
Entity type (EPlayer) | Any of its components' fields | An entity handle or a Construct's view |
Component (CMatchState) | That component's fields | An entity that has it |
| View model | Values a game registers for display only | The view model |
- Reading goes through the host's binding reader at the world's published read frame, as the editor's property grid does (Core). Nothing is copied or polled.
- Passing down: a composed widget with a class is a widget member of its user's class, whose constructor gives it its contexts (
TargetFrame Target{ Player };). A leaf widget usually has no class and takes inputs. - List items get their item as their context,
Item: an upgrade list over the upgrade entities gives each item widget its upgrade (ListsLists
). A row with no class may say what its item is ("contexts": { "Item": "Game.Offer" }), so what it reads is checked; it is the one context such a widget has.
- World-UI templates have one context, the entity they are drawn for, so an asset that passes the template rules is also a nameplate (World UI).
- View models hold what has no component: settings, connection state, numbers computed for display. Gameplay state stays in components, where it serializes and rolls back.
struct ConnectionInfo { float PingMs = 0.0f; int32_t Players = 0; TNX_VIEW_MODEL(ConnectionInfo, "Net.Connection", PingMs, Players) }; TNX_REGISTER_VIEW_MODEL(ConnectionInfo) TnxViewModel<ConnectionInfo> Connection; Connection.Set(&ConnectionInfo::PingMs, 42.0f); // any thread Connection.Update([](ConnectionInfo& info) { info.Players = 3; }); ui.Use("Lobby", { { "Net", TnxContext(Connection) } }); // "=Net.PingMs"; Lobby's class has TnxContextOf<ConnectionInfo> Net ui.Label(TnxBind(Connection, &ConnectionInfo::Players));
A view model has no components, so its paths are Context.Field. Its values are read through the same binding reader as entity fields: a change repaints what shows it, with no rebuild and nothing polled. Fields are numbers, flags, enums, and Fixed32. A destroyed view model reads as gone.
- The context is gone (the entity was destroyed, the world reset): its bindings read as gone and their elements show their default, as entity bindings do today. Shown documents follow their
ConstructLifetimetier (Lifetimes).
Validation
When an asset loads, every path, input, slot, region, and reference is resolved; when it is set or added, the contexts given are checked against what it declares. Errors name the asset, the element, and the property, and the rest still builds:
PlayerHud.tnxwidget line 6, PlayerHud.HealthBar.Value: Player.CHealth.Value: EPlayer has no CHealth SetHud(PlayerHud): its class gave it no Match (a TnxContextOf left empty) PlayerHud: Add("Notes", QuestUpdate): no region "Notes"; the HUD has Notifications, Markers UpgradeRow.tnxwidget line 4, UpgradeRow.Name.text: there is no input "Name"; the inputs are Title, Cost
A component field renamed in code shows up as one of these at load, not as a bar that silently reads zero.
Actions
What an element does when used. No logic is written in a widget asset; a visual language for that comes later.
| Action | Example | Done by |
|---|---|---|
| A sim command | { "command": "Game.BuyUpgrade", "Tier": 2 } | The simulation, at a fixed step: recorded, replayed, rolled back, and sent to the Authority |
| A UI action | { "show": "ShopPanel", "in": "Menu" }, { "show": "Confirm", "in": "Modal" }, { "hide": "self" }, { "tab": 2 }, { "play": "Pulse" }, { "toast": "Game.Saved" } | The HUD |
| A toast | { "toast": "Game.Saved" }, { "toast": "Game.LevelUp", "for": 5 }, { "toast": "Game.Quest", "in": "Notifications", "use": "QuestToast" } | The HUD: the text for for seconds (3 if not said) in the Toast layer, where toasts stack, or the region in names; built with the Toast asset, or the one use names, given the text as its Text; a label in the toast class if there is none |
| A call | { "call": "OpenMap" } | A method of the widget's class (Widget Classes), on the UI update; given the row's item in a list |
{ "name": "Buy", "type": "Button", "text": "Game.Shop.Buy", "on": { "click": { "command": "Game.BuyUpgrade", "Tier": "=Item.CUpgrade.Tier" } } }
A command never writes state from the UI: it enters the sim's input stream like a key press. Its fields are set by name beside it, to literals or to values read when it is sent. Commands and their fields are registered in code (step 1), so a misspelt command or field is a validation error.
Drag and drop: an element can be a drag source carrying its context (an inventory item) and a drop target for a context type. A drop is an event like a click, usually a command ("on": { "drop": { "command": "Game.MoveItem", "Item": "=Dropped.Item", "Slot": 3 } }), and the target has the :drop state while something it accepts is over it.
Styling
Every element is styled by class, as code-built UI is (Styling). An asset is also a style scope: its sheets, styles, and tokens reach everything it builds, including the assets it uses.
{ "widget": "StatBar", "version": 1, "sheets": [ "StatBar.ggss" ], "tokens": { "bar-fill": "#3FB950", "bar-back": "#1B1F28" }, "styles": { "bar": { "fill": "@bar-back", "accent": "@bar-fill", "radius": 3 }, "bar:hover": { "border": "1 @edge-bright" }, "caption": { "color": "@text-soft", "font-size": 12 } }, "root": { ... } }
{ "name": "Health", "use": "StatBar", "Value": "=...", "tokens": { "bar-fill": "@danger" } }, { "name": "Mana", "use": "StatBar", "Value": "=...", "tokens": { "bar-fill": "#4C8DFF" } }, { "name": "Notch", "type": "Row", "class": "bar", "style": { "radius": 7 } }
| Where | Written as | Reaches |
|---|---|---|
| The asset | "sheets": sheet files, in order; "styles": rules as in a sheet, over the sheets; "tokens": its tokens' values | Everything it builds, and the assets it uses |
| A use | "tokens" | That instance |
| An element | "style": properties as in a sheet | That element |
| Code | ui.Tokens({ { "bar-fill", colour } }) in an element's content; UseProps::Tokens | That element and everything inside it |
- Selectors, properties, and values are a sheet's (Styling), with tokens written
@name. A style takes values and tokens, not formulas. - What an outer author writes wins over what an inner one does. A HUD's styles restyle every
StatBarin it by class, and a token the HUD sets is used by every bar in it, over the bar's own value. Within one author, itsstylesare over its sheets, the tokens on a use over its own tokens, and an element'sstyleover its other rules. Code is outside every asset, soui.TokensandUseProps::Tokenswin over them all, the nearer over the farther. - An asset's tokens are its defaults. It declares the tokens it is recoloured by and their values; whoever uses it sets them, for one instance or for everything inside a composed widget.
- A scope is who built the element, not where it sits. What a HUD puts in a
StatBar's slot is styled as the HUD's. There are still no ancestry selectors: lookup is (scopes, classes, part, state), a table lookup. - A token's value can be a formula, on the asset or on a use, worked out per instance: over inputs (
"bar-fill": "=Fill"), or over game data, which it then follows:"=Player.CHealth.Value * 2"for a number,"=select(Player.CHealth.Low, '@danger', '@ok')"for one of two values (tokens, colours, or numbers) as the condition is true or not. When it changes, the looks using it repaint and nothing is built again. Four tokens per use, and four of the asset's own, can follow game data. - Popups an element opens, menus and tooltips, are built in the scopes the element is in, and a widget's own parts (a text field's text) are in the widget's.
- Sheet files are read from the widget folders with the assets and hot-reloaded with them. A sheet is named by its file name or the end of its path.
- The inspector's style explanation names the asset, use, or
ui.Tokenseach value came from.
Animation
Named clips in the asset, played by actions or triggers.
"animations": { "Spin": { "length": 1.2, "loop": true, "rate": "=Speed", "tracks": [ { "element": "Icon", "property": "rotation", "keys": [ [0, 0], [1.2, 360, "linear"] ] } ] }, "Pulse": { "length": 0.35, "tracks": [ { "element": "Meter", "property": "scale", "keys": [ [0, 1], [0.12, 1.15, "ease-out"], [0.35, 1] ] }, { "element": "Meter", "property": "opacity", "keys": [ [0, 1], [0.12, 0.7], [0.35, 1] ] } ] } }, "triggers": [ { "when": { "rises": "Value" }, "play": "Pulse" } ]
- Tracks animate an element's
opacity,offset-x,offset-y,scale,scale-x,scale-y,rotation(degrees, clockwise),skew-x, andskew-y(degrees), which apply to it and everything in it, and itsfill,accent,color,border, andradius. A key is[time, value]or[time, value, ease](linear,ease-in,ease-out,ease-in-out, the default, orstep), the ease shaping the way into it; a colour key is#RRGGBBor a token. - Nothing is laid out while a clip plays. A transform turns and scales the element and its content as a group about its centre, rewriting one clip entry and no row or primitive; a colour or opacity repaints the element, as a transition does. What a clip sets lies over what the builder and bindings set: an opacity multiplies, an offset adds, a scale multiplies, a rotation adds, a colour or radius replaces.
- Clips play once,
"loop": trueforever,"loop": 3three times, or"loop": "bounce"back and forth, at aratethat can be bound: a working spinner can speed up with progress. A clip that ends keeps its last values; one stopped lets go of them. - Triggers:
"shown"(when the asset first appears),"hidden"(a HUD widget being removed, which stays until its clip has played),{ "rises": "Value" }andfalls,changes(an input or a formula, read each frame), and{ "element": "Buy", "enters": "hover" }orleaves(hover,press,focus,checked), so an icon wiggles when the button holding it is hovered. Each runs"play"or"stop". - Actions:
{ "play": "Pulse" }and{ "stop": "Spin" }act on the nearest asset with that clip, and{ "play": "Pulse", "in": "Health" }on the asset used as the elementHealth. - Curve assets: a track can play a curve asset (
.tnxcurve) in place of keys of its own:{ "element": "Meter", "property": "offset-x", "curve": "Shake", "scale": 3, "length": 0.25 }playsShake's values timesscale, stretched tolengthseconds (its own length if none). A curve's keys are[time, value], reached along a cubic with automatic tangents, or with a third part: an ease word, or the key's tangents,{ "tangent": "auto" | "user" | "broken" | "flat", "in": slope, "out": slope }(value per second). An automatic key is smooth through its neighbours and level at the ends and at a peak or a trough, so a curve never overshoots its keys. Curves are the foundation's (Curve.h,CurveFile.h): the engine'sCurveTabletakes the same assets (CurveTable::Register), and a curve evaluates the same in a widget and in the engine. Numbers only: a colour track has keys of its own. - Event tracks are what a clip does at a time:
{ "element": "Coin", "events": [ [0.3, { "call": "PlayCoin" }], [0.8, [ { "play": "Sparkle", "in": "Icon" }, { "toast": "Game.Bonus" }]] ] }. Each is[time, action], the action (or a list of them) written as inon, about the element named (whatselfandinmean), or the asset's root if there is none. Each runs once as playing passes it, either way when bouncing, and again each loop; posing a clip runs nothing. A clip's length reaches its last event. In code,GGClip::Eventswith the actions given toUIContext::DeclareClip, andUIContext::RunClipEventsfor a tool scrubbing across them. - Tween:
"tween": 0.4or{ "time": 0.4, "ease": "linear" }on aLabelwith a bound number (it rolls up) or aProgress(it slides). - In code:
ui.Clip(name, GGClip)on the element being built into,UIContext::PlayandStop,ui.Tween,BoxProps::Transform,ui.Rotation,ui.Scale, andui.ScaleBox. - What an animation names must be built by the asset itself: an element inside a list's items or another asset's slot isn't reached by name.
- Flipbooks: an
Imageon a sprite sheet has aframeproperty, animated like any other track (a turning coin, a flickering fire). It uses the sprite sheets of step 11. - Time is the document's clock: reduced motion makes clips end at once, and a sim-bound document plays them identically on every resimulation.
The Designer
An editor window for widget assets, built from the editor's own panels:
| Part | What it does |
|---|---|
| Palette | Built-in elements, the engine's starter widgets, the project's widget assets, registered code widgets |
| Hierarchy | The element tree; drag to reorder and reparent; slots shown as places to drop |
| Canvas | The asset as it will look: resolution and aspect presets, safe-area overlay, UI scale, language |
| Details | The selected element's properties in the property grid; each can be a literal, a binding (a context field picker), an input, or a token |
| Inputs and contexts | Declared here, with preview data for each so the asset looks right without the game |
| Styles | The asset's sheet and tokens, and where each shown value came from |
| Animations | Clips on a timeline, keys per element and property |
| Problems | Validation errors, clickable to the element |
| Cost | Elements, bindings, and build time per asset and per instance |
Edits are undoable like every editor document, saved with the editor's safe writes, and reach a running game by hot reload.
- It edits the file, not the compiled asset. An open asset is its JSON tree, in the order it was written; each edit compiles it again into a library the designer owns, beside copies of the project's other assets, and the canvas builds from that. Unsaved edits never reach a running game: saving writes the file and the game's hot reload picks it up.
- Saving keeps the file as written. An element the designer didn't change keeps its keys in their order, and
//and/* */comments stay where they were, attached to what follows them. A comment just before an element can be turned into itsnote, which the designer shows. - Preview data is the editor's, not the asset's. Preview values for inputs and contexts, the resolution and aspect chosen, and placeholder region content are kept in
editor/previews/in the project (versioned, so a team shares them, and never packaged), one file per asset keyed by its asset ID, so a rename keeps them. The.tnxwidgetholds nothing for the designer butnotes. - Inputs and Contexts picks the asset's class and shows what it declares (its contexts, inputs, and the methods its actions can call), or, for a widget with no class, declares its inputs (name, type, and default); and holds the preview values. An input's preview is given to the canvas's use of the asset. A context is given as a preview target, and each of its fields that the asset's formulas read gets a value of its own. The field is found through the game's reflection, so a path the game doesn't have is reported in the designer just as it would be in the game.
- A context can read the game instead. Each context's Reads choice is the values typed for it, or the game in a play session: what the game gave this HUD under that name (
SetHud), else what it gave a HUD of that type, else a live entity of the type, or one picked from the play session's worlds by name (EPlayer #3 · Owner 1). The canvas then reads the published frame as the game's HUD does, so a bar moves as the player plays. With nothing playing, or nothing to read, the typed values are shown, and the choice is kept for the next session. - The canvas shows how the asset fits what it is given. It is a document of its own inside the canvas panel (
ui.Document, below), built from the designer's library. A HUD is shown on a screen: its size in pixels, which gives its aspect ratio, and a DPI, the pixels to a UI unit, so it is laid out in pixels / DPI units. Turning it round swaps width and height. The common devices are presets of the two (1920×1080 at 1, 3840×2160 at 2, 3440×1440 at 1, 1280×800 at 1, 2532×1170 at 3). Any other asset is shown in a parent area whose size is dragged, at its own size if it fits its content. UI scale and language are chosen beside them. - Details reads the registry. The selected elements' properties are the ones the widget registry lists for their types, in the groups Element, Layout, Content, Look, and Behaviour. With several selected, it shows the properties they share, and an edit goes to each of them as one undo step. A value is edited with its kind's editor: a number, a toggle, a list of words, or a colour. A size is how it is had (fit, grow, fixed, or a percentage of what holds it) and its number, made fixed at the size it is drawn at; edges are left, top, right, and bottom, written as briefly as they allow; scale, skew, and the pivot are across and down; an anchor is picked from a grid (Anchors, above); an image is a path picked from the project's content, by folder; a tooltip, a list's item, and a use pick a widget asset. What an element does (
on) opens an editor of its own: each event and its actions, each action's verb picked and what it names picked where the designer knows the choices (the commands the game registered, widget assets, the asset's clips), and its fields added from what the command or verb takes. A colour's tokens are the asset's own and the theme's. A property that can follow data has a button before it that makes it a formula, one of the asset's inputs, a token, or a binding picked from a context's fields: each context, then its components, then their fields, as the game's reflection lists them, so a path is never typed by hand. A dragged number is one undo step. A value set back to its default is left out of the file, and renaming an element in Details renames it everywhere it is referred to. - Styles edits the asset's own look in its file: its tokens (a colour gets a picker), its style rules and their properties, and the sheets it names. For the selected element it lists each property of the look the canvas drew, its value, the token it came through, and the rule and scope it came from.
- Cost builds one instance in a document of its own, with the preview values, and counts its nodes (with the assets it uses), bindings, and widget state, the nodes each element makes, and the average time to build it and lay it out the first time. It is measured again when the asset changes.
- Animation shows the asset's clips on a timeline: a row for each track (an element and a property), with its keys along it, under a ruler. Pressing or dragging on the ruler moves the playhead, and the canvas shows the clip held there (
UIContext::Pose). A key is dragged in time as one undo step, added at the playhead with the value of the key before it, and its time, value, and ease are edited below the tracks. The clip's length, looping, and rate are set above them, Play and Stop run it on the canvas, and the asset's triggers are listed as the file writes them. - Curves are edited in the Animation panel's curve editor: the curve drawn through its keys, keys dragged in time and value, the selected key's handles dragged round it to set its slopes, and its mode (auto, user, broken, flat, linear, step, or an ease) picked. The wheel zooms the time, with Shift the values; a middle or right drag moves the values shown. Make curve turns a track's own keys into a curve asset it plays. A curve edit is a step in the asset's undo, and the curve is saved with the asset. Before changing a curve that other assets play as well, the designer asks whether to change it for all of them or to make a copy for this track.
- On the canvas a
Placeis dragged to set itsxandy, and the selected element is sized by any of its edges or corners, which write a fixedwidthandheight. The edge held moves and the one across from it stays where it is: aPlace's offset follows by its anchor, so a bar anchored at the bottom grows upwards from its top edge and downwards from its bottom. With Alt it sizes about its middle; with Shift a corner keeps its shape. Each drag is one undo step. - Selecting and moving on the canvas. A click selects the element under the pointer; Shift or Ctrl adds it or takes it away. A drag from inside the selection moves it, every selected
Placetogether (Shift: along one axis); let go without moving and what was clicked is selected alone. A drag on an element in aRoworColumnmoves it among its siblings, a line showing where it will go. A drag from anything else draws a box that selects what lies wholly inside it, outermost only (Shift or Ctrl at the press: added to the selection). The arrow keys nudge the selectedPlaces a unit, ten with Shift; presses close together are one undo step. - Snapping. A moved or sized element's edges and middle snap to those of what holds it, its siblings, the screen, and the safe area, with a guide drawn along the line it is on. Snap in the canvas bar, or the editor's Snap action, turns it off; Ctrl holds it off during one drag. A snapped value is written exactly (layout edges are often between whole units); otherwise moves and sizes are whole units. A turned, skewed, or scaled element doesn't snap, as it isn't drawn where it is laid out.
- Anchors are picked in Details from a grid of the nine points of the box a
Placeis placed in, or None (laid out with its siblings). A new anchor keeps the element where it is drawn, itsxandychanged to match; with Ctrl it goes to the point itself. - The canvas's tools are Move (W), Rotate (E), and Skew; Space cycles them, as in the 3D viewport. Rotate draws a ring round the pivot and turns the element as the pointer goes round it (Shift: in 15° steps). Skew leans its top as the pointer moves across and drops its right side as it moves down (Shift: in 5° steps). In both, the pivot is drawn and can be dragged anywhere, in the element or out of it (Shift: onto its middle, corners, and the middles of its edges). It moves along the element's own axes, and a
Placemoves back as it does, so a turned element stays where it is drawn. The outline, the handles, and the pivot follow the element as it is turned and skewed, and sizing works along its own axes. - Auto-key. With Auto-key on in the timeline, a change to what the clip in front can animate is a key at the playhead instead of a change to the element: opacity, rotation, scale, skew, and colour, in Details or on the canvas, and a
Placemoved, nudged, or given anxory, which keys its offset from where it is placed. A key already at the playhead takes the value. The canvas is framed in red while it is on. - Event tracks are rows of markers on the timeline: + Events adds one for the element picked, a marker is dragged in time, and the selected event's actions are edited as an element's are. Played on the canvas, what they do is listed there; with Scrub runs events on, moving the playhead across one runs it too.
- The graph shows any numeric track: a curve asset's keys and handles, or the track's own keys, dragged in time and value. The clip's other numeric tracks are drawn faintly behind it, each fitted to the values the graph shows so their timing reads against it, and picked from the legend above it.
- Previewing behaviour. The selected element can be shown hovered, pressed, focused, or disabled, whatever the pointer does: its styles follow, and so do the clips its triggers play (
UIContext::ForceState). Interact (I) gives the pointer to the asset instead, as a player would use it (GGDocument::Dispatch): it hovers, presses, and clicks, its clips play, and what its actions would do (a command, an event, show, a toast) is listed on the canvas and never sent. While a clip plays, by Play or a trigger, the timeline's playhead follows it (UIContext::ClipTime). - The screen has the player's UI scale and the area it keeps clear (a notch, a home bar, overscan), set under Inputs and Contexts. The phone and tablet presets come with theirs, and Turn turns it. The canvas lays the HUD out at the DPI times the UI scale, gives
ui.SafeAreathe safe area, and shades what lies outside it. - The canvas's language is picked beside the screen: the editor's, any the project has strings for, or pseudo-text, which shows every string about 40% longer and accented, in brackets (
[Sàfé àréà ~~~~]), its placeholders and markup kept, so text a translation would cut off shows before there is one. It is kept with the asset's preview data, the editor stays in its own language, and a strings file saved is shown at once. - Docked, not split. Each part is its own dock panel that follows the designer tab in front, so the parts can be arranged, floated, and saved in layouts like any other. The UI workspace arranges them to start from.
Designer Steps
| # | What |
|---|---|
| 8a | The document: opened from the Content Browser, a tab per asset; the JSON tree with comments kept, saved in the outline layout with safe writes; undo and redo; unsaved changes, the exit question, autosave and recovery; the designer's own library; the canvas with resolution and aspect presets; Problems; the UI workspace |
| 8b | Structure: the hierarchy (drag to reorder and reparent, slots as drop places, rename, delete, duplicate, copy and paste, wrap with, replace with); the palette, dragged into the hierarchy or the canvas; selecting and hovering on the canvas |
| 8c | Details: the widget registry, which the loader and the designer share; each property a literal, an input, a token, or a binding picked from the context's reflected fields; the inputs and contexts panel with preview values |
| 8d | Styles and Cost: the asset's sheets, styles, and tokens, where each shown value came from, and elements, bindings, and build time per asset and instance |
| 8e | Animation: clips on a timeline, tracks per element and property, keys dragged, scrubbing and playing; triggers |
| 8f | Canvas tools: dragging Place offsets, resizing, anchors, region placeholders and their items' inputs, the safe-area overlay, UI scale, language; preview from play (a context reading what the game gave the HUD, or a live entity, in a play session) |
| 8g | Working in it all day: canvas handling (snapping and guides, box select, moving several, reordering in a Row or Column, nudging, the anchor grid); Details pickers (a binding picked from a context's reflected fields, image, widget, and curve pickers, tokens beside colours, sizes, edges, and actions edited as what they are); forcing hover, pressed, focused, and disabled, and an Interact mode that runs the asset's actions; the playhead following a playing clip; auto-key, several curves in the graph, event tracks; renaming with references, finding references, thumbnails |
Starter Widgets
The engine ships widget assets to use or copy, in Trinyx/assets/ui/widgets. They are read before the project's content, and a project asset of the same name replaces one without a warning, whichever is read first; copy the file into the project to change it. They are styled through the theme's tokens (accent, good, surface-panel, text-soft, ...) and their own, so a theme or a use restyles them.
| Widget | Inputs | Is |
|---|---|---|
StatBar | Value, Max (1), Label | A labelled bar that slides to a new value. Tokens bar-fill, bar-back |
ResourceBar | Value, Max (100), Label, Delay (0.4), Drain (0.5) | Health, mana: the amount beside its label, a trailing fill behind a drop, and a shake when it falls. Tokens bar-fill, bar-back, bar-trail |
Counter | Value (a big number), Label | A big number in suffix notation (1.23M) that rolls up, and bumps when it rises |
IconButton | Icon, Label | A button with an icon that wiggles on hover. What it does is set where it is used (on.click) |
Panel | Title | A titled frame as wide as its place, with a Content slot. Tokens panel-fill, panel-edge |
TooltipFrame | Title, Text | What a tooltip asset is built in, with a Content slot for more |
ListRow | Icon, Title, Detail | A list item: an icon, two lines cut to fit, and a Trailing slot (a price, a button) |
Toast | Text | What the toast action shows: it slides in and fades out |
CooldownIcon | Icon, Left (1 to 0) | An ability's icon, greyed under a ring that empties, and a pop when it is ready. Token cooldown-ring |
HeartRow | Count, Icon | One image per point, up to ten, with a hop when one is lost |
OptionRow | Label, Hint | A settings row: label and hint on the left, a Control slot on the right (a Carousel, by default, or a Toggle or Slider) |
The built-in controls are elements already and take classes like any other.
Steps
| # | What | Status |
|---|---|---|
| 1 | Game state in code: TnxBind(entityOrView, &Component::Field), sim commands (TnxSim::Send, a Construct's OnCommand) | Built (belowbelow); view models with step 2b |
| 2a | Widget assets in the GG-UI core: the file and its forgiving reader, compile, build (ui.Use), inputs, slots, formulas, actions handed to callbacks, validation, hot reload | Built: GGWidgetLibrary, ui.Use, UIContext::SetWidgets / SetWidgetActions |
| 2b | Widget assets in the engine: contexts against reflection, commands by name, the asset type, view models; HUDs, regions, and layers (TnxUI::, Add, Remove) | Built: contexts (TnxContext), commands by name (TNX_REGISTER_COMMAND), the host's library read from the project's content with hot reload, the asset type (listed and created in the Content Browser, with .tnxid sidecars), HUDs with regions, layers, show, hide, toast, and events, drawn in the game window and in play views, placed for split screen, with lifetime tiers; view models (TnxViewModel); references by asset ID |
| 3a | Structure: controls that take content (Button, Toggle), Switcher, Repeat, SafeArea, Carousel, tooltips as assets, on.change | Built |
| 3b | What paint draws: Progress, Image, and Label as above, Shape | Built, with a bindable image crop (each entry a number or a formula) and a blurred text-shadow. The flipbook frame is step 11 |
| 3c | Data: lists and tiles over game data with item contexts, selection, and slots; Viewport | Built: List and Tiles over data tables (.tnxtable), a game's sources (TnxUI::), and entities; where, sort, selection, select and activate, empty, header, footer; ==, !=, &&, \|\|, ! in formulas; Viewport. A list's where and sort can read the item and other contexts together |
| 4 | Rich text and action prompts: styled runs and inline images in one paragraph, action glyphs that follow the device | Built: RichText and ActionPrompt (ui.RichText, ui.ActionPrompt), spans and inline boxes in the text engine, gamepad buttons on input actions (GGPad, saved in Keybinds.ini), the device in use. Links (<link Name>, links, RichTextProps::OnLink). Rebinding a gamepad button in ui.KeyBinding is step 9 |
| 5 | Style scopes: asset sheets, scoped tokens, instance and element styles | Built: an asset's sheets, styles, and tokens; tokens on a use; element style; formula tokens over inputs; ui.Tokens, UseProps::Tokens; ranks so the outer author wins; .ggss files in the widget folders; tokens that follow game data (a number, or select between two values); popups and widget parts in their element's scopes; the inspector's explanation through scopes. Popups also get the document's assets, so an asset tooltip builds |
| 6 | Animation: clips (looping, bound rate), triggers (values, visibility, element states), tweens, the group transform, ScaleBox | Built: the group transform (an affine view per clip entry, on the GPU and in hit testing), clips and tweens in the core (UIAnimation.h), animations, triggers, play and stop, tween, and ScaleBox in assets; exit clips for HUD widgets. Not yet: the flipbook frame (step 11); a sim-bound document's clips (step 12); a popup anchored to an element under a transform is placed as if it weren't |
| 7 | Starter widgets | Built: the eleven above, read by the engine before the project's content (UIHost::AddWidgetFiles(path, true), GGWidgetLibrary::LoadStarter); drawn by the GPU as the reference draws them. With them: components grow when what they build grows, a button given content is sized round it, an element's own style is over its control's look, and a number shown as a constant follows notation; a bar's trailing fill takes the trail style property |
| 8 | The designer window (Designer StepsDesigner Steps) | 8a to 8f built: the document, the outline writer that keeps comments (GGWriteWidgetFile), the preview library, the canvas as a nested document (ui.Document), screens by size, DPI, UI scale, and safe area, Problems; the hierarchy, the palette, and the structure edits (GGWidgetFile); the widget registry, Details, inputs, contexts, and preview values; styles and cost; the animation timeline and curve assets; the canvas tools (move, size, rotate, skew, pivot), region previews, HUD assets, the canvas's language with pseudo-text, and preview from play. 8g under way: canvas handling, Details pickers, forced states, Interact, the live playhead, the actions editor, theme tokens, auto-key, the graph of any track, and event tracks built; widget classes (TnxWidget) behind assets, in place of declared contexts, named events, and the HUD's generated call |
| 9 | Input settings a game fills in: focus neighbours and wrapping, what brings the keyboard to the UI, tab switching, steps, wheel distance (Interaction) | |
| 10 | World-UI templates on the GPU, from widget assets; screen markers (an element held at an entity's place on screen, kept at the edge with an arrow when it is off screen) | |
| 11 | Sprites and sprite sheets; flipbook frames on Image | |
| 12 | Sim-bound documents |
Steps 1 to 8 make the HUD for the first game; 9 makes it work on a gamepad. Step 2 is split along the module boundary: the core knows nothing of the engine and is tested headless. The designer comes after the runtime so it edits a format that already runs.
Open
- Later elements: video, a blurred backdrop behind a panel, charts, a scrolling ticker, an on-screen keyboard for consoles.
- Effects that need the renderer: a material or shader on an element, a blurred backdrop, masks beyond rounded clipping.
- Text that changes with a number or a person: plural forms and grammatical gender in the string tables.
- A compact indented text form for people who write UI as text, loading into the same asset.
- Whether a widget asset can declare a visual-logic graph once the visual language exists, and what it may touch.
Curves
- Weighted handles. A key's handles set slopes only. A
"weight"per side would let a handle be lengthened too; the curve is then a Bezier solved for time. The key format leaves room for it. - Opening a curve on its own. A
.tnxcurveopened from the Content Browser should open in the curve editor without a widget track that plays it, with its own tab, undo, and save. - Curve editor working space. It lives in the Animation panel, under the tracks, at a fixed height. It needs room of its own: a height that follows the panel, or a panel of its own.
- Editing several keys at once. Box selection, moving or scaling a selection, flattening its tangents, copying and pasting keys.
- Several curves in one graph. Showing the clip's other tracks (and their curves) over the one being edited, to line up motions.
- Snapping. Times to the ruler's marks and values to round numbers, and a key snapped to the playhead.
- Value labels. The graph draws lines at round values but doesn't number them.
- Curves for colours and other kinds. A curve plays numbers; a colour track has keys of its own. Vector and colour curves need
CurveTablestores of their own. - The engine taking curve assets by name.
CurveTable::Registertakes a read curve file. Camera transitions and other engine users should name a curve asset and get its handle, followed on hot reload. - Region previews with inputs. A region's preview copies get no inputs, so one that needs them (a toast's text) shows empty. They should take preview values as the asset's own inputs do.
- Live playhead. While a clip plays on the canvas the playhead doesn't move with it: there is no way to read a playing clip's time yet.