ZOA Common
The shared UI primitives more than one package needs, plus the arbitration that stops HUDs fighting over the screen.
Common is where a primitive lands when a second package needs it. RadialMenu started life inside Nucleon with six hardcoded squad-command slices; when the weapon wheel needed the same control, the choice was to duplicate it or to generalise it. It was generalised, parameterised on its payload type, and moved here, and now both wheels are the same code.
The larger job the package does is arbitration. A running game has a minimap, a compass, a survival strip, an ammo readout, a crosshair, and a debug overlay, all of them independent MonoBehaviours that each know where they want to sit and nothing about each other. ZOAHudSlotRegistry gives them a way to claim a screen region and be told what they actually got, and ZOAUiSortingLayers gives every surface an explicit depth so a UIDocument at the default sorting order cannot silently swallow input meant for something else.
Everything here is UI Toolkit and everything here is programmatic. The HUD primitives build their own VisualElement trees rather than loading UXML, so any package can construct one without an asset import, and they pull their colours from --zoa-* tokens when a theme is attached to the panel ancestry, with hardcoded fallbacks so they stay usable in a test harness or a standalone preview.
Depends on (1)
How it works
Concepts
A widget claims a slot and is told what it got
ZOAHudSlot names eleven canonical screen regions, from the four corners through the two centres and two vertical rails to a full-screen overlay. A HUD widget declares a preferred slot in its inspector and calls ZOAHudSlotRegistry.Claim(preferred, this) in OnEnable. What comes back is the slot it actually holds, which may not be the one it asked for.
When a single-occupancy slot is already taken, the registry logs a warning naming the current claimants and walks a fixed fallback chain: top-right, top-left, bottom-right, bottom-left, top-centre, bottom-centre, middle-left, middle-right, then the two rails. The widget still gets a unique region instead of silently overlapping something else. If every slot is taken it returns ZOAHudSlot.None and the widget falls back to its inspector-default anchor.
LeftRail, RightRail and FullScreen are stackable and accept multiple claimants, because a rail of stacked widgets and a set of coexisting full-screen overlays are both legitimate. Claiming a slot you already hold is a no-op that returns the same slot, so calling Claim from OnEnable repeatedly is safe.
The slot is abstract, the anchor is concrete
ZOAHudSlotLayout.Resolve translates a slot into a ZOAHudSlotDescriptor carrying a ZOAHudAnchor preset and an edge margin in pixels, and ZOAHudAnchorApplier.Apply writes that onto a VisualElement as inline style. Keeping the translation in one lookup table means the canonical positions can move, tightening margins for a console preset for instance, with one edit rather than a pass over every HUD widget.
ZOAHudAnchor here is the canonical enum. A duplicate previously lived in the minimap package and was retired during the package-duplication consolidation; the canonical version is a strict superset of every legacy variant, so nothing was lost, though the numeric values do not line up and scenes carrying a serialized legacy value are regenerated rather than migrated.
Every UIDocument and Canvas declares its sorting order
Mixing uGUI Canvases with UI Toolkit UIDocuments makes depth a shared, global concern. A UIDocument left at sorting order zero sits at the same depth as a Canvas left at zero, and later-attached documents render on top by attach order, so a panel ends up invisible or, worse, quietly eating clicks meant for the window behind it.
ZOAUiSortingLayers is the fix: a set of named constants that every UIDocument and Canvas in the project assigns from. Background sits at -100, passive HUD readouts at 0, the compass just above at 5, the crosshair at 10, interaction prompts at 20, notifications at 30, developer HUDs at 50, the main menu at 80, modal windows at 100, modal dialogs at 150, the loading screen at 200, and runtime diagnostics on top at 1000. A sub-window that must sit above its owner takes its owner's tier plus one.
The tiers encode intent. The compass sits above the survival strip so it is not clipped by health bars; debug HUDs sit above notifications but below the main menu so they never block menu interaction; a quit prompt at the dialog tier covers an open inventory at the window tier.
HUD primitives read attributes through one interface
SurvivalStripElement and MobaOrbElement render attribute values, and they take that data through IHudAttributeBinding: a display name, an icon, a fill fraction, a colour, and a role hint, plus a Changed event raised on the Unity main thread. Any package can write an adapter, so the primitives never take a reference on Nucleon or the survival system.
Refresh is event-driven rather than polled. An element subscribes on attach, unsubscribes on detach, and re-reads CurrentFraction when Changed fires, and a binding can be swapped at any time through SetBinding.
The role hint is advice. MobaOrbElement uses HudAttributeRole to decide which binding drives the primary health orb and which drives the resource orb; SurvivalStripElement ignores it entirely and shows every binding it is given.
One presenter, several archetypes
HudStylePresenter owns a host VisualElement and rebuilds it for a given HudStyle and binding list. SurvivalStrips renders a vertical stack of name-bar-icon rows; MobaOrbs renders a health orb and an optional resource orb with a percentage readout; Minimal is the conservative baseline, and it is also the fallback for any style value the presenter does not recognise, so a new archetype can be added without breaking existing consumers.
The presenter does not decide which style applies. That decision belongs to the caller, which in Foundry is a resolver in Nucleon reading the player's settings profile and the active gameplay view. Render is a pure replacement: the host is cleared and rebuilt, and callers who want to avoid teardown on every value change let the primitives' own binding subscriptions do the work instead.
In the editor
Screens
Screenshot pending
/screenshots/hud-layout-designer.png
The editor window showing the 16:9 slot grid with several regions occupied, the claimant list on the side naming the live HUD MonoBehaviours holding each slot.
Screenshot pending
/screenshots/hud-slots-in-play.png
A gameplay frame with widgets in several canonical slots at once, minimap bottom-left, compass top-centre, survival strip top-right, so the arbitration result is visible as a layout.
Screenshot pending
/screenshots/hud-style-archetypes.png
The same attribute bindings rendered twice by HudStylePresenter, once as the vertical strip stack and once as the orb pair with percentage readouts.
Screenshot pending
/screenshots/radial-menu.png
The radial control open over gameplay with one sector hovered and highlighted, showing labels and icons around the ring.
Setup
Workflow
- 01
Declare a preferred slot on the widget
Expose a serialized ZOAHudSlot field so the widget's intended home is visible in the inspector, and pick the canonical one for its role: minimap bottom-left, compass top-centre, survival top-right, ballistics bottom-right, debug top-left.
- 02
Claim in OnEnable, release in OnDisable
Call Claim with the preferred slot and keep the returned value; that is the slot you actually hold. Release the same value in OnDisable so the region frees up when the widget is torn down or the scene unloads.
- 03
Resolve the claimed slot to an anchor
Feed the claimed slot through ZOAHudSlotLayout.Resolve and hand the descriptor to ZOAHudAnchorApplier.Apply along with your root element. When the claim came back None, fall back to the widget's own inspector anchor through the same applier so both paths dock identically.
- 04
Pin the sorting order
Assign the UIDocument's sortingOrder from ZOAUiSortingLayers rather than leaving it at zero. Passive readouts take Hud, prompts take Interaction, and anything modal takes ModalWindow or above.
- 05
Bind attributes rather than pushing values
Adapt whatever owns the numbers to IHudAttributeBinding and hand the bindings to a HudStylePresenter. The primitives subscribe on attach and repaint on Changed, so no per-frame push is needed.
Surface
Key types
ZOAHudSlot
enum
The eleven canonical screen regions a HUD widget can dock into. None is the sentinel for no claim.
- None, TopLeft, TopCenter, TopRight
- MiddleLeft, MiddleRight
- BottomLeft, BottomCenter, BottomRight
- LeftRail, RightRail, FullScreen
ZOAHudSlotRegistry
class
Process-wide claims registry. Static so a HUD MonoBehaviour can claim from OnEnable without resolving a service first. State clears on domain reload, and tests call Clear in teardown.
- static ZOAHudSlot Claim(ZOAHudSlot preferred, MonoBehaviour claimant)
- static void Release(ZOAHudSlot slot, MonoBehaviour claimant)
- static bool IsClaimed(ZOAHudSlot slot)
- static bool IsStackable(ZOAHudSlot slot)
- static IReadOnlyList<MonoBehaviour> Claimants(ZOAHudSlot slot)
- static event Action<ZOAHudSlot, MonoBehaviour> SlotChanged
- static void Clear()
ZOAHudSlotLayout
class
Slot to anchor-and-margin lookup. The one place the canonical on-screen positions are defined.
- static ZOAHudSlotDescriptor Resolve(ZOAHudSlot slot)
- struct ZOAHudSlotDescriptor { ZOAHudSlot Slot; ZOAHudAnchor Anchor; float Margin; }
ZOAHudAnchor
enum
The canonical anchor presets a resolved slot maps onto. ZOAHudAnchorApplier.Apply writes one onto a VisualElement as inline style, and StretchToFillParent covers the full-screen case.
- TopLeftCorner, TopRightCorner, BottomLeftCorner, BottomRightCorner, TopCenter, ...
- static void ZOAHudAnchorApplier.Apply(VisualElement element, ZOAHudAnchor anchor, float margin)
- static void ZOAHudAnchorApplier.StretchToFillParent(VisualElement element)
ZOAUiSortingLayers
class
Canonical sorting-order constants for every UIDocument and Canvas in the project. Lower is further back; higher receives input first when stacks overlap.
- const int Background = -100, Hud = 0, Compass = 5, Crosshair = 10
- const int Interaction = 20, Notification = 30, DebugHud = 50
- const int MainMenu = 80, ModalWindow = 100, ModalDialog = 150
- const int LoadingScreen = 200, DebugOverlay = 1000
RadialMenu<T>
class
Generic radial-menu VisualElement. Renders equal-sized sectors around a centre, tracks hover from a pointer position the caller supplies, and fires a selection event carrying the slice index and its payload. Knows nothing about what the payload means.
- RadialMenu(IReadOnlyList<Slice> slices, float radius = 120f)
- readonly struct Slice { string Label; Color Colour; Sprite Icon; T Payload; }
- event Action<int, T> OnSliceChosen
- void Show(Vector2 screenPos) void Hide()
- void UpdateHover(Vector2 pointerPos) void CommitHover()
- int Count { get; } float Radius { get; } int HoverIndex { get; }
IHudAttributeBinding
interface
One attribute's live view. The seam that lets HUD primitives render health, stamina or mana without referencing whatever system computes them.
- string DisplayName { get; } Sprite Icon { get; }
- float CurrentFraction { get; } Color CurrentColor { get; }
- HudAttributeRole Role { get; }
- event Action Changed
HudAttributeRole
enum
Role hint on a binding. Drives orb slot selection in the MOBA presenter and is ignored by the survival strips.
- Unspecified, Primary, Resource, Auxiliary
HudStyle
enum
Top-level HUD layout archetypes a presenter can render. Unrecognised values fall back to Minimal, so new archetypes do not break existing consumers.
- Minimal, SurvivalStrips, MobaOrbs
HudStylePresenter
class
Thin router that owns a host element and rebuilds it for a style and binding list. Never decides which style applies; that is the caller's job.
- HudStylePresenter(VisualElement host)
- HudStyle CurrentStyle { get; }
- void Render(HudStyle style, IReadOnlyList<IHudAttributeBinding> bindings)
SurvivalStripElement
class
Single-row attribute strip: name on the left, fill track in the middle, icon on the right. Built programmatically, themed through --zoa-surface-* and --zoa-border-* tokens with hardcoded fallbacks.
- SurvivalStripElement()
- void SetBinding(IHudAttributeBinding binding)
- IHudAttributeBinding Binding { get; }
- const string UssClass = "zoa-survival-strip" (plus __name, __track, __fill, __icon)
MobaOrbElement
class
Health and resource orb pair with a centred percentage label. The fill is a vertically-clipped inner circle rather than a radial mesh, which is the cheapest convincing approximation UI Toolkit allows at runtime. Collapses to one orb when the resource binding is null.
- MobaOrbElement()
- void SetBindings(IHudAttributeBinding primary, IHudAttributeBinding resource)
- const string UssClass = "zoa-moba-orbs" (plus --primary, --resource modifiers)
HudSubtitleEvent
struct
Published on the shared event bus to put a line on the HUD subtitle band. It lives here because Common sits at the bottom of the graph, so dialogue, audio, scripted sequences and accessibility can all publish subtitles without referencing the package that renders them.
- readonly string Speaker readonly string Text readonly float DurationSeconds
- HudSubtitleEvent(string speaker, string text, float durationSeconds = 0f)
ZOAThemedHudUtility
class
Shared UI Toolkit chrome for runtime HUDs, debug HUDs and diagnostic panels: applies the theme and standard chrome to a root, sets up truncating titles, and enables editor-style drag and resize on standard HUD panels.
- static void ApplyThemeAndChrome(VisualElement root)
- static T ConfigureTruncatedTitle<T>(this T element, string fullValue = null)
- static void EnableEditorChromeForStandardHudPanels(...)
- static void EnableEditorDragResize(...)
Usage
Examples
using UnityEngine;
using UnityEngine.UIElements;
using ZOA.Common.UI;
using ZOA.Common.Unity.UI;
[RequireComponent(typeof(UIDocument))]
public sealed class AmmoHud : MonoBehaviour
{
[SerializeField] private ZOAHudSlot preferredSlot = ZOAHudSlot.BottomCenter;
[SerializeField] private ZOAHudAnchor fallbackAnchor = ZOAHudAnchor.BottomCenter;
[SerializeField, Min(0f)] private float margin = 16f;
private ZOAHudSlot _claimedSlot;
private void OnEnable()
{
var document = GetComponent<UIDocument>();
document.sortingOrder = ZOAUiSortingLayers.Hud;
// Claim returns what you actually got, which may not be what
// you asked for: a taken single-occupancy slot walks the
// fallback chain rather than letting two HUDs overlap.
_claimedSlot = ZOAHudSlotRegistry.Claim(preferredSlot, this);
var root = document.rootVisualElement;
if (_claimedSlot != ZOAHudSlot.None)
{
var descriptor = ZOAHudSlotLayout.Resolve(_claimedSlot);
ZOAHudAnchorApplier.Apply(root, descriptor.Anchor, descriptor.Margin);
}
else
{
ZOAHudAnchorApplier.Apply(root, fallbackAnchor, margin);
}
}
private void OnDisable()
{
if (_claimedSlot == ZOAHudSlot.None) return;
ZOAHudSlotRegistry.Release(_claimedSlot, this);
_claimedSlot = ZOAHudSlot.None;
}
}using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using ZOA.Common.UI;
public sealed class WeaponWheel
{
private readonly RadialMenu<int> _wheel;
public WeaponWheel(VisualElement host, IReadOnlyList<WeaponEntry> weapons)
{
var slices = new List<RadialMenu<int>.Slice>(weapons.Count);
for (int i = 0; i < weapons.Count; i++)
{
slices.Add(new RadialMenu<int>.Slice(
label: weapons[i].DisplayName,
colour: weapons[i].Tint,
icon: weapons[i].Icon,
payload: weapons[i].SlotIndex));
}
_wheel = new RadialMenu<int>(slices, radius: 140f);
_wheel.OnSliceChosen += (index, slotIndex) => Equip(slotIndex);
host.Add(_wheel);
}
// Hover tracking is caller-driven so the wheel works from a mouse
// position, a gamepad stick, or a synthetic position in a test.
public void Tick(Vector2 pointerPosition) => _wheel.UpdateHover(pointerPosition);
// Callers that commit on action release rather than mouse-up
// drive the selection explicitly instead.
public void Commit() => _wheel.CommitHover();
public void Open(Vector2 screenPos) => _wheel.Show(screenPos);
public void Close() => _wheel.Hide();
private void Equip(int slotIndex) { /* ... */ }
}using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using ZOA.Common.UI.HUD;
// Adapt whatever owns the numbers. The primitives never see your type.
public sealed class AttributeBinding : IHudAttributeBinding
{
public string DisplayName { get; set; }
public Sprite Icon { get; set; }
public float CurrentFraction { get; private set; }
public Color CurrentColor { get; set; }
public HudAttributeRole Role { get; set; }
public event Action Changed;
public void Push(float fraction01)
{
CurrentFraction = fraction01;
Changed?.Invoke();
}
}
public sealed class VitalsHud
{
private readonly HudStylePresenter _presenter;
private readonly List<IHudAttributeBinding> _bindings = new();
public VitalsHud(VisualElement host) => _presenter = new HudStylePresenter(host);
// Render is a full replacement, so call it when the style or the
// binding set changes, not when a value changes: the elements
// subscribe to Changed themselves.
public void Apply(HudStyle style) => _presenter.Render(style, _bindings);
}using ZOA.Common.UI.HUD;
using ZOA.Messaging;
var bus = FoundryServiceRegistry.Get<IFoundryEventBus>();
// Speaker may be empty for narrator or system lines; a duration of
// zero falls back to the renderer's configured default.
bus.Publish(new HudSubtitleEvent("// ALLY-KILO", "Contact, north ridge.", 3.5f));Tooling
Editor tools
HUD Layout Designer
Tools > ZOA > Advanced > Build > UI > HUD Layout Designer
Draws a 16:9 mini-screen with the canonical slots overlaid as labelled regions and lists the claimants the registry currently tracks. Clicking a slot pings its claimants in the Hierarchy and the context menu reassigns a claimant to another slot. It subscribes to SlotChanged, so a play-mode HUD claiming or releasing a slot updates the canvas live, and it redraws on hierarchy and selection changes for edit-mode authoring.
Read this
Notes and caveats
See also