Events & Input: Gesture Lab
A 2 × 2 grid of input zones, each demonstrating a distinct event
pattern. The cyan TapZone counts GestureType.Tap events; the orange
DragZone follows your finger via GestureType.Drag and snaps back on
GestureType.Drop; the purple HoldZone reports GestureType.LongTap
duration and fires throttled HapticsModule.triggerVibration while
held; the green RawZone counts raw EventType.Touch Began events. A
status line at the top mirrors the most recent event so you can read
which emitter fired and on which zone.

What you'll build
- Four 250 × 250 colored Screen Image "zones" — each parents a 2D Text child label that displays the zone's running state (count, ms, etc.).
- A
StatusTextat the top that mirrors the most recent input event (Touch.Began, GestureType.Tap, LongTap, Drag start, Drop). - A
GameControllerempty SceneObject hostingGestureLab— one script that subscribes to two separate emitters:EventManager.getGlobalEmitter()for rawTouchevents.EventManager.getGestureEmitter()for semanticTap,LongTap,Drag,Dropgestures.
- A scale-bounce on the TapZone, an offset-tracking drag on the DragZone (with snap-back animation on Drop), throttled haptic vibration during a long-press, and a hit-tested raw Touch counter on the RawZone.
Touch vs Gesture — when to use which
The two emitters serve different needs and are easy to confuse:
| Emitter | Fires for | Use it when |
|---|---|---|
getGlobalEmitter() + EventType.Touch | Every raw finger phase: Began, Moved, Ended, Canceled. One event per phase per finger. | You need the precise per-frame finger data — drawing apps, multi-finger games, custom drag implementations, hit-testing buttons (with TouchUtils.isScreenPointOnImage). |
getGestureEmitter() + GestureType.{Tap,LongTap,Drag,Drop} | Recognised gestures: Tap after a short press-release, LongTap while a press is sustained, Drag for each frame of a drag-in-progress, Drop on release after a drag. | You want semantic input — "did the user tap this thing?", "is this a long-press?". The engine handles the timing/movement thresholds for you. |
The demo uses both side-by-side so you can see the difference. The
RawZone counts Touch.Began events; the TapZone counts
GestureType.Tap. A single quick press on the TapZone fires both —
one Touch.Began (counted on RawZone if it's the rawZone, otherwise
ignored by the script) plus one GestureType.Tap (counted on
TapZone). A held press on the HoldZone fires many Touch.Moved
events but only one GestureType.LongTap chain.
Open the demo
Unzip and open in Effect House (5.9.0+). The opening scene contains:
- Camera — default 3D perspective camera, untouched.
- 2D Camera — auto-created by the first 2D Text.
- TitleText — "Gesture Lab" at the top.
- StatusText — yellow status line that mirrors the latest event.
- TapZone (cyan) at
(-140, 150)with TapLabel child ("Tap × N"). - DragZone (orange) at
(140, 150)with DragLabel child ("Drag me" / "Dragging"). - HoldZone (purple) at
(-140, -150)with HoldLabel child ("Long-tap N ms"). - RawZone (green) at
(140, -150)with RawLabel child ("Raw Touch × N"). - HintText at the bottom with a short usage hint.
- GameController — empty SceneObject hosting
GestureLab.
Read the script
GestureLab.ts
@component()
export class GestureLab extends APJS.BasicScriptComponent {
// Live status + zone references.
@serializeProperty statusText!: APJS.SceneObject;
@serializeProperty tapZone!: APJS.SceneObject;
@serializeProperty tapLabel!: APJS.SceneObject;
@serializeProperty dragZone!: APJS.SceneObject;
@serializeProperty dragLabel!: APJS.SceneObject;
@serializeProperty holdZone!: APJS.SceneObject;
@serializeProperty holdLabel!: APJS.SceneObject;
@serializeProperty rawZone!: APJS.SceneObject;
@serializeProperty rawLabel!: APJS.SceneObject;
// Tunables.
@serializeProperty hapticDurationMs: number = 30;
@serializeProperty hapticIntervalMs: number = 250; // throttle haptics during a hold
// Component caches (resolved in lazy init).
private statusComp!: APJS.Text;
private tapImg!: APJS.Image;
private dragImg!: APJS.Image;
private holdImg!: APJS.Image;
private rawImg!: APJS.Image;
private tapLabelText!: APJS.Text;
private dragLabelText!: APJS.Text;
private holdLabelText!: APJS.Text;
private rawLabelText!: APJS.Text;
private tapST!: APJS.ScreenTransform;
private dragST!: APJS.ScreenTransform;
// Counters + state.
private tapCount: number = 0;
private rawCount: number = 0;
private tapBouncePulse: number = 0; // 0..1, decays with dt
private dragOrigin!: APJS.Vector2f; // resting anchored position of the DragZone
private dragGrabbed: boolean = false;
private dragStartNorm!: APJS.Vector2f;
private dragStartAnchored!: APJS.Vector2f;
private snapBackUntil: number = 0; // elapsed time at which snap-back finishes
private snapBackFrom!: APJS.Vector2f;
private elapsed: number = 0;
private lastHapticAt: number = -999;
private inited: boolean = false;
// Touch / gesture callback refs (so we can remove on destroy).
private touchCallback!: (e: APJS.IEvent) => void;
private tapGestureCallback!: (e: APJS.IEvent) => void;
private longTapCallback!: (e: APJS.IEvent) => void;
private dragCallback!: (e: APJS.IEvent) => void;
private dropCallback!: (e: APJS.IEvent) => void;
onUpdate(dt: number): void {
if (!this.inited) {
if (
!this.statusText || !this.tapZone || !this.tapLabel ||
!this.dragZone || !this.dragLabel || !this.holdZone ||
!this.holdLabel || !this.rawZone || !this.rawLabel
) return;
this.statusComp = this.statusText.getComponent("Text") as APJS.Text;
this.tapImg = this.tapZone.getComponent("Image") as APJS.Image;
this.dragImg = this.dragZone.getComponent("Image") as APJS.Image;
this.holdImg = this.holdZone.getComponent("Image") as APJS.Image;
this.rawImg = this.rawZone.getComponent("Image") as APJS.Image;
this.tapLabelText = this.tapLabel.getComponent("Text") as APJS.Text;
this.dragLabelText = this.dragLabel.getComponent("Text") as APJS.Text;
this.holdLabelText = this.holdLabel.getComponent("Text") as APJS.Text;
this.rawLabelText = this.rawLabel.getComponent("Text") as APJS.Text;
this.tapST = this.tapZone.getComponent("ScreenTransform") as APJS.ScreenTransform;
this.dragST = this.dragZone.getComponent("ScreenTransform") as APJS.ScreenTransform;
// Snapshot the drag zone's resting position so Drop can snap it back.
const o = this.dragST.anchoredPosition;
this.dragOrigin = new APJS.Vector2f(o.x, o.y);
this.subscribe();
this.inited = true;
console.log("[GestureLab] ready");
}
this.elapsed += dt;
// Tap bounce decay: scale 1 + 0.2*pulse, lerp pulse to 0.
if (this.tapBouncePulse > 0) {
this.tapBouncePulse = Math.max(0, this.tapBouncePulse - dt * 4);
const s = 1 + 0.2 * this.tapBouncePulse;
this.tapST.scale = new APJS.Vector2f(s, s);
}
// Snap-back animation after Drop: lerp the drag zone back to its origin.
if (this.snapBackUntil > this.elapsed) {
const total = 0.25;
const t = 1 - Math.max(0, (this.snapBackUntil - this.elapsed) / total);
const k = Math.min(1, t);
this.dragST.anchoredPosition = new APJS.Vector2f(
this.snapBackFrom.x + (this.dragOrigin.x - this.snapBackFrom.x) * k,
this.snapBackFrom.y + (this.dragOrigin.y - this.snapBackFrom.y) * k,
);
} else if (this.snapBackUntil > 0) {
// Snap finished — freeze at origin and clear the marker.
this.dragST.anchoredPosition = this.dragOrigin.clone();
this.snapBackUntil = 0;
}
}
onDestroy(): void {
if (!this.inited) return;
APJS.EventManager.getGlobalEmitter().off(APJS.EventType.Touch, this.touchCallback, this);
const ge = APJS.EventManager.getGestureEmitter();
ge.off(APJS.GestureType.Tap, this.tapGestureCallback, this);
ge.off(APJS.GestureType.LongTap, this.longTapCallback, this);
ge.off(APJS.GestureType.Drag, this.dragCallback, this);
ge.off(APJS.GestureType.Drop, this.dropCallback, this);
}
private subscribe(): void {
// Global emitter — raw Touch events. We count finger-down events on RawZone only.
this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
if (!APJS.TouchUtils.isScreenPointOnImage(t.position, this.rawImg)) return;
this.rawCount++;
this.rawLabelText.text = "Raw Touch\n× " + this.rawCount;
this.statusComp.text = "Touch.Began — raw count = " + this.rawCount;
};
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.Touch, this.touchCallback, this);
// Gesture emitter — semantic gestures (Tap / LongTap / Drag / Drop).
const gesture = APJS.EventManager.getGestureEmitter();
this.tapGestureCallback = (event: APJS.IEvent) => {
const g = event.args[0] as APJS.GestureInfo;
if (!APJS.TouchUtils.isScreenPointOnImage(g.endPoint, this.tapImg)) return;
this.tapCount++;
this.tapBouncePulse = 1; // triggers the scale-bounce in onUpdate
this.tapLabelText.text = "Tap\n× " + this.tapCount;
this.statusComp.text = "GestureType.Tap fired";
};
gesture.on(APJS.GestureType.Tap, this.tapGestureCallback, this);
this.longTapCallback = (event: APJS.IEvent) => {
const g = event.args[0] as APJS.GestureInfo;
if (!APJS.TouchUtils.isScreenPointOnImage(g.endPoint, this.holdImg)) return;
const ms = Math.round(g.duration);
this.holdLabelText.text = "Long-tap\n" + ms + " ms";
this.statusComp.text = "LongTap firing — " + ms + " ms";
// Throttle haptics so a long hold doesn't continuously buzz.
if (this.elapsed * 1000 - this.lastHapticAt > this.hapticIntervalMs) {
APJS.HapticsModule.triggerVibration(this.hapticDurationMs, 0.7, 0.5);
this.lastHapticAt = this.elapsed * 1000;
}
};
gesture.on(APJS.GestureType.LongTap, this.longTapCallback, this);
this.dragCallback = (event: APJS.IEvent) => {
const g = event.args[0] as APJS.GestureInfo;
if (g.firstTrigger) {
// The drag has just started — only follow the finger if it began on DragZone.
if (!APJS.TouchUtils.isScreenPointOnImage(g.startPoint, this.dragImg)) {
this.dragGrabbed = false;
return;
}
this.dragGrabbed = true;
this.dragStartNorm = new APJS.Vector2f(g.startPoint.x, g.startPoint.y);
const a = this.dragST.anchoredPosition;
this.dragStartAnchored = new APJS.Vector2f(a.x, a.y);
this.statusComp.text = "GestureType.Drag started";
}
if (!this.dragGrabbed) return;
// Convert the normalized finger delta to anchored-position pixels.
// ScreenTransform y-axis is up, gesture y-axis is down — flip.
const dx = (g.endPoint.x - this.dragStartNorm.x) * 720;
const dy = -(g.endPoint.y - this.dragStartNorm.y) * 1280;
this.dragST.anchoredPosition = new APJS.Vector2f(
this.dragStartAnchored.x + dx,
this.dragStartAnchored.y + dy,
);
this.dragLabelText.text = "Dragging";
};
gesture.on(APJS.GestureType.Drag, this.dragCallback, this);
this.dropCallback = (event: APJS.IEvent) => {
if (!this.dragGrabbed) return;
this.dragGrabbed = false;
// Trigger the snap-back animation in onUpdate.
const a = this.dragST.anchoredPosition;
this.snapBackFrom = new APJS.Vector2f(a.x, a.y);
this.snapBackUntil = this.elapsed + 0.25;
this.dragLabelText.text = "Drag me";
this.statusComp.text = "GestureType.Drop — snapping back";
};
gesture.on(APJS.GestureType.Drop, this.dropCallback, this);
}
}
The Events-namespace calls of interest:
APJS.EventManager.getGlobalEmitter()is the project-wide bus for raw Touch / RecordStart / RecordEnd events. Subscribe with.on(APJS.EventType.Touch, callback, this). The callback receives anIEventwhoseargs[0]is aTouchData.APJS.EventManager.getGestureEmitter()is a separate bus for recognised gestures. Subscribe with.on(APJS.GestureType.X, callback, this). The callback'sargs[0]is aGestureInfo.TouchData.phaseis one ofBegan,Moved,Ended,Canceled. Filter onBeganfor "single press" mechanics; useMovedfor drawing or active-drag implementations; useEnded/Canceledfor cleanup or release-time logic.TouchData.positionis a normalizedVector2fwith(0,0)= top-left and(1,1)= bottom-right. Pass it directly toTouchUtils.isScreenPointOnImage— no axis-flipping.GestureInfo.endPointis the normalized 0-1 point where the gesture ended (or is currently). Always populated, on every gesture type.GestureInfo.startPointis the gesture's origin — only valid forDragandDrop. Used here to test "did the drag start on DragZone?" exactly once per drag sequence.GestureInfo.firstTriggeristrueexactly once per drag sequence — on the very firstDragevent. Use it to snapshot the grab-offset and skip irrelevant drags that didn't start on your zone.GestureInfo.duration(LongTap only) is the press duration in milliseconds, updating each LongTap event.APJS.HapticsModule.triggerVibration(durationMs, strength, frequency)fires device vibration.durationMsis10–200(default30),strengthandfrequencyare0–1(defaults0.7and0.5). Throttle yourself — the demo limits haptics to once every 250 ms while a long-press is active.APJS.TouchUtils.isScreenPointOnImage(point, image)is the hit-test helper. Pass the rawtouchInfo.positionorgestureInfo.endPointand the Image component — no coordinate conversion needed, the helper handles every layout case.
Customize
On GameController → GestureLab:
hapticDurationMs(default30) — vibration pulse length per fire. Must be10–200; the engine clamps outside that range.hapticIntervalMs(default250) — minimum gap between consecutive haptics during a long-press. Lower for buzzier feel, higher for a single soft confirmation.
Per-zone tweaks (no script changes needed):
- TapZone / DragZone / HoldZone / RawZone color — drop in any
hue via the inspector's
Image.color. - Zone label fontSize / bold / color — tune in the inspector;
the script only writes
Text.text.
Suggestions for further play:
- Add a 5th gesture: subscribe to a custom multi-finger pinch
pattern using two
Touch.Movedevents and dot-product the velocities. The raw Touch emitter is the right tool for that. - Replace the snap-back lerp with a
TweenonScreenTransform.anchoredPosition(see the TweenAnimation reference for the Tween primitives, currently grouped under Post-Process). - Combine with the Audio tutorial: play a
pop SFX on every
Tap, a low buzz on everyLongTap— gives the haptic feedback an audible companion.
What you learned
This tutorial used:
EventManager.getGlobalEmitter()+EventType.Touchfor raw finger events (Began,Moved,Ended,Canceled).EventManager.getGestureEmitter()+GestureType.{Tap, LongTap, Drag, Drop}for semantic gestures.TouchData—position(normalized 0-1, top-left origin),phase.GestureInfo—startPoint,endPoint,firstTrigger,duration.TouchUtils.isScreenPointOnImagefor zone-based hit-testing on either a TouchData position or a GestureInfo endPoint.HapticsModule.triggerVibrationfor tactile feedback, throttled by elapsed time.- Lazy unsubscribe in
onDestroy— the symmetrical.off(...)pattern matters because the emitters outlive the script's lifecycle.
Read the full EventManager reference, the EventType, IEvent, and IEventEmitter references; the TouchData, TouchPhase, and TouchUtils for raw input; the GestureInfo and GestureType for semantic gestures; the HapticsModule for vibration; and the Events & Input namespace overview.
For UI-side hit-testing patterns and ScreenTransform layout, see the 2D / UI tutorial.