import type { EventEmitter } from '../event-emitter'; import type { PointerEvent } from './pointer-event'; import type { GlobalCoordinates } from '../math'; import type { PointerEventReceiver } from './pointer-event-receiver'; import { RentalPool } from '../util/rental-pool'; import { PointerType } from './pointer-type'; /** * Signals that an object has nested pointer events on nested objects that are not an Entity with * a PointerComponent. For example TileMap Tiles */ export interface HasNestedPointerEvents { _dispatchPointerEvents(receiver: PointerEventReceiver): void; _processPointerToObject(receiver: PointerEventReceiver): void; } /** * */ function hasNestedEvents(object: any): object is HasNestedPointerEvents { return object && object._dispatchPointerEvents && object._processPointerToObject; } export class PointerTargetObjectProxy { public object!: TObject; public contains!: (point: GlobalCoordinates) => boolean; public active!: () => boolean; public get events(): EventEmitter { return this.object.events; } public init(object: TObject, contains: (point: GlobalCoordinates) => boolean, active: () => boolean): void { this.object = object; this.contains = contains; this.active = active; } } export class PointerEventsToObjectDispatcher { private _proxyPool = new RentalPool( () => new PointerTargetObjectProxy(), (p) => p, 100 ); private _objectToProxy = new Map>(); private _proxies: PointerTargetObjectProxy[] = []; /** * Tracks an object to associate with pointers and their events * @param object * @param contains * @param active */ public addObject(object: TObject, contains: (point: GlobalCoordinates) => boolean, active: () => boolean): void { const proxy = this._proxyPool.rent(false); proxy.init(object, contains, active); this._proxies.push(proxy); this._objectToProxy.set(object, proxy); } public _getProxy(object: TObject): PointerTargetObjectProxy { const proxy = this._objectToProxy.get(object); if (proxy) { return proxy; } throw new Error('No PointerTargetProxy for object'); } /** * Untracks an object associated with pointers and their events * @param object */ public removeObject(object: TObject): void { const proxy = this._objectToProxy.get(object); if (proxy) { const index = this._proxies.indexOf(proxy); this._objectToProxy.delete(object); if (index > -1) { this._proxies.splice(index, 1); } this._proxyPool.return(proxy); } } private _lastFrameObjectToPointers = new Map, number[]>(); private _currentFrameObjectToPointers = new Map, number[]>(); private _objectCurrentlyUnderPointer(object: PointerTargetObjectProxy, pointerId: number): boolean { return !!(this._currentFrameObjectToPointers.has(object) && this._currentFrameObjectToPointers.get(object)!.includes(pointerId)); } private _objectWasUnderPointer(object: PointerTargetObjectProxy, pointerId: number): boolean { return !!(this._lastFrameObjectToPointers.has(object) && this._lastFrameObjectToPointers.get(object)!.includes(pointerId)); } private _entered(object: PointerTargetObjectProxy, pointerId: number): boolean { return this._objectCurrentlyUnderPointer(object, pointerId) && !this._lastFrameObjectToPointers.has(object); } private _left(object: PointerTargetObjectProxy, pointerId: number): boolean { return !this._currentFrameObjectToPointers.has(object) && this._objectWasUnderPointer(object, pointerId); } /** * Manually associate a pointer id with an object. * * This assumes you've checked that the pointer is indeed over the object. */ public addPointerToObject(object: TObject, pointerId: number): void { const maybeProxy = this._objectToProxy.get(object); if (maybeProxy) { this._addPointerToProxy(maybeProxy, pointerId); } } private _addPointerToProxy(object: PointerTargetObjectProxy, pointerId: number): void { if (!this._currentFrameObjectToPointers.has(object)) { this._currentFrameObjectToPointers.set(object, [pointerId]); return; } const pointers = this._currentFrameObjectToPointers.get(object)!; this._currentFrameObjectToPointers.set(object, pointers.concat(pointerId)); } /** * Dispatches the appropriate pointer events in sortedObject order on tracked objects * @param receiver * @param sortedObjects */ public dispatchEvents(receiver: PointerEventReceiver, sortedObjects?: TObject[]) { const lastFrameEntities = new Set(this._lastFrameObjectToPointers.keys()); const currentFrameEntities = new Set(this._currentFrameObjectToPointers.keys()); // Filter preserves z order let lastMovePerPointer: Map; let lastUpPerPointer: Map; let lastDownPerPointer: Map; let sortedObjectsProxies: PointerTargetObjectProxy[] = []; if (sortedObjects) { for (let i = 0; i < sortedObjects.length; i++) { const object = sortedObjects[i]; const proxy = this._getProxy(object); sortedObjectsProxies.push(proxy); // TODO it might be faster to "just" maintain a sorted list from last/current frame // this will require some more engineering to accurately track z index changes over time } } else { // eschew sorted order just use last/current frame entities in any order // TODO use Set.union when it's more supported by the last 5 Safari versions const unsortedObjectProxies = new Set(lastFrameEntities); for (const other of currentFrameEntities) { unsortedObjectProxies.add(other); } sortedObjectsProxies = Array.from(unsortedObjectProxies); } // Dispatch events in proxy z order for (let i = 0; i < sortedObjectsProxies.length; i++) { const proxy = sortedObjectsProxies[i]; if (hasNestedEvents(proxy.object)) { proxy.object._dispatchPointerEvents(receiver); } if (lastFrameEntities.has(proxy) || currentFrameEntities.has(proxy)) { lastDownPerPointer = this._processDownAndEmit(receiver, proxy); lastUpPerPointer = this._processUpAndEmit(receiver, proxy); lastMovePerPointer = this._processMoveAndEmit(receiver, proxy); const lastUpDownMoveEvents = [...lastMovePerPointer.values(), ...lastDownPerPointer.values(), ...lastUpPerPointer.values()]; this._processEnterLeaveAndEmit(receiver, proxy, lastUpDownMoveEvents); this._processCancelAndEmit(receiver, proxy); this._processWheelAndEmit(receiver, proxy); } } } /** * Given the tracked objects, update pointer containment given the provided contains() * @param receiver * @param objects */ public processPointerToObject(receiver: PointerEventReceiver, objects: TObject[]) { // Pre-process find entities under pointers for (let objectIndex = 0; objectIndex < objects.length; objectIndex++) { const object = objects[objectIndex]; const proxy = this._getProxy(object); if (hasNestedEvents(object)) { object._processPointerToObject(receiver); } for (const [pointerId, pos] of receiver.currentFramePointerCoords.entries()) { if (proxy.contains(pos)) { this._addPointerToProxy(proxy, pointerId); } } } } /** * Clear current frames pointer-object associations and track last frame pointer-object associations */ public clear() { this._lastFrameObjectToPointers.clear(); this._lastFrameObjectToPointers = new Map, number[]>(this._currentFrameObjectToPointers); this._currentFrameObjectToPointers.clear(); } private _processDownAndEmit(receiver: PointerEventReceiver, object: PointerTargetObjectProxy): Map { const lastDownPerPointer = new Map(); // Loop through down and dispatch to entities for (const event of receiver.currentFrameDown) { if (event.active && this._objectCurrentlyUnderPointer(object, event.pointerId)) { object.events.emit('pointerdown', event as any); if (receiver.isDragStart(event.pointerId)) { object.events.emit('pointerdragstart', event as any); } } lastDownPerPointer.set(event.pointerId, event); } return lastDownPerPointer; } private _processUpAndEmit(receiver: PointerEventReceiver, object: PointerTargetObjectProxy): Map { const lastUpPerPointer = new Map(); // Loop through up and dispatch to entities for (const event of receiver.currentFrameUp) { if (event.active && this._objectCurrentlyUnderPointer(object, event.pointerId)) { object.events.emit('pointerup', event as any); if (receiver.isDragEnd(event.pointerId)) { object.events.emit('pointerdragend', event as any); } } lastUpPerPointer.set(event.pointerId, event); } return lastUpPerPointer; } private _processMoveAndEmit(receiver: PointerEventReceiver, object: PointerTargetObjectProxy): Map { const lastMovePerPointer = new Map(); // Loop through move and dispatch to entities for (const event of receiver.currentFrameMove) { if (event.active && object.active() && this._objectCurrentlyUnderPointer(object, event.pointerId)) { // move object.events.emit('pointermove', event as any); if (receiver.isDragging(event.pointerId)) { object.events.emit('pointerdragmove', event as any); } } lastMovePerPointer.set(event.pointerId, event); } return lastMovePerPointer; } private _processEnterLeaveAndEmit( receiver: PointerEventReceiver, object: PointerTargetObjectProxy, lastUpDownMoveEvents: PointerEvent[] ) { // up, down, and move are considered for enter and leave for (const event of lastUpDownMoveEvents) { const isActive = event.active && object.active(); const isTouchOrPenEvent = event.pointerType === PointerType.Pen || event.pointerType === PointerType.Touch; // enter if ( isActive && // enter can happen on move (this._entered(object, event.pointerId) || // or enter can happen on pointer down (isTouchOrPenEvent && event.type === 'down' && this._objectCurrentlyUnderPointer(object, event.pointerId))) ) { object.events.emit('pointerenter', event as any); if (receiver.isDragging(event.pointerId)) { object.events.emit('pointerdragenter', event as any); } break; } if ( isActive && // leave can happen on move (this._left(object, event.pointerId) || // or leave can happen on pointer up (isTouchOrPenEvent && event.type === 'up' && this._objectCurrentlyUnderPointer(object, event.pointerId))) ) { object.events.emit('pointerleave', event as any); if (receiver.isDragging(event.pointerId)) { object.events.emit('pointerdragleave', event as any); } break; } } } private _processCancelAndEmit(receiver: PointerEventReceiver, object: PointerTargetObjectProxy) { // cancel for (const event of receiver.currentFrameCancel) { if (event.active && object.active() && this._objectCurrentlyUnderPointer(object, event.pointerId)) { object.events.emit('pointercancel', event as any); } } } private _processWheelAndEmit(receiver: PointerEventReceiver, object: PointerTargetObjectProxy) { // wheel for (const event of receiver.currentFrameWheel) { // Currently the wheel only fires under the primary pointer '0' if (event.active && object.active() && this._objectCurrentlyUnderPointer(object, 0)) { object.events.emit('pointerwheel', event as any); } } } }