Skip to content

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 notes ui-root-canvas-mounting, figma-to-unity-pipeline, figma-adaptive-ugui-layout.

0. TL;DR — the rules that bite

  1. One shared UIRoot canvas (ScreenSpaceOverlay), built at runtime in RaidBootstrap.BuildUIRoot(). Every screen view nests under it.
  2. Views are hard-linked, not found. Every child component + sprite is a [SerializeField] reference wired in the prefab. NO GetComponent*/FindDeep/ Find-by-name at runtime, NO loading sprites from the project.
  3. 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) — matching WinScreenView. Setting stretch/scale-1 BREAKS prefab-stage scaling (see §4).
  4. Edit prefabs via PrefabUtility.LoadPrefabContentsSaveAsPrefabAssetUnloadPrefabContents. NEVER OpenPrefab from a script — the prefab stage auto-saves and will bake mistakes (and driven-zero transforms) into the asset.
  5. Views fill the screen via UIMount.FillParent at 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 Zenject MonoBehaviour) 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 + a Render(vm) that pushes data into them, and event 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
- Overlay, not Camera. Screen-space UI that never composites with world content is ScreenSpaceOverlay. The whole project is Overlay (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 — a LoadPrefabContents+SaveAsPrefabAsset script save, or a prefab-stage auto-save with a 0-size Game view. Fix by copying WinScreenView'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 via SerializedObject inside a LoadPrefabContents tree. 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. Image collides with a namespace in RunCommand scripts — alias using 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 blocksLayoutElement with explicit preferredHeight. - Variable block → its own VLG, no LayoutElement (parent reads its preferred height). - BackgroundLayoutElement.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 via GameConfig.rarityProjectInstaller). Set colour: SetDef.color (tints the item name — wired in InventoryController.BuildCardInventoryItemCardVM.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.