Unity UI Workflow (Shadow Raiders)¶
Read this before touching any UI. It captures how UGUI is structured in this project, the non-obvious rules that will bite you, and where to resume. Pairs with
PROJECT_CONTEXT.md(design source of truth) and the memory notesui-root-canvas-mounting,figma-to-unity-pipeline,figma-adaptive-ugui-layout.
0. TL;DR — the rules that bite¶
- One shared UIRoot canvas (ScreenSpaceOverlay), built at runtime in
RaidBootstrap.BuildUIRoot(). Every screen view nests under it. - Views are hard-linked, not found. Every child component + sprite is a
[SerializeField]reference wired in the prefab. NOGetComponent*/FindDeep/Find-by-name at runtime, NO loading sprites from the project. - Never hand-set the root Canvas RectTransform. Its serialized state is a driven
placeholder —
anchors (0,0)/(0,0),pivot (0,0),scale (0,0,0)— matchingWinScreenView. Setting stretch/scale-1 BREAKS prefab-stage scaling (see §4). - Edit prefabs via
PrefabUtility.LoadPrefabContents→SaveAsPrefabAsset→UnloadPrefabContents. NEVEROpenPrefabfrom a script — the prefab stage auto-saves and will bake mistakes (and driven-zero transforms) into the asset. - Views fill the screen via
UIMount.FillParentat mount, not via prefab anchors.
1. MVC pattern¶
Each screen is Controller + View (+ ViewModel). Look at
Assets/Scripts/UI/{Lobby,Inventory,InGame}/.
- Controller (
*Controller.cs, a ZenjectMonoBehaviour) owns lifecycle: it instantiates the View prefab (_container.InstantiatePrefabForComponent<TView>()), parents it under UIRoot, sets sort order, subscribes to the View's click events, and feeds it a ViewModel. It talks to services/save/config; the View never does. - View (
*View.cs) is pure presentation — serialized refs + aRender(vm)that pushes data into them, andevent Actions it raises on interaction. No DI, no services, no lookups. Example:LobbyView,InventoryView,InGameView. - ViewModel (
*ViewModel.cs/*VM.cs) is a plain data snapshot the controller fills. Example:InventoryItemCardVM,InventoryViewModel.
2. The shared UIRoot canvas (RaidBootstrap.BuildUIRoot)¶
UIRoot (built once per session)
• Canvas → ScreenSpaceOverlay, sortingOrder 0
• CanvasScaler → ScaleWithScreenSize, referenceResolution 1290×2796, match = 1 (height)
• GraphicRaycaster + one EventSystem (new Input System module)
└─ each screen View (nested) — see §3
FloaterLayer, MetaHud,
MapController). Camera mode was tried and reverted — it wasn't the cause of any bug.
- Controllers mount their view and layer it via a nested Canvas with overrideSorting:
HUD 100, lobby 100, inventory 150, equipment popup 160, loadout 200.
3. How a view mounts (and why FillParent exists)¶
A view prefab's ROOT has its own Canvas + CanvasScaler + CanvasGroup. Those exist
so the prefab previews + scales correctly when opened standalone in the prefab stage.
At runtime the view is a nested canvas, so:
- A nested Canvas ignores its own renderMode AND CanvasScaler — only UIRoot's apply.
- A nested Canvas is not auto-stretched to fill its parent; its RectTransform keeps its
serialized (driven-placeholder) size, which only equals the screen at exactly the
reference resolution.
So each controller calls ShadowRaiders.UI.Common.UIMount.FillParent(view) right after
SetParent(uiRoot). It sets anchors 0–1, zero offsets, scale 1 → the view fills UIRoot at
every resolution. (UIMount.FillParent is the ONLY place that stretches a view.)
4. The root-canvas driven state (do not "fix" it)¶
When you inspect a screen prefab root via LoadPrefabContents, you'll see
anchors (0,0)/(0,0), pivot (0,0), sizeDelta 0, localScale (0,0,0). This is the
correct driven placeholder of a root Overlay Canvas — the Canvas overwrites scale live
from the CanvasScaler, which is what makes the prefab scale with the Game-view resolution
in prefab mode. WinScreenView is the reference; all views were normalized to match it.
- Hand-setting stretch anchors +
scale (1,1,1)turns it into fixed, non-driven values → the CanvasScaler no longer scales it → the prefab renders under the prefab-stage environment canvas as a non-root child and only repositions on resolution change. (This exact mistake was made and reverted mid-refactor.) - It gets baked WRONG (to
(0,0,0)/zero-size showing nothing) only when a prefab is saved without a valid screen — aLoadPrefabContents+SaveAsPrefabAssetscript save, or a prefab-stage auto-save with a 0-size Game view. Fix by copyingWinScreenView's root values back. Edit these prefabs in prefab mode with a normal Game view.
5. Hard-linked components (the current standard)¶
Every runtime view references its children/sprites through [SerializeField] fields wired
in the prefab. There is no runtime F()/FindDeep/GetComponentInChildren/
UIButton.Attach, and no Resources.Load/AssetDatabase sprite loading. Patterns:
- Fixed repeats (8 equip slots, 4 stat cells) → a serialized array of the typed
sub-view (InventoryView.slots[8], stats[4]).
- Dynamic repeats (stash cells, passive rows, stage pips, currency chips — count unknown
at author time) → one serialized typed template ref; spawn with
Instantiate(typedTemplate) which returns the typed component (NO GetComponent). The
template's own internals are hard-linked; only the count is runtime. The template is
SetActive(false) in Awake.
- Buttons use the shared UIButton (over UGUI Button). Every clickable node has an
authored Button (transition None, targetGraphic = its raycast Image) + UIButton,
referenced by the view. To author one in a prefab: AddComponent<Button>() (transition
None, targetGraphic = the node's/child's Image with raycastTarget=true) + AddComponent<UIButton>().
- Lazy colour capture: a view that remembers an authored "normal" colour (e.g.
InventoryStatView, InventoryPassiveSkillView, InventoryItemCardView) must capture it
lazily on first Render, NOT in Awake. Awake doesn't run while a row is inactive
in the hierarchy, leaving the colour at (0,0,0,0) (black-transparent). Guard with a
bool _captured.
Wiring tooling (throwaway — Assets/Scripts/Editor/UIWiring/)¶
UIWire.Set(component, "field", value)/UIWire.SetArray(...)— set a serialized object-ref by field name viaSerializedObjectinside aLoadPrefabContentstree. Supports dotted paths for nested struct fields ("raidSlot.rect").UIWireVerify.AssertAllRefs(prefabPath, componentType)— logs every NULL object-ref on a component (0 = fully wired; some fields are intentionally optional, so eyeball the list).- Run from
Unity_RunCommand.Imagecollides with a namespace in RunCommand scripts — aliasusing UImage = UnityEngine.UI.Image;. Delete this folder once the missing-UI work is play-tested and stable.
6. Adaptive layout (panels that resize to content)¶
See figma-adaptive-ugui-layout memory + DebriefingWin/InventoryItemCard. To make a
panel grow/shrink as sections toggle:
- Put a VerticalLayoutGroup(childControlHeight=true) + ContentSizeFitter(vertical=PreferredSize)
spine on the panel.
- Fixed blocks → LayoutElement with explicit preferredHeight.
- Variable block → its own VLG, no LayoutElement (parent reads its preferred height).
- Background → LayoutElement.ignoreLayout=true, stretched behind.
- Toggle a section with SetActive(false) → the spine recomputes → panel resizes.
- A stretch anchor under a LayoutGroup collapses the cross-axis — give layout children
non-stretch top-center anchors with explicit sizeDelta.
7. Figma → Unity pipeline¶
/figma-to-unity (see figma-to-unity-pipeline memory + tools/figma_unity_pipeline/AGENT_PIPELINE.md).
Figma selection → sprites (extract_sprite_candidates.py/reconcile_sprites.py) → UI-IR JSON
(figma_to_ir.py) → validate_uiir.py (must exit 0) → Unity importer
(Tools → Figma to Unity → Importer or FigmaToUnityImporter.GenerateFromIR headless) →
Assets/Prefabs/UI/<ScreenId>.prefab. The importer authors a ScreenSpaceOverlay root Canvas
with the driven placeholder transform (does NOT hand-set root anchors). Imported screens are
then restructured + hard-linked by hand (adaptive spine, arrays, typed templates, authored
buttons) — a re-import clobbers that, so treat the import as a one-time scaffold.
8. Reusable leaf views + colours¶
Shared/CurrencyView(icon + number),Shared/TopPanelView(avatar/name/power + CurrencyView row),Nav/NavBarView(bottom nav, active/inactive slot styling),Inventory/InventoryItemView(gear cell — rarity frame + icon + level + overlays),Inventory/InventoryStatView(stat chip- ↑/↓ arrow),
Inventory/InventoryPassiveSkillView(perk/set row),Lobby/StageIndicator(floor pip),Common/UIButton(button widget + click event + optional badge). - Rarity colour:
ShadowRaiders.Loot.Rarity.Color(rarity)(4 tiers, config-tunable viaGameConfig.rarity→ProjectInstaller). Set colour:SetDef.color(tints the item name — wired inInventoryController.BuildCard→InventoryItemCardVM.NameColor).
9. Existing screens¶
| Screen | View | Controller | Notes |
|---|---|---|---|
| In-raid HUD | InGame/InGameView |
InGame/InGameViewController |
timer/announce/heal/goals/minimap slot; hosts the minimap |
| Lobby | Lobby/LobbyView |
Lobby/LobbyController |
biome/stage select, TopPanel + NavBar, StageIndicator pips |
| Inventory (equipment) | Inventory/InventoryView |
Inventory/InventoryController |
8 equip slots, 4 stats, stash grid (scroll), TopPanel + NavBar |
| Item-detail popup | Inventory/InventoryEquipmentView (2× InventoryItemCardView) |
(InventoryController) | Tap-To-Close node is BackgroundTapToClose |
| Minimap (in-raid) | Map/MapView |
Map/MapController |
RenderTexture-baked map (MapTextureBaker), own Overlay canvas |
| Result win | WinScreenView.prefab |
— | prefab only, no script yet |
| Result lose | LoseScreenView.prefab |
— | prefab only, no script yet |
| Settings | SettingsView.prefab |
— | prefab only; needs slider/toggle leaf components |
| Full map | IngameMap.prefab (FTU import) |
— | no FullMapView yet (minimap tap should open it) |
Also imported but not wired: DebriefingWin.prefab (win/extract result design), SettingsView (refactored).
10. Where we left off / next¶
The hard-linking refactor is complete for the 13 in-use views (all verified 0 null refs).
Next work is the missing screens — see docs/ui/MISSING_UI_IMPLEMENTATION.md:
FullMapView, LoseScreenView, WinScreenView, SettingsView (+ their leaf components, e.g. a
fillable progress/slider bar for Settings). Grounded in the web proto (game.js) and the
existing prefab designs.