Direct taps and UI component actions
An actor can handle a tap directly:
class TapTarget : Component {
void OnTap() {
Transform.Move(10, 0);
}
}OnTap() has zero arguments. Self is the tapped actor. It uses the existing pointer hit test, tap gesture recognition and component onGameEvent lifecycle. The compiled component's gameEvents metadata makes the actor tap eligible without an Interactable component or a Flow graph. An explicitly disabled Interactable still opts out. Disabled/detached components do not receive hooks; cancelled gestures and duplicate pointer releases do not produce another call. Existing Interactable events, interact/tap Flow events and tap reactions remain available.
Bind a button
- Select a button in the UI workspace.
- Under Button action, choose Component action.
- Choose an object in the current scene, then one of its public component actions.
- Set its arguments using the typed controls.
The picker uses the same component action catalog and field schemas as Flow, including built-in actions, public script actions, async actions and declared NetworkActions. It does not list private methods, lifecycle hooks, conditions or methods hidden from the action catalog. Entity/component arguments store actor IDs; asset arguments store asset IDs. Strings are literal values, not Flow expressions. Lists, records, options, maps, units and other supported structured values use the existing field editor. Editing a value is a UI-document undo command, including continuous control previews that commit as one edit.
A binding invokes exactly one action on pointer, keyboard or gamepad activation. While present, it suppresses the saved Event field. Clearing the binding by choosing Custom event restores that event route. A missing or disabled target, missing method, or wrong-scene binding never falls back to an old bridge graph. The inspector preserves unresolved references and reports them for repair; runtime diagnostics identify unavailable targets/actions.
Stored identity and dispatch
The optional button.props.action field is:
{
type: "componentAction",
sceneId: "scene_main",
objectId: "director",
component: "gameDirector",
method: "BeginRun",
params: { difficulty: 2 },
paramSchema: { difficulty: { type: "int" } }
}component is the registered definition ID, and method is the canonical key in definition.actions. The UI uses componentCatalogGroups() and flowNodeFromComponentItem() to obtain them. The editor's semantic rename preserves component and method identity using [ComponentId] and [FlowId]; manual source renames must retain those IDs too. Object and scene display-name changes leave IDs unchanged. Saved public parameter names are part of the action contract.
paramSchema is a detached, serializable field-schema snapshot. Reference walkers use it before scripts compile, including nested assets under members named id, src or source; ordinary strings and actor IDs remain distinct from assets. Scene serialization, both project stores, project packages, UI template copy/paste, and network scene snapshots retain the binding. Project import and scene duplication translate scene IDs; the UI reference walker can also translate actor IDs and typed actor arguments. Copying a template to a different scene requires choosing its new target explicitly.
createUIRuntime accepts getSceneId and getActionObject. Both composition roots resolve a binding against the current Play world at activation time. No authoring Thing or previously active scene instance is retained. dispatchUIAction() checks scope, target lifetime, enabled state and public metadata, then calls ComponentHost.run(). That path owns typed coercion, diagnostics, tracing, async task policy and the compiled NetworkAction request. Network replicas remain read-only for direct UI activation, consistent with the existing button policy; host action results travel through the normal ordered snapshots/patches.
Pause behavior
Direct taps and UI calls check isThingTimePaused(target) immediately. A paused gameplay target does not receive input even before the next simulation frame. Put the Director in timeGroup: "ui" so its button action can call Time.ResumeGroup("gameplay") while gameplay is paused. Pausing ui also blocks that Director's direct button calls. This input gate does not prohibit explicit script-to-script calls or committed component events such as Health changes.
Verification
src/game/player/start-player.direct-actions.test.js: actual player pointer route, zero-argument hook, typed dispatch, duplicate prevention, legacy event fallback, disabled actors, stable rename references, replica policy and pause/resume.src/app/direct-actions.test.js: mounted DOM picker/typed fields, document undo and redo, store reopen, isolated Play target and Stop restoration.src/game/ui-runtime/action-*.test.jsandmodel/action.test.js: canonical network request dispatch, scene snapshots, reference walkers, serialization, IndexedDB and asset package round trips.tests/server-ui-actions.test.cjs: authenticated server writes and disk restart using a throwaway data directory.tests/ui-direct-actions-browser.mjs: isolated real-browser inspector fixture.
Compiler changes are owned separately: tap: "OnTap" in OPAL_EVENT_HOOKS, OnTap: [] in OPAL_HOOKS, compiler gameEvents metadata, and a zero-argument tap branch in compiled onGameEvent. This implementation adds no script grammar.