Kernelcom.zoa.common · v0.4.0

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.

The HUD Layout Designer

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.

Canonical slots occupied in play mode

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.

SurvivalStrips beside MobaOrbs

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.

RadialMenu in play

Setup

Workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

Claiming a slot and docking to itcsharp
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;
    }
}
Release the slot you were granted, not the one you preferred. They are frequently different.
Building a weapon wheel from the generic radial menucsharp
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) { /* ... */ }
}
The payload type is yours. The squad-command wheel and the weapon wheel are the same control with different payloads.
Rendering attributes through a presentercsharp
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);
}
MobaOrbs reads the Role hint to pick which binding drives which orb. SurvivalStrips renders them all in order.
Publishing a subtitle from anywherecsharp
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));
The publisher never references the package that renders the band. Common sits at the bottom of the dependency graph, so the event type lives here.

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