# 3. Worked Example: HUD and Inventory Screen Part of [[UI Toolkit and Diegetic UI]]. Requires Unity 6000.5+ (uses `PanelRenderer`). Previous: [[2.UI Toolkit Project Setup]]. Next: [[4.Data Binding and Custom Controls]]. We build, from scratch: - A **HUD** (bottom-left status panel with a data-bound health bar, health text and gold, a toast notification, and a key hint). - An **inventory screen** on its own panel component: `ListView` of item stacks, a detail pane, a drop button, open/close with a USS transition. - A small **data layer** (`PlayerStats`, `ItemDefinition`, `Inventory`) that knows nothing about UI Toolkit except the binding interface. Everything is complete code you can paste. Create the folders from [[2.UI Toolkit Project Setup]] first, and the `Theme.uss` from section 2.6 of that note. ## Step 1 - The data layer Design rule: gameplay owns state; UI observes it. The HUD reads `PlayerStats` through **runtime data binding**, so no `Update()` polling and no UI references inside gameplay code. ### 1a. `PlayerStats.cs` (`Assets/Game/`) `INotifyBindablePropertyChanged` tells the binding system exactly which property changed, so only those bindings update. Without it, bindings poll every frame. `[CreateProperty]` makes a property visible to the binding system; `[DontCreateProperty]` hides the serialized backing fields. ```csharp using System; using System.Runtime.CompilerServices; using Unity.Properties; using UnityEngine; using UnityEngine.UIElements; public class PlayerStats : MonoBehaviour, INotifyBindablePropertyChanged { [SerializeField, DontCreateProperty] float health = 100f; [SerializeField, DontCreateProperty] float maxHealth = 100f; [SerializeField, DontCreateProperty] int gold; // Required by INotifyBindablePropertyChanged public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged; [CreateProperty] public float Health { get => health; set { value = Mathf.Clamp(value, 0f, maxHealth); if (Mathf.Approximately(health, value)) return; health = value; Notify(); // "Health" Notify(nameof(HealthText)); } } [CreateProperty] public float MaxHealth { get => maxHealth; set { value = Mathf.Max(1f, value); if (Mathf.Approximately(maxHealth, value)) return; maxHealth = value; health = Mathf.Min(health, maxHealth); Notify(); Notify(nameof(Health)); Notify(nameof(HealthText)); } } [CreateProperty] public int Gold { get => gold; set { value = Mathf.Max(0, value); if (gold == value) return; gold = value; Notify(); Notify(nameof(GoldText)); } } // Read-only, derived: bind with BindingMode.ToTarget [CreateProperty] public string HealthText => quot;{Mathf.CeilToInt(health)} / {Mathf.CeilToInt(maxHealth)}"; [CreateProperty] public string GoldText => quot;{gold:N0} gold"; public void TakeDamage(float amount) => Health -= amount; public void Heal(float amount) => Health += amount; public void AddGold(int amount) => Gold += amount; void Notify([CallerMemberName] string property = "") => propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(property)); // Inspector edits at runtime bypass the setters; re-notify so the HUD follows. void OnValidate() { if (!Application.isPlaying) return; Notify(nameof(Health)); Notify(nameof(MaxHealth)); Notify(nameof(Gold)); Notify(nameof(HealthText)); Notify(nameof(GoldText)); } [ContextMenu("Damage 10")] void DebugDamage() => TakeDamage(10f); [ContextMenu("Heal 10")] void DebugHeal() => Heal(10f); [ContextMenu("Gold +25")] void DebugGold() => AddGold(25); } ``` Why `ContextMenu`: right-click the component header in Play Mode to test the HUD without writing gameplay. ### 1b. `ItemDefinition.cs` (`Assets/Game/`) ```csharp using UnityEngine; [CreateAssetMenu(menuName = "Inventory/Item Definition", fileName = "NewItem")] public class ItemDefinition : ScriptableObject { public string displayName = "New Item"; public Sprite icon; [TextArea] public string description; [Min(1)] public int maxStack = 99; } ``` ### 1c. `Inventory.cs` (`Assets/Game/`) ```csharp using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class InventorySlot { public ItemDefinition item; public int count; } public class Inventory : MonoBehaviour { [SerializeField, Min(1)] int capacity = 24; [SerializeField] List<InventorySlot> slots = new List<InventorySlot>(); // ListView.itemsSource takes an IList; List<T> qualifies. public List<InventorySlot> Slots => slots; public event Action Changed; public event Action<ItemDefinition, int> ItemAdded; /// <returns>How many were actually added (less than amount if full).</returns> public int Add(ItemDefinition item, int amount = 1) { if (item == null || amount <= 0) return 0; int remaining = amount; // 1) top up existing stacks foreach (var slot in slots) { if (remaining == 0) break; if (slot.item != item) continue; int space = item.maxStack - slot.count; if (space <= 0) continue; int moved = Mathf.Min(space, remaining); slot.count += moved; remaining -= moved; } // 2) open new stacks while (remaining > 0 && slots.Count < capacity) { int moved = Mathf.Min(item.maxStack, remaining); slots.Add(new InventorySlot { item = item, count = moved }); remaining -= moved; } int added = amount - remaining; if (added > 0) { ItemAdded?.Invoke(item, added); Changed?.Invoke(); } return added; } public bool Remove(ItemDefinition item, int amount = 1) { if (item == null || amount <= 0) return false; int total = 0; foreach (var s in slots) if (s.item == item) total += s.count; if (total < amount) return false; for (int i = slots.Count - 1; i >= 0 && amount > 0; i--) { if (slots[i].item != item) continue; int take = Mathf.Min(slots[i].count, amount); slots[i].count -= take; amount -= take; if (slots[i].count == 0) slots.RemoveAt(i); } Changed?.Invoke(); return true; } } ``` ### 1d. `InventoryDebugGiver.cs` (test helper) Press **G** to add a random item. Uses the Input System package. ```csharp using UnityEngine; using UnityEngine.InputSystem; public class InventoryDebugGiver : MonoBehaviour { [SerializeField] Inventory inventory; [SerializeField] ItemDefinition[] items; void Update() { var kb = Keyboard.current; if (kb == null || items == null || items.Length == 0) return; if (kb.gKey.wasPressedThisFrame) inventory.Add(items[Random.Range(0, items.Length)], Random.Range(1, 6)); } } ``` ## Step 2 - The HUD documents ### 2a. `Hud.uxml` (`Assets/UI/Hud/`) Notes on choices: - The full-screen root and the layout containers use `picking-mode="Ignore"` so the HUD never swallows clicks meant for the world or other panels. Only things that must receive pointer events keep the default (`Position`). - Two stylesheets: shared theme + feature sheet. - Bindings are attached from C# in this tutorial (explicit and refactor-safe). Section 4.4 of [[4.Data Binding and Custom Controls]] shows the UXML-attribute equivalent. ```xml <ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="False"> <Style src="project://database/Assets/UI/Shared/Theme.uss" /> <Style src="Hud.uss" /> <ui:VisualElement name="hud-root" class="theme hud-root" picking-mode="Ignore"> <ui:VisualElement name="status-panel" class="panel status-panel" picking-mode="Ignore"> <ui:Label text="VITALS" class="caption" /> <ui:ProgressBar name="hp-bar" class="bar bar--health" low-value="0" high-value="100" /> <ui:Label name="hp-text" class="value-text" /> <ui:Label name="gold-text" class="value-text value-text--gold" /> </ui:VisualElement> <ui:VisualElement name="toast-row" class="toast-row" picking-mode="Ignore"> <ui:Label name="toast" class="panel toast toast--hidden" picking-mode="Ignore" /> </ui:VisualElement> <ui:Label name="hint" class="hint" text="[Tab] Inventory" picking-mode="Ignore" /> </ui:VisualElement> </ui:UXML> ``` The absolute `project://database/...` path for the shared theme is the form the UI Builder writes when a sheet lives in another folder. If the Builder rewrote it differently in your project, keep whatever it generated. ### 2b. `Hud.uss` `.unity-progress-bar__background` and `.unity-progress-bar__progress` are the built-in `ProgressBar` parts, styled through descendant selectors from our `.bar` class. Open the Debugger to see them. ```css .hud-root { position: absolute; left: 0; top: 0; right: 0; bottom: 0; } .status-panel { position: absolute; left: 24px; bottom: 24px; width: 300px; } .bar { height: 20px; margin-top: 6px; margin-bottom: 4px; } .bar .unity-progress-bar__background { background-color: rgba(255, 255, 255, 0.08); border-width: 0; border-radius: 4px; } .bar .unity-progress-bar__progress { border-radius: 4px; } .bar--health .unity-progress-bar__progress { background-color: var(--color-health); } .value-text { font-size: 14px; -unity-font-style: bold; margin: 0; } .value-text--gold { color: var(--color-gold); margin-top: 4px; } /* Toast: absolute row spanning the width, centred content */ .toast-row { position: absolute; left: 0; right: 0; top: 24px; flex-direction: row; justify-content: center; } .toast { padding: 8px 16px; transition-property: opacity, translate; transition-duration: 0.25s; transition-timing-function: ease-out; } .toast--hidden { opacity: 0; translate: 0 -12px; } .hint { position: absolute; right: 24px; bottom: 24px; color: var(--color-muted); font-size: 12px; } ``` ## Step 3 - `HudController.cs` (`Assets/UI/Hud/`) Responsibilities: find elements, attach bindings, show toasts. It does **not** modify health or gold. ```csharp using Unity.Properties; using UnityEngine; using UnityEngine.UIElements; [RequireComponent(typeof(PanelRenderer))] public class HudController : MonoBehaviour { const string ToastHiddenClass = "toast--hidden"; [SerializeField] PlayerStats stats; [SerializeField] Inventory inventory; [SerializeField, Min(0.5f)] float toastSeconds = 2f; PanelRenderer panelRenderer; int loadedVersion = -1; Label toast; IVisualElementScheduledItem toastTimer; void OnEnable() { panelRenderer = GetComponent<PanelRenderer>(); panelRenderer.RegisterUIReloadCallback(OnUIReload); if (inventory != null) inventory.ItemAdded += OnItemAdded; } void OnDisable() { panelRenderer.UnregisterUIReloadCallback(OnUIReload); if (inventory != null) inventory.ItemAdded -= OnItemAdded; } void OnUIReload(PanelRenderer renderer, VisualElement root, int version) { if (version == loadedVersion) return; loadedVersion = version; // The data source is inherited by every descendant that has a binding // without its own dataSource. root.dataSource = stats; var hpBar = Require<ProgressBar>(root, "hp-bar"); hpBar.SetBinding("value", ToTarget(nameof(PlayerStats.Health))); hpBar.SetBinding("highValue", ToTarget(nameof(PlayerStats.MaxHealth))); Require<Label>(root, "hp-text").SetBinding("text", ToTarget(nameof(PlayerStats.HealthText))); Require<Label>(root, "gold-text").SetBinding("text", ToTarget(nameof(PlayerStats.GoldText))); toast = Require<Label>(root, "toast"); toastTimer = null; // timers belonged to the old tree } public void ShowToast(string message) { if (toast == null) return; toast.text = message; toast.RemoveFromClassList(ToastHiddenClass); toastTimer?.Pause(); toastTimer = toast.schedule .Execute(() => toast.AddToClassList(ToastHiddenClass)) .StartingIn((long)(toastSeconds * 1000f)); } void OnItemAdded(ItemDefinition item, int amount) => ShowToast(quot;+{amount} {item.displayName}"); // --- helpers ------------------------------------------------------- static DataBinding ToTarget(string path) => new DataBinding { dataSourcePath = new PropertyPath(path), bindingMode = BindingMode.ToTarget // read-only UI: never write back to gameplay }; static T Require<T>(VisualElement root, string name) where T : VisualElement { var e = root.Q<T>(name); if (e == null) throw new System.InvalidOperationException(quot;UXML is missing {typeof(T).Name} named '{name}'"); return e; } } ``` Explanations: - `root.dataSource = stats` sets the data source for the hierarchy; each binding's `dataSourcePath` is resolved relative to it. A `MonoBehaviour` works as a source; any C# object does. - Binding IDs (`"value"`, `"highValue"`, `"text"`) are the **target property names** on the element. - `Require<T>` fails loudly when UXML and code drift. Silent `null` refs are the most common UI Toolkit bug class. - Toast timer: we keep the scheduled item so a second toast cancels the first hide timer (otherwise the first timer would hide the second toast early). ## Step 4 - Scene wiring (HUD) 1. Create `GamePanelSettings` (see [[2.UI Toolkit Project Setup]]). 2. Empty GameObject `UI`. Child `Hud`: add **Panel Renderer**. Panel Settings = `GamePanelSettings`, Source Asset = `Hud.uxml`, Sort Order = 0. Add `HudController`; drag in `PlayerStats` and `Inventory` references. 3. Empty GameObject `Player` with `PlayerStats` and `Inventory`. Add `InventoryDebugGiver` somewhere and set its array of items. 4. Make two or three `ItemDefinition` assets (`Assets > Create > Inventory > Item Definition`) with icons. 5. Play. Right-click `PlayerStats` > **Damage 10**: the bar and text update. Press **G**: the toast appears and disappears. ## Step 5 - The inventory documents ### 5a. `InventoryRow.uxml` A template for one row. No `<Style>` here: the rows are instantiated inside the inventory document, whose sheets already apply to descendants. ```xml <ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="False"> <ui:VisualElement class="row"> <ui:VisualElement name="row-icon" class="row__icon" /> <ui:Label name="row-name" class="row__name" /> <ui:Label name="row-count" class="row__count" /> </ui:VisualElement> </ui:UXML> ``` ### 5b. `Inventory.uxml` ```xml <ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="False"> <Style src="project://database/Assets/UI/Shared/Theme.uss" /> <Style src="Inventory.uss" /> <ui:VisualElement name="inventory-root" class="theme inventory-root inventory-root--hidden"> <ui:VisualElement name="inventory-window" class="panel inventory-window"> <ui:VisualElement class="inventory-window__header"> <ui:Label text="INVENTORY" class="caption inventory-window__title" /> <ui:Button name="close-button" text="X" class="icon-button" /> </ui:VisualElement> <ui:VisualElement class="inventory-window__body"> <ui:ListView name="item-list" class="item-list" fixed-item-height="48" selection-type="Single" show-alternating-row-backgrounds="ContentOnly" /> <ui:VisualElement class="detail"> <ui:Label name="detail-name" class="detail__name" /> <ui:Label name="detail-text" class="detail__text" /> <ui:Button name="drop-button" text="Drop one" class="detail__drop" /> </ui:VisualElement> </ui:VisualElement> </ui:VisualElement> </ui:VisualElement> </ui:UXML> ``` The root is a full-screen dimmer. It keeps the default picking mode on purpose: while the inventory is open it should block clicks reaching the world. When closed it is `display: none`, so it blocks nothing. ### 5c. `Inventory.uss` ```css .inventory-root { position: absolute; left: 0; top: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); align-items: center; justify-content: center; } .inventory-root--hidden { display: none; } /* Closed state of the window; --open is added a frame later so the transition plays */ .inventory-window { width: 720px; height: 460px; opacity: 0; translate: 0 24px; transition-property: opacity, translate; transition-duration: 0.18s; transition-timing-function: ease-out; } .inventory-window--open { opacity: 1; translate: 0 0; } .inventory-window__header { flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8px; } .inventory-window__body { flex-direction: row; flex-grow: 1; } .icon-button { width: 28px; height: 28px; margin: 0; } .item-list { flex-grow: 1; margin-right: 12px; border-width: 1px; border-color: var(--color-border); border-radius: 4px; } .row { flex-direction: row; align-items: center; height: 48px; padding-left: 8px; padding-right: 12px; } .row__icon { width: 32px; height: 32px; margin-right: 12px; -unity-background-scale-mode: scale-to-fit; } .row__name { flex-grow: 1; } .row__count { color: var(--color-muted); } .detail { width: 220px; } .detail__name { font-size: 20px; -unity-font-style: bold; margin-bottom: 8px; } .detail__text { flex-grow: 1; white-space: normal; color: var(--color-muted); } .detail__drop { margin: 0; } ``` Notes: - `white-space: normal` lets long descriptions wrap. Without it a `Label` stays on one line. - The ListView's own selection and hover look comes from the theme (`.unity-collection-view__item--selected`). Override these classes in your sheet to restyle. ## Step 6 - `InventoryScreenController.cs` ```csharp using UnityEngine; using UnityEngine.Events; using UnityEngine.InputSystem; using UnityEngine.UIElements; [RequireComponent(typeof(PanelRenderer))] public class InventoryScreenController : MonoBehaviour { const string HiddenClass = "inventory-root--hidden"; const string OpenClass = "inventory-window--open"; [SerializeField] Inventory inventory; [SerializeField] VisualTreeAsset rowTemplate; [SerializeField] bool manageCursor = true; [SerializeField] UnityEvent<bool> onVisibilityChanged; // hook look/move scripts here // Cached child references so BindRow never calls Q() while scrolling sealed class RowRefs { public VisualElement icon; public Label name; public Label count; } PanelRenderer panelRenderer; int loadedVersion = -1; VisualElement rootEl, window; ListView list; Label detailName, detailText; Button dropButton; IVisualElementScheduledItem hideTimer; bool isOpen; void OnEnable() { panelRenderer = GetComponent<PanelRenderer>(); panelRenderer.RegisterUIReloadCallback(OnUIReload); inventory.Changed += OnInventoryChanged; } void OnDisable() { panelRenderer.UnregisterUIReloadCallback(OnUIReload); inventory.Changed -= OnInventoryChanged; } void Update() { var kb = Keyboard.current; if (kb == null) return; if (kb.tabKey.wasPressedThisFrame) { if (isOpen) Close(); else Open(); } else if (isOpen && kb.escapeKey.wasPressedThisFrame) Close(); } void OnUIReload(PanelRenderer renderer, VisualElement root, int version) { if (version == loadedVersion) return; loadedVersion = version; rootEl = Require<VisualElement>(root, "inventory-root"); window = Require<VisualElement>(root, "inventory-window"); list = Require<ListView>(root, "item-list"); detailName = Require<Label>(root, "detail-name"); detailText = Require<Label>(root, "detail-text"); dropButton = Require<Button>(root, "drop-button"); Require<Button>(root, "close-button").clicked += Close; dropButton.clicked += OnDropClicked; list.makeItem = MakeRow; list.bindItem = BindRow; list.fixedItemHeight = 48; // virtualization: fixed height rows list.selectionType = SelectionType.Single; list.itemsSource = inventory.Slots; // an IList: ListView reads Count + indexer list.selectionChanged += _ => UpdateDetails(); // The tree is fresh: it starts hidden (class set in UXML). Sync our state. isOpen = false; UpdateDetails(); } // ---- ListView callbacks ------------------------------------------- VisualElement MakeRow() { var row = rowTemplate.Instantiate(); // returns a TemplateContainer row.userData = new RowRefs { icon = row.Q<VisualElement>("row-icon"), name = row.Q<Label>("row-name"), count = row.Q<Label>("row-count"), }; return row; } void BindRow(VisualElement element, int index) { var refs = (RowRefs)element.userData; var slot = inventory.Slots[index]; refs.name.text = slot.item.displayName; refs.count.text = slot.count > 1 ? quot;x{slot.count}" : string.Empty; refs.icon.style.backgroundImage = slot.item.icon != null ? new StyleBackground(Background.FromSprite(slot.item.icon)) : new StyleBackground(StyleKeyword.None); } // ---- State --------------------------------------------------------- void OnInventoryChanged() { if (list == null) return; list.RefreshItems(); // rebind visible rows, handle count change if (list.selectedIndex >= inventory.Slots.Count) list.ClearSelection(); UpdateDetails(); } void UpdateDetails() { if (list == null) return; if (list.selectedItem is InventorySlot slot && slot.item != null) { detailName.text = slot.item.displayName; detailText.text = slot.item.description; dropButton.SetEnabled(true); } else { detailName.text = "-"; detailText.text = "Select an item."; dropButton.SetEnabled(false); } } void OnDropClicked() { if (list.selectedItem is InventorySlot slot) inventory.Remove(slot.item, 1); } public void Open() { if (isOpen || rootEl == null) return; isOpen = true; hideTimer?.Pause(); rootEl.RemoveFromClassList(HiddenClass); // display: flex list.RefreshItems(); UpdateDetails(); // Add the "open" class on the next scheduler tick so the browser-style // transition has a rendered "closed" frame to animate from. window.schedule.Execute(() => window.AddToClassList(OpenClass)); SetCursor(true); onVisibilityChanged?.Invoke(true); } public void Close() { if (!isOpen || rootEl == null) return; isOpen = false; window.RemoveFromClassList(OpenClass); // fade/slide out... hideTimer = rootEl.schedule .Execute(() => rootEl.AddToClassList(HiddenClass)) // ...then display: none .StartingIn(200); SetCursor(false); onVisibilityChanged?.Invoke(false); } void SetCursor(bool uiMode) { if (!manageCursor) return; Cursor.lockState = uiMode ? CursorLockMode.None : CursorLockMode.Locked; Cursor.visible = uiMode; } static T Require<T>(VisualElement root, string name) where T : VisualElement { var e = root.Q<T>(name); if (e == null) throw new System.InvalidOperationException(quot;UXML is missing {typeof(T).Name} named '{name}'"); return e; } } ``` Explanations worth reading: - **Why a second `PanelRenderer`**: the inventory is a self-contained document with its own lifecycle. Give it Sort Order 10 and keep it as a *sibling* of the HUD under the `UI` GameObject, so the two documents stay independent and each can be enabled/disabled on its own. - **`RowRefs` in `userData`**: `ListView` recycles row elements. `makeItem` runs rarely; `bindItem` runs constantly while scrolling. Caching the child lookups avoids `Q()` calls on the hot path. - **Two-phase open**: removing `--hidden` makes the window exist at `opacity: 0`; the scheduled add of `--open` starts the USS transition. Doing both in the same frame skips the transition because there was no resolved "from" style. - **Two-phase close**: remove `--open` (animates out), then hide after the transition duration. The scheduled `Pause()` in `Open()` cancels a pending hide if the player reopens quickly. - `manageCursor` assumes a locked-cursor first-person game. Turn it off for point-and-click. ## Step 7 - Scene wiring (inventory) and test 1. Child of `UI`: `Inventory` GameObject, **Panel Renderer** with `GamePanelSettings` (same asset as the HUD: they share one panel), Source Asset = `Inventory.uxml`, **Sort Order = 10**. 2. Add `InventoryScreenController`; assign `Inventory` (on `Player`) and `Row Template` = `InventoryRow.uxml`. 3. Play. Press **G** a few times, **Tab** to open, click a row, press **Drop one**. 4. Open the Debugger on the Game view. Select a row; inspect which classes and styles were applied, and how the transition class toggles. ## Step 8 - Exercises - Add `ProgressBar` bindings for stamina with a converter (see [[4.Data Binding and Custom Controls]]). - Replace the health `ProgressBar` with the `ResourceBar` custom control from [[4.Data Binding and Custom Controls]]. - Add gamepad navigation: make rows and buttons focusable and handle `NavigationCancelEvent` on the inventory root to call `Close()`. - Add a "Use" button that raises an event on `Inventory`; keep gameplay out of the controller. Next: [[4.Data Binding and Custom Controls]].