Luna's first-class navigation primitive. Use LunaNavigationHost when your shell needs a push/pop stack of screens — detail overlays, modal popups, drill-down inspectors — with typed contexts, automatic Esc-to-back, and parent visibility management. Pair with LunaTabHost for top-level tab routing.
Two navigation models, pick the right one. Luna ships both because they fit different shell shapes. Prefab-spawned modals (the older model —
UIPrefabLoaderString.Instantiate(key)returns a GameObject with its ownUIDocument+UIViewComponent, Esc closes via_addDefaultEscapeAction) are great when each modal is a separate, fully-isolated view.LunaNavigationHost(this page) is for single-shell scenes where the panel is one big UIDocument and every navigable destination is aVisualElementsubtree cloned into a container slot. The Showcase sample uses the latter; many game UIs mix both.
using CupkekGames.Luna.Navigation;
// In your shell controller's Awake:
public class GameShell : UIViewComponent {
[SerializeField] VisualTreeAsset _heroDetailVta; // VTA destination
[SerializeField] GameObject _confirmModalGo; // in-scene prefab destination
LunaNavigationHost _nav;
protected override void Awake() {
base.Awake();
var detailOverlay = ParentElement.Q<VisualElement>("DetailOverlay");
_nav = new LunaNavigationHost(detailOverlay);
// VTA-cloned destination — a plain INavigationScreen class created
// fresh per push.
_nav.Register(new VtaDestination<HeroDetailContext>(
_heroDetailVta,
() => new HeroDetailScreen()));
// In-scene prefab destination — the modal MonoBehaviour implements
// INavigationScreen<Config> directly. Same component instance reused
// across pushes (host's single-instance guard handles "don't push
// twice").
_nav.Register(new InSceneDestination<ConfirmModalView.Config>(
_confirmModalGo,
() => _confirmModalGo.GetComponent<ConfirmModalView>()));
}
public void OpenHeroDetail(HeroCard card) =>
_nav.Push(new HeroDetailContext(card));
public void Confirm(string title, string message, Action onConfirm) =>
_nav.Push(new ConfirmModalView.Config { Title = title, Message = message, OnConfirm = onConfirm });
}The context — new HeroDetailContext(card) or new ConfirmModalView.Config { … } — is both the type-key for dispatch and the per-push payload. LunaNavigationHost looks up the registered destination for that type, runs its render strategy (clone VTA into the overlay, OR activate the in-scene GameObject), creates the screen instance via the factory, and calls screen.OnEnter(view, context, host) so the screen wires UXML to context before any animation runs.
See Two render strategies, two ways to be a screen below for the full pattern comparison.
| Type | Role |
|---|---|
LunaNavigationHost | The runtime stack manager. One per shell scene; takes a container VisualElement. |
INavigationDestination<TContext> | Bundles a render strategy + a screen factory for one route. |
INavigationScreen<TContext> | The screen's lifecycle contract — OnEnter(view, context, host) + OnExit(). |
INavigationRenderStrategy | Decouples "how do I appear/disappear" from the stack manager. Two ship in v1; consumers can add their own. |
VtaRenderStrategy | Clone a VisualTreeAsset into the container on push, remove it on pop. For sub-panel screens. |
VtaDestination<TContext> | Convenience destination bundling VtaRenderStrategy + a screen factory. |
InScenePrefabStrategy | Activate a pre-instantiated, disabled GameObject on push; fade out + deactivate on pop. For modal prefabs that live in the scene. |
InSceneDestination<TContext> | Convenience destination bundling InScenePrefabStrategy + a screen factory. |
Pick the strategy that matches how your destination's view comes into the world:
| If your destination is… | Strategy | Screen pattern |
|---|---|---|
A VisualTreeAsset cloned into a container slot (e.g. detail panels in a single-shell scene) | VtaRenderStrategy | Plain C# class implementing INavigationScreen<TContext>, created fresh per push by a factory closure. |
A MonoBehaviour already in the scene as a disabled GameObject (e.g. modal prefabs) | InScenePrefabStrategy | The MonoBehaviour itself implements INavigationScreen<TContext>. No wrapper class needed. |
Used by Showcase's detail panels (HeroDetail, Profile, Inventory, etc.). Each push clones a fresh VisualElement tree; the screen is short-lived state holding wiring for that one mount.
public class HeroDetailScreen : INavigationScreen<HeroDetailContext>
{
public void OnEnter(VisualElement root, HeroDetailContext ctx, LunaNavigationHost nav) {
root.Q<Label>("HeroName").text = ctx.Hero.Name;
root.Q<Button>("Back").clicked += nav.Pop;
// ... rest of UXML wiring
}
public void OnExit() { }
}
// Registration:
nav.Register(new VtaDestination<HeroDetailContext>(
_heroDetailVta,
() => new HeroDetailScreen())); // fresh instance per pushUsed by Showcase's 5 modals (Confirm, Auth, Input, LevelUp, MailDetail). The prefab is placed in the scene at design time as a disabled GameObject; its UIDocument + UIViewComponent already own the UXML wiring lifecycle. Adding two lifecycle methods makes the MonoBehaviour serve as its own navigation screen — no wrapper class:
public class ConfirmModalView : UIViewComponent, INavigationScreen<ConfirmModalView.Config>
{
public class Config {
public string Title;
public string Message;
public Action OnConfirm;
// ...
}
public void ApplyConfig(Config c) {
// wire UXML to config (existing method)
}
void INavigationScreen<Config>.OnEnter(VisualElement _, Config ctx, LunaNavigationHost nav) {
ApplyConfig(ctx);
}
void INavigationScreen<Config>.OnExit() { }
}
// Scene authoring: drag ConfirmModal.prefab into the scene, uncheck active.
// Add a [SerializeField] GameObject _confirmModalGo on the shell controller.
// Registration:
nav.Register(new InSceneDestination<ConfirmModalView.Config>(
_confirmModalGo,
() => _confirmModalGo.GetComponent<ConfirmModalView>()));The factory () => _confirmModalGo.GetComponent<ConfirmModalView>() returns the same MonoBehaviour instance every push — fine since the MB is persistent across pushes. The host's single-instance guard prevents double-pushing the same destination.
When to pick which: Pattern A for sub-panel content cloned out of a VisualTreeAsset; Pattern B for prefab-based modals that benefit from the persistent-GameObject lifecycle. A single shell can use both — Showcase does.
nav.Push(new HeroDetailContext(card))
1. Lookup destination dict by typeof(HeroDetailContext)
2. Single-instance guard: if HeroDetailContext is already on the stack, no-op
3. Hide previous top (display:none) unless new view is marked modal/sheet
4. strategy.Render(container) → returns mounted view
5. screen = destination.CreateScreen()
6. screen.OnEnter(view, context, this) — screen wires UXML to context
7. Push entry onto stack + register Esc handler via InputEscapeManager
8. Fire OnPushed event
[player presses Esc, or some screen calls nav.Pop()]
1. Top entry's OnExit() runs
2. strategy.Dismiss(view) — removes from hierarchy (VTA) or fades out (in-scene prefab)
3. InputEscapeManager.PopWithoutExecute(escapeKey) clears the Esc binding
4. Previous top's display:flex restored
5. Fire OnPopped eventThe host treats a destination's root as either replacing (default — hides parent) or overlaying (parent stays visible). Mark the UXML root with one of two classes to opt into overlay behavior:
<ui:VisualElement class="base-modal"> <!-- centered card over a dim backdrop -->
...
</ui:VisualElement>
<ui:VisualElement class="bottom-sheet"> <!-- slide-up sheet from below -->
...
</ui:VisualElement>When the host detects either class in the new view, it leaves the previous top's display: flex so the modal / sheet layers visibly over it. No code wiring — pure USS contract.
Push<TContext>(ctx) is a no-op if a destination for TContext is already somewhere on the stack. Use PushReplacingTopOfSameType<TContext>(ctx) for the "tap a second item in a list while the first is open — replace rather than stack" flow. Check nav.IsActive<TContext>() before pushing to handle this gracefully.
public class LunaNavigationHost {
public LunaNavigationHost(VisualElement container);
public int StackDepth { get; }
public event Action<Type> OnPushed;
public event Action<Type> OnPopped;
public void Register<TContext>(INavigationDestination<TContext> destination);
public bool IsActive<TContext>();
public void Push<TContext>(TContext context);
public void PushReplacingTopOfSameType<TContext>(TContext context);
public void Pop();
public void PopToRoot();
}LunaTabHostTab routing is orthogonal to push/pop. A typical shell uses both: one LunaNavigationHost for the detail-overlay stack + one LunaTabHost for the bottom-tab body.
var tabHost = new LunaTabHost(contentSlot) {
Mode = TabHostMode.CloneOnSwitch // default
};
tabHost.OnTabChanged += UpdateTabBarActive;
tabHost.Register("home", new VtaTabDestination(_homeTabVta,
onEnter: body => new HomeTab(_fxLib).Mount(body, _nav)));
tabHost.Register("heroes", new VtaTabDestination(_heroesTabVta,
onEnter: body => new HeroesTab().Mount(body, _nav)));
// ...
tabHost.SwitchTab("home");Tab classes are plain classes wired via registration closures — there's no ITabScreen interface to implement. The closure captures whatever dependencies the tab body needs (navigation host, FX library, etc.) and Mount is just a method name convention.
TabHostMode controls how tab bodies are treated across switches:
| Mode | Behavior | When to pick |
|---|---|---|
CloneOnSwitch (default) | Clone the destination's view fresh on every switch; discard the previous tab's view entirely. Tab state resets on switch. Cheap memory, expensive per switch. | Tab bodies are heavy (large lists); state should reset naturally. |
KeepMounted | Clone each tab's view once on first visit, keep it mounted thereafter (display: none / flex on switch). First switch pays the clone cost; subsequent switches are instant. Tab state (scroll position, form data) persists. | Settings panels with forms; inventory with per-tab filters that should persist. |
PreMountAll | Mount every registered tab's view at first SwitchTab regardless of which tab. After that, every switch is just a display toggle. Highest memory; zero per-switch work. | 3–5-tab shells where switching is the dominant interaction and latency matters. |
Settable at any time via tabHost.Mode = …, though changing modes mid-session may leave dangling mounted views — set at construction time and don't change for production use.
public class LunaTabHost {
public LunaTabHost(VisualElement contentSlot);
public TabHostMode Mode { get; set; }
public string ActiveTab { get; }
public int RegisteredTabCount { get; }
public event Action<string> OnTabChanged;
public void Register(string tabId, ITabDestination destination);
public void SwitchTab(string tabId);
public void Refresh(); // force-rebuild current tab
}If your destination needs to come into the world differently than VTA-clone (Addressables load, scene-baked prefab toggle, runtime construction), implement INavigationRenderStrategy:
public class AddressablesRenderStrategy : INavigationRenderStrategy {
readonly string _addressKey;
public AddressablesRenderStrategy(string key) { _addressKey = key; }
public VisualElement Render(VisualElement container) {
// Load a VisualTreeAsset via Addressables, clone into container.
// ...
}
public void Dismiss(VisualElement view) {
view.RemoveFromHierarchy();
// Release the Addressables handle.
}
}Plug it into a custom INavigationDestination<TContext> and register as normal.
These are intentional omissions; raise an issue if your game needs them:
Argument[] typeless payload — won't ship. C# generics + typed contexts are strictly better DX.LUNA_NAVIGATION_DESIGN.md Phases 4+ but not yet implemented.Runtime/Scripts/Navigation/ in the Luna packageSettings
Theme
Light
Contrast
Material
Dark
Dim
Material Dark
System
Sidebar(Light & Contrast only)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction