UICueBinder binds UI events to payoffs — a transition sequence, a sound, a one-shot UIRender particle burst — with zero C# at the call site. You author rows of selector → trigger → payoff in the inspector (or share them as profile assets), and the binder plays the right payoff whenever a matching element fires the trigger.

Luna's juice content was already asset-driven — sequence assets, attractor motion presets, reactions assets. The triggerwhen something plays — still required a programmer: btn.clicked += … for every claim button, every tab, every hover. UI Cue generalizes the trigger half.

Quick start

  1. Add UICueBinder to the GameObject that holds the screen's PanelRenderer (it requires one).
  2. Add a row: Selector .claim-btn, Trigger Click, and drop a TransitionSequenceAsset + AudioClip into the payoff.
  3. Enter play mode and click. Every element matching .claim-btn — including ones inside ListView rows — now punches and clinks.

No code, no per-button wiring, and elements created after setup work too.

How it works: event delegation

The binder registers one callback per trigger type on the panel root. When an event dispatches, it tests evt.target and its ancestors against each row's compiled selector and fires the payoff on match — the same pattern the web uses for dynamic content.

This matters for one reason above all: recycled elements just work. ListView and GridView rebind pooled rows without detaching them, so any system that registers callbacks per element at setup time silently misses rebound rows. Delegation matches at dispatch time instead — there is nothing to miss and nothing to leak.

The cost is a selector match per dispatched event × row count, which is negligible for discrete events (click, focus, change). That is also why there is deliberately no pointer-move or drag trigger — per-frame matching is the wrong tool.

Triggers

TriggerFires onNotes
Clickclick (press + release on the same element)the workhorse
PointerDown / PointerUppress / releasepress feel
PointerEnter / PointerLeavepointer entering / leaving a matched elementsee the hover note below
FocusIn / FocusOutkeyboard/gamepad focus gained / lostfocus parity for hover payoffs
ValueChangeda control's value changesgated by ValueFilter (bool) / ValueStep (numeric); see below
ScreenEnterpanel (re)loadentrance juice; supports StaggerDelay
CustomUICue.Fire(element, key) from codethe game-logic bridge

Hover note. UI Toolkit enter/leave events don't bubble — they fire once per entered element — so for PointerEnter/PointerLeave the selector must match the entered element itself, not an ancestor. Every other trigger also matches ancestors: a Click row with selector .card fires when any child of the card is clicked.

ValueChanged. UI Toolkit sends typed ChangeEvent<T>s; the binder listens for all four control value types — bool (Toggle, RadioButton), int (SliderInt, RadioButtonGroup, IntegerField), float (Slider), string (DropdownField, TextField). Two gates turn continuous streams into discrete cues:

  • ValueFilter (bool rows): fire on any change, only on check, or only on uncheck — two rows on one selector give check-pop vs uncheck-thud.
  • ValueStep (numeric rows): fire only when the value crosses a multiple of the step. ValueStep: 0.1 on a 0–1 slider ticks at 0.1, 0.2, … 1.0 like a notched dial. Steps are raw values (a 0–100 bar wants 10), and a fast drag that jumps several steps in one event fires once, so a full-range fling can't spam sounds or render slots.

An ungated row (filter Any, step 0, no cooldown) warns once at load: sliders change every frame of a drag and text fields fire per keystroke, so either gate it or set a small Cooldown if continuous firing is intentional.

Row reference

FieldMeaning
SelectorCSS-style selector matched against the event target and its ancestors — .claim-btn, #MailButton, Button.primary
Triggerone of the triggers above
CustomKeyonly for Custom: the key passed to UICue.Fire
ValueFilteronly for ValueChanged on bool controls: Any / True (check only) / False (uncheck only)
ValueSteponly for ValueChanged on numeric controls: fire when the value crosses a multiple of this step, in raw values. 0 = every change
ApplyToSelectoroptional closest-ancestor selector to play the payoff on — click the button, punch the card. Empty = the matched element itself
Payoffthe bundle below
ReplayRestart (default) stops the row's running sequence on that element and replays from the top; IgnoreWhilePlaying skips fires until it finishes
Cooldownminimum seconds between fires per target element (unscaled time). Spam guard for punchy payoffs
StaggerDelayonly for ScreenEnter: seconds × match index, for cascade-in feels

Payoff bundle

FieldMeaning
SequenceTransitionSequenceAsset played on the target
Sound + PitchJitterclip routed through LunaUIManager.AudioHandler, with optional ± random pitch per play
Vfx + VfxSize / VfxAnchor / VfxOffsetone-shot ParticleSystem prefab via UIRenderManager.PlayBurst

All three channels are optional and fire together at the moment of the event. A row with only Sound is a click-sfx rule; a row with only Sequence is pure motion. The shape deliberately mirrors UIAttractorDefaultReactionsSO so the two systems feel like one family.

Selector syntax

The same dialect as every Luna ElementSelector: #Name, .class, Type, *, compounds (Button.primary), descendant (.card .icon), child (.list > .row), and comma unions. No pseudo-classes or attribute selectors.

Custom cues — the game-logic bridge

Game code rarely knows what should play; it knows what happened. Fire the semantic fact and let rows own everything else:

csharp
UICue.Fire(claimButton, "quest.claimed");

Row: Trigger = Custom, CustomKey = "quest.claimed". The event bubbles through the same delegation path, so the selector can match the fired element or any ancestor. This is also the intended path for dynamic appears — chat bubbles, spawned rows — where the code creating the element knows the moment it becomes meaningful.

Profiles

UICueProfileSO (Create → Luna → UI Cue → Profile) holds a reusable row list — "app-wide button feel" lives here once instead of per screen. A binder takes any number of profiles plus its own inline rows, and precedence is additive: every matching row fires, nothing overrides. Juice composes.

The recommended split: generic UI feel → profiles; screen-specific payoffs → inline rows; attractor lifecycle → reactions assets; everything else → code.

ScreenEnter

On panel load (and UXML hot-reload) the binder runs one query per ScreenEnter row and fires the payoff on every match, StaggerDelay seconds apart in match order. An empty selector means the panel root itself.

Scope it right: ScreenEnter fires when the panel tree loads — not on every navigation push of a pooled view. For per-push entrance stagger on UIViewComponent screens, use Stagger Entry (its FadeInStart trigger re-fires on every push). ScreenEnter suits HUDs, standalone panels, and screens whose tree load is their appearance.

Budget & replay safety

  • One active sequence player per element. Two rows animating the same element preempt each other (newest wins) — same rule as everywhere in Luna. Split targets with ApplyToSelector or a wrapper element when two payoffs must overlap.
  • Keep payoffs self-contained, or hold-paired on the same pose. A sequence that returns to rest (RestoreOnEnd) always composes. A hold pair works too — hover-in (Persist) matched by hover-out on the same property. What does not compose is interleaving other rows that animate the same property between a hold pair (e.g. recreating press-and-hold between hover-in and hover-out): the later restore captures a mid-hold value and the element settles off-rest. Full press+hover feel is Interaction Juice's job — its single driver owns all four slots.
  • Vfx is a one-shot burst. Each concurrent play holds a UIRender slot (pool default 3, max 10) until the particles die. Use one-shot prefabs — a looping prefab holds its slot for the full 5 s auto-release timeout. Cooldown and IgnoreWhilePlaying are the levers. Requires a UIRenderManager in the scene; the binder warns and skips otherwise.
  • Sound soft-fails silent when no LunaUIManager/AudioHandler exists.
  • Payoffs on elements detached from a panel are skipped.

Debugging

Enable _debugTrace on the binder to log every match:

[UICueBinder] Home: ButtonFeel[2] '.tab-btn' Click matched 'QuestsTab' → play on 'QuestsTab' seq:TabPunch sfx:click_soft vfx:-

Rows that can never fire — an empty selector on a non-ScreenEnter trigger, a Custom row without a key — are warned about once at load, not silently dropped.

Limitations

  • No pointer-move / drag triggers — per-frame selector matching is out of scope by design (ValueChanged + ValueStep covers the slider case discretely).
  • Ambient/looping VFX stay component-driven (PlayAmbient) — a fire-and-forget row can't own a lifetime.
  • Payoffs fire at the moment of the event; timeline composition ("punch, wait 0.3, then burst") belongs inside the sequence asset.

Demo

The UI Cue showcase scene (Components/Juice/UICue in the imported Showcase sample) wires every trigger from Inspector rows: hover + click-pop button feel from a shared CueButtonFeel profile, an ApplyToSelector card punch, a toggle with check/uncheck ValueFilter rows, a slider ticking at ValueStep: 0.1 notches, a dropdown pick-pop, five ScreenEnter chips staggering in, and a reward chest where the scene's only line of gameplay code — UICue.Fire(chest, "demo.reward") — triggers two additive rows: a punch + sfx and a SparkleBurst via UIRender.

See also

  • Transition Animation — authoring the sequence assets rows play
  • Interaction Juice — per-element press/hover manipulator with slot retargeting
  • Stagger Entry — entrance stagger tied to view push, not panel load
  • UI Attractor — flying-particle rewards; its reactions asset is the same payoff shape
  • Source: Runtime/Scripts/UICue/UICueBinder.cs, UICueRow.cs, UICueProfileSO.cs, UICueEvent.cs

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL