quot;Missing item {e.itemId} in save"); continue; } var inst = new ItemInstance(def); foreach (var a in e.affixes) { var st = db.Stat(a.stat); if (st) inst.rolledAffixes.Add(new ItemDefinition.Mod { stat = st, value = a.value, type = a.type }); } eq.Equip(inst); } } } ``` > [!warning] Asset renames > Stats are saved by asset `name`. If you rename `Strength.asset` after shipping, add a migration step (see the migration section in [[High Scores, Saves and Player Stats]]), or give `StatDefinition` an explicit `id` field like `ItemDefinition` has. ## 6.2 Character sheet with UI Toolkit This sheet uses a view-model plus runtime data binding, following the pattern in [[4.Data Binding and Custom Controls]]. Stat rows are generated from the stat list, so adding a new stat asset adds a row without any UI changes. This example uses `UIDocument`. On the `PanelRenderer` workflow, swap the component as described in [[2.UI Toolkit Project Setup]]. ```xml <!-- CharacterSheet.uxml --> <ui:UXML xmlns:ui="UnityEngine.UIElements"> <Style src="CharacterSheet.uss" /> <ui:VisualElement name="sheet" class="sheet"> <ui:Label name="class-level" class="sheet__title" /> <ui:ProgressBar name="xp-bar" low-value="0" high-value="1" /> <ui:Label name="points" class="sheet__points" /> <ui:ScrollView name="stat-list" /> </ui:VisualElement> </ui:UXML> ``` ```css /* CharacterSheet.uss */ .sheet { width: 360px; padding: 12px; background-color: rgba(10, 12, 20, 0.9); border-radius: 8px; } .sheet__title { font-size: 20px; -unity-font-style: bold; color: rgb(240, 220, 160); } .sheet__points { color: rgb(120, 220, 120); margin: 6px 0; } .stat-row { flex-direction: row; justify-content: space-between; align-items: center; height: 26px; } .stat-row__name { flex-grow: 1; color: rgb(210, 210, 210); } .stat-row__value { width: 80px; -unity-text-align: middle-right; color: white; } .stat-row__value--buffed { color: rgb(120, 200, 255); } .stat-row__plus { width: 24px; height: 22px; margin-left: 6px; } ``` ```csharp // CharacterSheetViewModel.cs using System; using Unity.Properties; using UnityEngine.UIElements; /// <summary>Bindable header data. Notifies UI Toolkit when properties change so bindings update without polling.</summary> public class CharacterSheetViewModel : INotifyBindablePropertyChanged { public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged; string _header; float _xp01; string _points; [CreateProperty] public string Header { get => _header; set { if (_header == value) return; _header = value; Notify(nameof(Header)); } } [CreateProperty] public float Xp01 { get => _xp01; set { if (_xp01 == value) return; _xp01 = value; Notify(nameof(Xp01)); } } [CreateProperty] public string Points { get => _points; set { if (_points == value) return; _points = value; Notify(nameof(Points)); } } void Notify(string property) => propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(property)); } ``` ```csharp // CharacterSheetView.cs using System.Collections.Generic; using Unity.Properties; using UnityEngine; using UnityEngine.UIElements; [RequireComponent(typeof(UIDocument))] public class CharacterSheetView : MonoBehaviour { [SerializeField] CharacterStats stats; [SerializeField] CharacterLevel level; readonly CharacterSheetViewModel _vm = new(); readonly Dictionary<Stat, (Label value, Button plus)> _rows = new(); void OnEnable() { var root = GetComponent<UIDocument>().rootVisualElement; root.dataSource = _vm; root.Q<Label>("class-level").SetBinding("text", new DataBinding { dataSourcePath = new PropertyPath(nameof(CharacterSheetViewModel.Header)), bindingMode = BindingMode.ToTarget }); root.Q<ProgressBar>("xp-bar").SetBinding("value", new DataBinding { dataSourcePath = new PropertyPath(nameof(CharacterSheetViewModel.Xp01)), bindingMode = BindingMode.ToTarget }); root.Q<Label>("points").SetBinding("text", new DataBinding { dataSourcePath = new PropertyPath(nameof(CharacterSheetViewModel.Points)), bindingMode = BindingMode.ToTarget }); BuildRows(root.Q<ScrollView>("stat-list")); stats.AnyStatChanged += OnStatChanged; level.XpChanged += OnXp; level.LeveledUp += OnLevel; RefreshHeader(); } void OnDisable() { stats.AnyStatChanged -= OnStatChanged; level.XpChanged -= OnXp; level.LeveledUp -= OnLevel; } void BuildRows(ScrollView list) { list.Clear(); _rows.Clear(); foreach (var stat in stats.All) { var row = new VisualElement(); row.AddToClassList("stat-row"); var name = new Label(stat.Definition.displayName); name.AddToClassList("stat-row__name"); name.tooltip = stat.Definition.description; var value = new Label(); value.AddToClassList("stat-row__value"); var plus = new Button { text = "+" }; plus.AddToClassList("stat-row__plus"); var captured = stat; plus.clicked += () => { level.SpendPoint(captured.Definition); RefreshHeader(); }; plus.style.display = stat.Definition.isDerived ? DisplayStyle.None : DisplayStyle.Flex; row.Add(name); row.Add(value); row.Add(plus); list.Add(row); _rows[stat] = (value, plus); UpdateRow(stat); } } void OnStatChanged(Stat s) { if (_rows.ContainsKey(s)) UpdateRow(s); } void OnXp(long total, long start, long next) => RefreshHeader(); void OnLevel(int _) => RefreshHeader(); void UpdateRow(Stat s) { var (value, _) = _rows[s]; value.text = s.Definition.Format(s.Value); value.EnableInClassList("stat-row__value--buffed", s.Value > s.BaseValue + 0.0001f); value.tooltip = BuildModifierTooltip(s); } static string BuildModifierTooltip(Stat s) { var sb = new System.Text.StringBuilder(quot;Base {s.BaseValue:0.##}"); foreach (var m in s.Modifiers) sb.Append(quot;\n{m.Type} {m.Value:+0.##;-0.##} ({(m.Source as Object)?.name ?? m.Source?.GetType().Name ?? "?"})"); return sb.ToString(); } void RefreshHeader() { _vm.Header = quot;{(stats.Class ? stats.Class.displayName : "Adventurer")} Lv {level.Level}"; _vm.Xp01 = level.Progress01(); _vm.Points = level.UnspentPoints > 0 ? quot;{level.UnspentPoints} points to spend" : ""; foreach (var kv in _rows) kv.Value.plus.SetEnabled(level.UnspentPoints > 0); } } ``` > [!note] Why a mix of binding and events? > The fixed header fields use data binding. The stat rows are created dynamically and updated from `Stat.Changed`, which is simpler than writing bindable wrappers for every row. If you prefer pure binding, bind a `ListView` whose `itemsSource` is a list of `StatRowViewModel`s (see [[4.Data Binding and Custom Controls]]). For a diegetic version, such as a character sheet on an in-world tablet or a VR wrist panel, reuse the same UXML on a world-space panel ([[6.Diegetic Terminal and Wrist Display]]).