import { Intersection, Matrix4, Object3D, Ray, Vector2, Vector3 } from 'three'; import { showBalloonMessage, showBalloonWarning } from './debug/debug.js'; import { Context } from './engine_setup.js'; import { KeyboardEventBase,PointerEventBase } from './engine_ssr.js'; import { getTempVector, getWorldQuaternion } from './engine_three_utils.js'; import type { ButtonName, CursorTypeName, IGameObject, IInput, MouseButtonName, Vec2 } from './engine_types.js'; import { DeviceUtilities, type EnumToPrimitiveUnion, getParam } from './engine_utils.js'; const debug = getParam("debuginput"); /** * Types of pointer input devices supported by Needle Engine. */ export const enum PointerType { /** Mouse or trackpad input */ Mouse = "mouse", /** Touch screen input */ Touch = "touch", /** XR controller input (e.g., VR controllers) */ Controller = "controller", /** XR hand tracking input */ Hand = "hand" } export type PointerTypeNames = EnumToPrimitiveUnion; const enum PointerEnumType { PointerDown = "pointerdown", PointerUp = "pointerup", PointerMove = "pointermove", } const enum KeyboardEnumType { KeyDown = "keydown", KeyUp = "keyup", KeyPressed = "keypress" } /** * Event types that can be listened to via {@link Input.addEventListener}. * @see {@link NEPointerEvent} for pointer event data * @see {@link NEKeyboardEvent} for keyboard event data */ export const enum InputEvents { /** Fired when a pointer button is pressed */ PointerDown = "pointerdown", /** Fired when a pointer button is released */ PointerUp = "pointerup", /** Fired when a pointer moves */ PointerMove = "pointermove", /** Fired when a key is pressed down */ KeyDown = "keydown", /** Fired when a key is released */ KeyUp = "keyup", /** Fired when a key produces a character value */ KeyPressed = "keypress" } /** e.g. `pointerdown` */ type PointerEventNames = EnumToPrimitiveUnion; type KeyboardEventNames = EnumToPrimitiveUnion; export type InputEventNames = PointerEventNames | KeyboardEventNames; declare type PointerEventListener = (evt: NEPointerEvent) => void; declare type KeyboardEventListener = (evt: NEKeyboardEvent) => void; declare type InputEventListener = PointerEventListener | KeyboardEventListener; export declare type NEPointerEventInit = PointerEventInit & { clientZ?: number; origin: object; pointerId: number; /** the index of the device */ deviceIndex: number; pointerType: PointerTypeNames; mode: XRTargetRayMode, ray?: Ray; /** The control object for this input. In the case of spatial devices the controller, * otherwise a generated object in screen space. The object may not be in the scene. */ device: IGameObject; buttonName: ButtonName | "none"; /** If true the pointer event was cancelled (e.g. controller disconnected before selectend, or hand tracking lost during pinch) */ cancelled?: boolean; } declare type OnPointerHitsEvent = (args: OnPointerHitEvent) => void; declare type OnPointerHitEvent = { /** The object that raised the event */ sender: object; /** The pointer event that invoked the event */ event: NEPointerEvent; /** The intersections that were generated from this event (or are associated with this event in any way) */ hits: Intersection[]; } export interface IPointerHitEventReceiver { onPointerHits: OnPointerHitsEvent; } /** An intersection that is potentially associated with a pointer event */ export declare type NEPointerEventIntersection = Intersection & { event?: NEPointerEvent }; /** * Extended PointerEvent with Needle Engine-specific data. * Contains information about the input device, spatial data for XR, and world-space ray. * * @example Accessing event data in a component * ```ts * onPointerDown(args: PointerEventData) { * const evt = args.event; * console.log(`Pointer ${evt.pointerId} (${evt.pointerType})`); * if (evt.isSpatial) { * console.log("XR input, ray:", evt.ray); * } * } * ``` * * @see {@link Input} for the input management system * @see {@link PointerType} for available pointer types */ export class NEPointerEvent extends PointerEventBase { /** * Spatial input data */ clientZ?: number; /** the device index: mouse and touch are always 0, otherwise e.g. index of the connected Gamepad or XRController */ readonly deviceIndex: number; /** The origin of the event contains a reference to the creator of this event. * This can be the Needle Engine input system or e.g. a XR controller. * Implement `onPointerHits` to receive the intersections of this event. */ readonly origin: object & Partial; /** the browser event that triggered this event (if any) */ readonly source: Event | null; /** Is the pointer event created via a touch on screen or a spatial device like a XR controller or hand tracking? */ readonly mode: XRTargetRayMode | "transient-pointer"; /** Returns true if the input was emitted in 3D space (and not by e.g. clicking on a 2D screen). You can use {@link mode} if you need more information about the input source */ get isSpatial() { return this.mode != "screen"; } /** A ray in worldspace for the event. * If the ray is undefined you can also use `space.worldForward` and `space.worldPosition` */ get ray(): Ray { if (!this._ray) { this._ray = new Ray(this.space.worldPosition.clone(), this.space.worldForward.clone()); } return this._ray; } private set ray(value: Ray) { this._ray = value; } /**@returns true if this event has a ray. If you access the ray property a ray will automatically created */ get hasRay() { return this._ray !== undefined; } private _ray: Ray | undefined; /** The device space (this object is not necessarily rendered in the scene but you can access or copy the matrix) * E.g. you can access the input world space source position with `space.worldPosition` or world direction with `space.worldForward` */ readonly space: IGameObject; /** true if this event is a click */ isClick: boolean = false; /** true if this event is a double click */ isDoubleClick: boolean = false; /** true if the interaction was cancelled (e.g. controller disconnected or hand tracking lost) */ readonly cancelled: boolean = false; /** @returns `true` if the event is marked to be used (when `use()` has been called). Default: `false` */ get used() { return this._used; } private _used: boolean = false; /** Call to mark an event to be used */ use() { this._used = true; } /** Identifier for this pointer event. * For mouse and touch this is always 0. * For XR input: a combination of the deviceIndex + button to uniquely identify the exact input (e.g. LeftController:Button0 = 0, RightController:Button1 = 11) */ override get pointerId(): number { return this._pointerid; } private readonly _pointerid; // this is set via the init arguments (we override it here for intellisense to show the string options) /** What type of input created this event: touch, mouse, xr controller, xr hand tracking... */ override get pointerType(): PointerTypeNames { return this._pointerType; } private readonly _pointerType: PointerTypeNames; /** * The button name that raised this event (e.g. for mouse events "left", "right", "middle" or for XRTrigger "xr-standard-trigger" or "xr-standard-thumbstick") * Use {@link button} to get the numeric button index (e.g. 0, 1, 2...) on the controller or mouse. */ readonly buttonName?: ButtonName | "none" = undefined; // this is set via the init arguments (we override it here for intellisense to show the string options) /** The input that raised this event like `pointerdown` */ override get type(): InputEventNames { return this._type; } private readonly _type: InputEventNames; /** metadata can be used to associate additional information with the event */ readonly metadata = {} /** intersections that were generated from this event (or are associated with this event in any way) */ readonly intersections = new Array(); constructor(type: InputEvents | InputEventNames, source: Event | null, init: NEPointerEventInit) { super(type, init); this.clientZ = init.clientZ; // apply the init arguments. Otherwise the arguments will be undefined in the bundled / published version of needle engine // so we have to be careful if we override properties - we then also need to set them in the constructor this._pointerid = init.pointerId; this._pointerType = init.pointerType; this._type = type; this.deviceIndex = init.deviceIndex; this.origin = init.origin; this.source = source; this.mode = init.mode; this._ray = init.ray; this.space = init.device; this.buttonName = init.buttonName; if (init.cancelled) (this as { cancelled: boolean }).cancelled = true; } private _immediatePropagationStopped = false; get immediatePropagationStopped() { return this._immediatePropagationStopped; } private _propagationStopped = false; get propagationStopped() { return this._immediatePropagationStopped || this._propagationStopped; } stopImmediatePropagation(): void { this._immediatePropagationStopped = true; super.stopImmediatePropagation(); this.source?.stopImmediatePropagation(); } stopPropagation(): void { this._propagationStopped = true; super.stopPropagation(); this.source?.stopPropagation(); if (debug) console.warn("Stop propagation...", this.pointerId, this.pointerType) } } export class NEKeyboardEvent extends KeyboardEventBase { source?: Event constructor(type: InputEvents, source: Event, init: KeyboardEventInit) { super(type, init) this.source = source; } stopImmediatePropagation(): void { super.stopImmediatePropagation(); this.source?.stopImmediatePropagation(); } } export class KeyEventArgs { key: string; keyType: string; source?: Event; constructor(evt: KeyboardEvent) { this.key = evt.key; this.keyType = evt.type; this.source = evt; } } export enum InputEventQueue { Early = -100, Default = 0, Late = 100, } declare type EventListenerOptions = { /** For addEventListener: The queue to add the listener to. Listeners in the same queue are called in the order they were added. Default is 0. * For removeEventListener: The queue to remove the listener from. If no queue is specified the listener will be removed from all queues */ queue?: InputEventQueue | number; /** If true, the listener will be removed after it is invoked once. */ once?: boolean; /** The listener will be removed when the given AbortSignal object's `abort()` method is called. If not specified, no AbortSignal is associated with the listener. */ signal?: AbortSignal; } type RegisteredEventListenerValue = Array<{ priority: number, listeners: Array<{ callback: InputEventListener, options: EventListenerOptions }> }>; /** * Handles all input events including mouse, touch, keyboard, and XR controllers. * Access via `this.context.input` from any component. * * @example Checking mouse/pointer state * ```ts * update() { * if (this.context.input.mouseDown) { * console.log("Mouse button pressed"); * } * if (this.context.input.mouseClick) { * console.log("Click detected"); * } * const pos = this.context.input.mousePosition; * console.log(`Mouse at: ${pos.x}, ${pos.y}`); * } * ``` * @example Keyboard input * ```ts * update() { * if (this.context.input.isKeyDown("Space")) { * console.log("Space pressed this frame"); * } * if (this.context.input.isKeyPressed("w")) { * console.log("W key is held down"); * } * } * ``` * @example Event-based input * ```ts * onEnable() { * this.context.input.addEventListener("pointerdown", this.onPointerDown); * } * onDisable() { * this.context.input.removeEventListener("pointerdown", this.onPointerDown); * } * onPointerDown = (evt: NEPointerEvent) => { * console.log("Pointer down:", evt.pointerId); * } * ``` * * @see {@link NEPointerEvent} for pointer event data * @see {@link InputEvents} for available event types * @see {@link PointerType} for pointer device types * @link https://engine.needle.tools/docs/scripting.html */ export class Input implements IInput { /** This is a list of event listeners per event type (e.g. pointerdown, pointerup, keydown...). Each entry contains a priority and list of listeners. * That way users can control if they want to receive events before or after other listeners (e.g subscribe to pointer events before the EventSystem receives them) - this allows certain listeners to be always invoked first (or last) and stop propagation * Listeners per event are sorted */ private readonly _eventListeners: Record = {}; /** Adds an event listener for the specified event type. The callback will be called when the event is triggered. * * Returns an unsubscribe function — call it to remove the listener. * Pass it to {@link Behaviour.autoCleanup} for automatic lifecycle management. * * @param type The event type to listen for * @param callback The callback to call when the event is triggered * @param options The options for adding the event listener. * @returns A function that removes the event listener when called. * * @example With autoCleanup (recommended) * ```ts * export class MyComponent extends Behaviour { * onEnable() { * this.autoCleanup(this.context.input.addEventListener("pointerdown", (evt) => { * console.log("Pointer down", evt.pointerId, evt.pointerType); * })); * } * // Listener is automatically removed on disable — no manual cleanup needed! * } * ``` * @example Manual unsubscribe * ```ts * const off = input.addEventListener("pointerdown", (evt) => { * console.log("Pointer down", evt.pointerId, evt.pointerType); * }); * // later * off(); * ``` * @example Adding a listener that is called after all other listeners * By using a higher value for the queue the listener will be called after other listeners (default queue is 0). * ```ts * input.addEventListener("pointerdown", (evt) => { * console.log("Pointer down", evt.pointerId, evt.pointerType); * }, { queue: 10 }); * ``` * @example Adding a listener that is only called once * ```ts * input.addEventListener("pointerdown", (evt) => { * console.log("Pointer down", evt.pointerId, evt.pointerType); * }, { once: true }); * ``` */ addEventListener(type: PointerEventNames, callback: PointerEventListener, options?: EventListenerOptions): () => void; addEventListener(type: KeyboardEventNames, callback: KeyboardEventListener, options?: EventListenerOptions): () => void; addEventListener(type: InputEvents | InputEventNames, callback: InputEventListener, options?: EventListenerOptions): () => void { if (!this._eventListeners[type]) this._eventListeners[type] = []; if (!callback || typeof callback !== "function") { console.error("Invalid call to addEventListener: callback is required and must be a function!"); return () => {}; } if (!options) options = {}; // create a copy of the options object to avoid the original object being modified else options = { ...options }; let queue = 0; if (options?.queue != undefined) queue = options.queue; const listeners = this._eventListeners[type]; const queueListeners = listeners.find(l => l.priority === queue); if (!queueListeners) { listeners.push({ priority: queue, listeners: [{ callback, options }] }); // ensure we sort the listeners by priority listeners.sort((a, b) => a.priority - b.priority); } else { queueListeners.listeners.push({ callback, options }); } return () => this.removeEventListener(type as any, callback as any, options); } /** Removes the event listener from the specified event type. If no queue is specified the listener will be removed from all queues. * @param type The event type to remove the listener from * @param callback The callback to remove * @param options The options for removing the event listener */ removeEventListener(type: PointerEventNames, callback: PointerEventListener, options?: EventListenerOptions); removeEventListener(type: KeyboardEventNames, callback: KeyboardEventListener, options?: EventListenerOptions); removeEventListener(type: InputEvents | InputEventNames, callback: InputEventListener, options?: EventListenerOptions): void { if (!this._eventListeners[type]) return; if (!callback) return; const listeners = this._eventListeners[type]; // if a specific queue is requested the callback should only be removed from that queue if (options?.queue != undefined) { const queueListeners = listeners.find(l => l.priority === options.queue); if (!queueListeners) return; const index = queueListeners.listeners.findIndex(l => l.callback === callback); if (index >= 0) queueListeners.listeners.splice(index, 1); } // if no queue is requested the callback will be removed from all queues else { for (const l of listeners) { const index = l.listeners.findIndex(l => l.callback === callback); if (index >= 0) l.listeners.splice(index, 1); } } } private dispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) { /** True when the next event queue should not be invoked */ let preventNextEventQueue = false; // Handle keyboard event if (evt instanceof NEKeyboardEvent) { const listeners = this._eventListeners[evt.type]; if (listeners) { for (const queue of listeners) { for (let i = 0; i < queue.listeners.length; i++) { const entry = queue.listeners[i]; // if the abort signal is aborted we remove the listener and will not invoke it if (entry.options?.signal?.aborted) { queue.listeners.splice(i, 1); i--; continue; } // if the event should only be invoked once then we remove the listener before invoking it if (entry.options.once) { queue.listeners.splice(i, 1); i--; } (entry.callback as KeyboardEventListener)(evt); } } } } // Hnadle pointer event if (evt instanceof NEPointerEvent) { const listeners = this._eventListeners[evt.type]; if (listeners) { for (const queue of listeners) { if (preventNextEventQueue) break; for (let i = 0; i < queue.listeners.length; i++) { const entry = queue.listeners[i]; // if the abort signal is aborted we remove the listener and will not invoke it if (entry.options?.signal?.aborted) { queue.listeners.splice(i, 1); i--; continue; } // if immediatePropagationStopped is true we stop propagation altogether if (evt.immediatePropagationStopped) { preventNextEventQueue = true; if (debug) console.log("immediatePropagationStopped", evt.type); break; } // if propagationStopped is true we continue invoking the current queue but then not invoke the next queue else if (evt.propagationStopped) { preventNextEventQueue = true; if (debug) console.log("propagationStopped", evt.type); // we do not break here but continue invoking the listeners in the queue } // if the event should only be invoked once then we remove the listener before invoking it if (entry.options.once) { queue.listeners.splice(i, 1); i--; } (entry.callback as PointerEventListener)(evt); } } } } } _doubleClickTimeThreshold = .2; _longPressTimeThreshold = 1; get mousePosition(): Vector2 { return this._pointerPositions[0]; }; get mousePositionRC(): Vector2 { return this._pointerPositionsRC[0]; } get mouseDown(): boolean { return this._pointerDown[0]; } get mouseUp(): boolean { return this._pointerUp[0]; } /** Is the primary pointer clicked (usually the left button). This is equivalent to `input.click` */ get mouseClick(): boolean { return this._pointerClick[0]; } /** Was a double click detected for the primary pointer? This is equivalent to `input.doubleClick` */ get mouseDoubleClick(): boolean { return this._pointerDoubleClick[0]; } get mousePressed(): boolean { return this._pointerPressed[0]; } get mouseWheelChanged(): boolean { return this.getMouseWheelChanged(0); } /** Is the primary pointer double clicked (usually the left button). This is equivalent to `input.mouseDoubleClick` */ get click(): boolean { return this._pointerClick[0]; } /** Was a double click detected for the primary pointer? */ get doubleClick(): boolean { return this._pointerDoubleClick[0]; } /** * Get a connected Gamepad * Note: For a gamepad to be available to the browser it must have received input before while the page was focused. * @link https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API * @returns The gamepad or null if no gamepad is connected */ getGamepad(index: number = 0): Gamepad | null { if (typeof navigator !== "undefined" && "getGamepads" in navigator) { return navigator.getGamepads()[index] || null; } return null; } private readonly _setCursorTypes: CursorTypeName[] = []; /** @deprecated use setCursor("pointer") */ setCursorPointer() { this.setCursor("pointer"); } /** @deprecated use unsetCursor() */ setCursorNormal() { this.unsetCursor("pointer"); } /** * Set a custom cursor. This will set the cursor type until unsetCursor is called */ setCursor(type: CursorTypeName) { this._setCursorTypes.push(type); if (this._setCursorTypes.length > 10) { this._setCursorTypes.shift(); } this.updateCursor(); } /** * Unset a custom cursor. This will set the cursor type to the previous type or default */ unsetCursor(type: CursorTypeName) { for (let i = this._setCursorTypes.length - 1; i >= 0; i--) { if (this._setCursorTypes[i] === type) { this._setCursorTypes.splice(i, 1); this.updateCursor(); break; } } } private updateCursor() { if (this._setCursorTypes?.length == 0) this.context.domElement.style.cursor = "default"; else this.context.domElement.style.cursor = this._setCursorTypes[this._setCursorTypes.length - 1]; } /** * Check if a pointer id is currently used. */ getIsPointerIdInUse(pointerId: number) { for (const evt of this._pointerEventsPressed) { if (evt.pointerId === pointerId) { if (evt.used) return true; } } return false; } /** how many pointers are currently pressed */ getPointerPressedCount(): number { let count = 0; for (let i = 0; i < this._pointerPressed.length; i++) { if (this._pointerPressed[i]) { count++; } } return count; } /** * Gets the position of the given pointer index in pixel * @param i The pointer index * @returns The position of the pointer in pixel */ getPointerPosition(i: number): Vector2 | null { if (i >= this._pointerPositions.length) return null; return this._pointerPositions[i]; } getPointerPositionLastFrame(i: number): Vector2 | null { if (i >= this._pointerPositionsLastFrame.length) return null; return this._pointerPositionsLastFrame[i]; } getPointerPositionDelta(i: number): Vector2 | null { if (i >= this._pointerPositionsDelta.length) return null; return this._pointerPositionsDelta[i]; } /** * The pointer position in screenspace coordinates (-1 to 1) where 0 is the center of the screen. * This can be useful for e.g. raycasting (see https://threejs.org/docs/#api/en/core/Raycaster.setFromCamera) */ getPointerPositionRC(i: number): Vector2 | null { if (i >= this._pointerPositionsRC.length) return null; return this._pointerPositionsRC[i]; } getPointerDown(i: number): boolean { if (i >= this._pointerDown.length) return false; return this._pointerDown[i]; } getPointerUp(i: number): boolean { if (i >= this._pointerUp.length) return false; return this._pointerUp[i]; } getPointerPressed(i: number): boolean { if (i >= this._pointerPressed.length) return false; const res = this._pointerPressed[i]; // if (i === 0) console.log(...this._pointerIds); return res; } getPointerClicked(i: number): boolean { if (i >= this._pointerClick.length) return false; return this._pointerClick[i]; } getPointerDoubleClicked(i: number): boolean { if (i >= this._pointerDoubleClick.length) return false; return this._pointerDoubleClick[i]; } getPointerDownTime(i: number): number { if (i >= this._pointerDownTime.length) return -1; return this._pointerDownTime[i]; } getPointerUpTime(i: number): number { if (i >= this._pointerUpTime.length) return -1; return this._pointerUpTime[i]; } getPointerLongPress(i: number): boolean { if (i >= this._pointerDownTime.length) return false; return this.getPointerPressed(i) && this.context.time.time - this._pointerDownTime[i] > this._longPressTimeThreshold; } getIsMouse(i: number): boolean { if (i < 0 || i >= this._pointerTypes.length) return false; return this._pointerTypes[i] === PointerType.Mouse; } getIsTouch(i: number): boolean { if (i < 0 || i >= this._pointerTypes.length) return false; return this._pointerTypes[i] === PointerType.Touch; } getTouchesPressedCount(): number { let count = 0; for (let i = 0; i < this._pointerPressed.length; i++) { if (this._pointerPressed[i] && this.getIsTouch(i)) { count++; } } return count; } getMouseWheelChanged(i: number = 0): boolean { if (i >= this._mouseWheelChanged.length) return false; return this._mouseWheelChanged[i]; } getMouseWheelDeltaY(i: number = 0): number { if (i >= this._mouseWheelDeltaY.length) return 0; return this._mouseWheelDeltaY[i]; } getPointerEvent(i: number): Event | undefined { if (i >= this._pointerEvent.length) return undefined; return this._pointerEvent[i] ?? undefined; } *foreachPointerId(pointerType?: string | PointerType | string[] | PointerType[]): Generator { for (let i = 0; i < this._pointerTypes.length; i++) { // check if the pointer is active if (this._pointerIsActive(i)) { // if specific pointer types are requested if (pointerType !== undefined) { const type = this._pointerTypes[i]; if (Array.isArray(pointerType)) { let isInArray = false; for (const t of pointerType) { if (type === t) { isInArray = true; break; } } if (!isInArray) continue; } else { if (pointerType !== type) continue; } } yield i; } } } *foreachTouchId(): Generator { for (let i = 0; i < this._pointerTypes.length; i++) { const type = this._pointerTypes[i]; if (type !== PointerType.Touch) continue; if (this._pointerIsActive[i]) yield i; } } private _pointerIsActive(index: number) { if (index < 0) return false; return this._pointerPressed[index] || this._pointerDown[index] || this._pointerUp[index]; } private context: Context; private _pointerDown: boolean[] = [false]; private _pointerUp: boolean[] = [false]; private _pointerClick: boolean[] = [false]; private _pointerDoubleClick: boolean[] = [false]; private _pointerPressed: boolean[] = [false]; private _pointerPositions: Vector2[] = [new Vector2()]; private _pointerPositionsLastFrame: Vector2[] = [new Vector2()]; private _pointerPositionsDelta: Vector2[] = [new Vector2()]; private _pointerPositionsRC: Vector2[] = [new Vector2()]; private _pointerPositionDown: Vector3[] = [new Vector3()]; private _pointerDownTime: number[] = []; private _pointerUpTime: number[] = []; private _pointerUpTimestamp: number[] = []; private _pointerIds: number[] = []; private _pointerTypes: string[] = [""]; private _mouseWheelChanged: boolean[] = [false]; private _mouseWheelDeltaY: number[] = [0]; private _pointerEvent: Event[] = []; /** current pressed pointer events. Used to check if any of those events was used */ private _pointerEventsPressed: NEPointerEvent[] = []; /** This is added/updated for pointers. screenspace pointers set this to the camera near plane */ private _pointerSpace: IGameObject[] = []; private readonly _pressedStack = new Map(); private onDownButton(pointerId: number, button: number) { let stack = this._pressedStack.get(pointerId); if (!stack) { stack = []; this._pressedStack.set(pointerId, stack); } stack.push(button); } private onReleaseButton(pointerId: number, button: number) { const stack = this._pressedStack.get(pointerId); if (!stack) return; const index = stack.indexOf(button); if (index >= 0) stack.splice(index, 1); } /** the first button that was down and is currently pressed */ getFirstPressedButtonForPointer(pointerId: number): number | undefined { const stack = this._pressedStack.get(pointerId); if (!stack) return undefined; return stack[0]; } /** the last (most recent) button that was down and is currently pressed */ getLatestPressedButtonForPointer(pointerId: number): number | undefined { const stack = this._pressedStack.get(pointerId); if (!stack) return undefined; return stack[stack.length - 1]; } /** Get a key (if any) that was just pressed this frame (this is only true for the frame it was pressed down) */ getKeyDown(): string | null; /** Get true or false if the given key was pressed this frame */ getKeyDown(key: KeyCode | ({} & string)): boolean; getKeyDown(key?: KeyCode | ({} & string)): boolean | string | null { // If a key is provided check if it was pressed this frame if (key !== undefined) { return this.isKeyDown(key); } // If no key was provided get the first key that was pressed this frame for (const key in this.keysPressed) { const k = this.keysPressed[key]; if (k.startFrame === this.context.time.frameCount) return k.key; } return null; } /** Get a key (if any) that is currently being pressed (held down) */ getKeyPressed(): string | null; /** Get true or false if the given key is pressed */ getKeyPressed(key: KeyCode | ({} & string)): boolean getKeyPressed(key?: KeyCode | ({} & string)): boolean | string | null { if (key !== undefined) { return this.isKeyPressed(key); } for (const key in this.keysPressed) { const k = this.keysPressed[key]; if (k.pressed) return k.key; } return null; } /** Get a key (if any) that was released in this frame */ getKeyUp(): string | null; /** Get true or false if the given key was released this frame */ getKeyUp(key: KeyCode | ({} & string)): boolean; getKeyUp(key?: KeyCode | ({} & string)): boolean | string | null { if (key !== undefined) { return this.isKeyUp(key); } for (const key in this.keysPressed) { const k = this.keysPressed[key]; if (k.pressed === false && k.frame === this.context.time.frameCount) return true; return false; } return null; } isKeyDown(keyCode: KeyCode | ({} & string)) { if (!this.context.application.isVisible || !this.context.application.hasFocus) return false; const codes = this.getCodeForCommonKeyName(keyCode); if (codes !== null) { for (const code of codes) if (this.isKeyDown(code)) return true; return false; } const k = this.keysPressed[keyCode]; if (!k) return false; return k.startFrame === this.context.time.frameCount && k.pressed; } isKeyUp(keyCode: KeyCode | ({} & string)) { if (!this.context.application.isVisible || !this.context.application.hasFocus) return false; const codes = this.getCodeForCommonKeyName(keyCode); if (codes !== null) { for (const code of codes) if (this.isKeyUp(code)) return true; return false; } const k = this.keysPressed[keyCode]; if (!k) return false; return k.frame === this.context.time.frameCount && k.pressed === false; } isKeyPressed(keyCode: KeyCode | ({} & string)): boolean { if (!this.context.application.isVisible || !this.context.application.hasFocus) return false; const codes = this.getCodeForCommonKeyName(keyCode); if (codes !== null) { for (const code of codes) if (this.isKeyPressed(code)) return true; return false; } const k = this.keysPressed[keyCode]; if (!k) return false; return k.pressed || false; } // utility helper for mapping common names to actual codes; e.g. "Shift" -> "ShiftLeft" and "ShiftRight" or "a" -> "KeyA" private getCodeForCommonKeyName(keyName: string): string[] | null { if (keyName.length === 1) { // check if this is a digit if (keyName >= "0" && keyName <= "9") return ["Digit" + keyName]; // check if this is a letter if (keyName >= "a" && keyName <= "z") return ["Key" + keyName.toUpperCase()]; if (keyName == " ") return ["Space"]; } switch (keyName) { case "shift": case "Shift": return ["ShiftLeft", "ShiftRight"]; case "control": case "Control": return ["ControlLeft", "ControlRight"]; case "alt": case "Alt": return ["AltLeft", "AltRight"]; } return null; } createInputEvent(args: NEPointerEvent) { // TODO: technically we would need to check for circular invocations here! switch (args.type) { case InputEvents.PointerDown: if (debug) showBalloonMessage("Create Pointer down"); this.onDownButton(args.deviceIndex, args.button); this.onDown(args); break; case InputEvents.PointerMove: if (debug) showBalloonMessage("Create Pointer move"); this.onMove(args); break; case InputEvents.PointerUp: if (debug) showBalloonMessage("Create Pointer up"); this.onUp(args); this.onReleaseButton(args.deviceIndex, args.button); break; } } convertScreenspaceToRaycastSpace(vec2: T): T { vec2.x = (vec2.x - this.context.domX) / this.context.domWidth * 2 - 1; vec2.y = -((vec2.y - this.context.domY) / this.context.domHeight) * 2 + 1; return vec2; } /** @internal */ constructor(context: Context) { this.context = context; this.context.post_render_callbacks.push(this.onEndOfFrame); } /** this is the html element we subscribed to for events */ private _htmlEventSource!: HTMLElement; private _eventsBound = false; private _eventsExplicitlyUnbound = false; /** @internal true while the DOM event listeners are attached */ get eventsBound() { return this._eventsBound; } /** @internal true after unbindEvents() was called (suppresses the automatic binding on update — see Context.frameBegin) */ get eventsExplicitlyUnbound() { return this._eventsExplicitlyUnbound; } bindEvents() { // only detach the DOM listeners here — registered subscriber callbacks // (addEventListener) must survive a (re)bind, e.g. the automatic binding on the // first update (see Context.frameBegin) or the create() boot path this.unbindDomEvents(); // we subscribe to the canvas element because we don't want to receive events when the user is interacting with the UI // e.g. if we have slotted HTML elements in the needle engine DOM elements we don't want to receive input events for those this._htmlEventSource = this.context.renderer.domElement; window.addEventListener('contextmenu', this.onContextMenu); this._htmlEventSource.addEventListener('pointerdown', this.onPointerDown, { passive: true }); window.addEventListener('pointermove', this.onPointerMove, { passive: true, capture: true, }); window.addEventListener('pointerup', this.onPointerUp, { passive: true }); window.addEventListener('pointercancel', this.onPointerCancel, { passive: true }) window.addEventListener("touchstart", this.onTouchStart, { passive: true }); window.addEventListener("touchmove", this.onTouchMove, { passive: true }); window.addEventListener("touchend", this.onTouchEnd, { passive: true }); this._htmlEventSource.addEventListener('wheel', this.onMouseWheel, { passive: true }); window.addEventListener("wheel", this.onWheelWindow, { passive: true }) window.addEventListener("keydown", this.onKeyDown, false); window.addEventListener("keypress", this.onKeyPressed, false); window.addEventListener("keyup", this.onKeyUp, false); // e.g. when using sharex to capture we loose focus thus dont get e.g. key up events window.addEventListener('blur', this.onLostFocus); this._eventsBound = true; this._eventsExplicitlyUnbound = false; } /** Detaches the DOM event listeners and clears all registered subscriber callbacks. * Also opts this context out of the automatic input binding on update — call * {@link bindEvents} to opt back in. */ unbindEvents() { this._eventsExplicitlyUnbound = true; for (const key in this._eventListeners) { this._eventListeners[key].length = 0; // clear all listeners for this event } this.unbindDomEvents(); } /** detach the DOM listeners without touching registered subscriber callbacks */ private unbindDomEvents() { this._eventsBound = false; window.removeEventListener('contextmenu', this.onContextMenu); this._htmlEventSource?.removeEventListener('pointerdown', this.onPointerDown); window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointerup', this.onPointerUp); window.removeEventListener('pointercancel', this.onPointerCancel); window.removeEventListener("touchstart", this.onTouchStart); window.removeEventListener("touchmove", this.onTouchMove); window.removeEventListener("touchend", this.onTouchEnd); this._htmlEventSource?.removeEventListener('wheel', this.onMouseWheel, false); window.removeEventListener("wheel", this.onWheelWindow, false); window.removeEventListener("keydown", this.onKeyDown, false); window.removeEventListener("keypress", this.onKeyPressed, false); window.removeEventListener("keyup", this.onKeyUp, false); window.removeEventListener('blur', this.onLostFocus); } dispose() { const index = this.context.post_render_callbacks.indexOf(this.onEndOfFrame); if (index >= 0) this.context.post_render_callbacks.splice(index, 1); this.unbindEvents(); } private onLostFocus = () => { for (const kp in this.keysPressed) { this.keysPressed[kp].pressed = false; } } private readonly _receivedPointerMoveEventsThisFrame = new Array; private onEndOfFrame = () => { this._receivedPointerMoveEventsThisFrame.length = 0; for (let i = 0; i < this._pointerUp.length; i++) this._pointerUp[i] = false; for (let i = 0; i < this._pointerDown.length; i++) this._pointerDown[i] = false; for (let i = 0; i < this._pointerClick.length; i++) this._pointerClick[i] = false; for (let i = 0; i < this._pointerDoubleClick.length; i++) this._pointerDoubleClick[i] = false; for (const pt of this._pointerPositionsDelta) pt.set(0, 0); for (let i = 0; i < this._mouseWheelChanged.length; i++) this._mouseWheelChanged[i] = false; for (let i = 0; i < this._mouseWheelDeltaY.length; i++) this._mouseWheelDeltaY[i] = 0; } private canReceiveInput(evt: Event) { // If the user has HTML objects ontop of the canvas // if(evt.target === this.context.renderer.domElement) return true; // const css = window.getComputedStyle(evt.target as HTMLElement); // if(css.pointerEvents === "all") return false; // We only check the target elements here since the canvas may be overlapped by other elements // in which case we do not want to use the input (e.g. if a HTML element is being triggered) if (evt.target === this.context.renderer?.domElement) return true; if (evt.target === this.context.domElement) return true; // if we are in AR we always want to receive touches because the canvas is the whole screen. // See https://linear.app/needle/issue/NE-4345 if (this.context.isInAR) { return true; } // looks like in Mozilla WebXR viewer the target element is the body if (this.context.isInAR && evt.target === document.body && DeviceUtilities.isMozillaXR()) return true; if (debug) console.warn("CanReceiveInput:False for", evt.target); return false; } private onContextMenu = (evt: Event) => { if (this.canReceiveInput(evt) === false) return; // if (evt instanceof PointerEvent) { // // for longpress on touch there might open a context menu // // in which case we set the pointer pressed back to false (resetting the pressed pointer) // // we need to emit a pointer up event here as well // if (evt.pointerType === "touch") { // // for (const index in this._pointerPressed) { // // if (this._pointerTypes[index] === PointerType.Touch) { // // // this._pointerPressed[index] = false; // // // this throws orbit controls? // // // const ne = this.createPointerEventFromTouch("pointerup", parseInt(index), this._pointerPositions[index].x, this._pointerPositions[index].y, 0, evt); // // // this.onUp(ne); // // } // // } // } // } } private keysPressed: { [key: KeyCode | string]: { pressed: boolean, frame: number, startFrame: number, key: string, code: KeyCode | string } } = {}; private onKeyDown = (evt: KeyboardEvent) => { if (debug) console.log(`key down ${evt.code}, ${this.context.application.hasFocus}`, evt); if (!this.context.application.hasFocus) return; const ex = this.keysPressed[evt.code]; if (ex && ex.pressed) return; this.keysPressed[evt.code] = { pressed: true, frame: this.context.time.frameCount + 1, startFrame: this.context.time.frameCount + 1, key: evt.key, code: evt.code }; const ne = new NEKeyboardEvent(InputEvents.KeyDown, evt, evt); this.onDispatchEvent(ne); } private onKeyPressed = (evt: KeyboardEvent) => { if (!this.context.application.hasFocus) return; const p = this.keysPressed[evt.code]; if (!p) return; p.pressed = true; p.frame = this.context.time.frameCount + 1; const ne = new NEKeyboardEvent(InputEvents.KeyPressed, evt, evt); this.onDispatchEvent(ne); } private onKeyUp = (evt: KeyboardEvent) => { if (!this.context.application.hasFocus) return; const p = this.keysPressed[evt.code]; if (!p) return; p.pressed = false; p.frame = this.context.time.frameCount + 1; const ne = new NEKeyboardEvent(InputEvents.KeyUp, evt, evt); this.onDispatchEvent(ne); } private onWheelWindow = (evt: WheelEvent) => { // check if we are in pointer lock mode if (document.pointerLockElement) { // only if yes we want to use the mouse wheel as a pointer event this.onMouseWheel(evt); } }; private onMouseWheel = (evt: WheelEvent) => { if (this.canReceiveInput(evt) === false) return; if (this._mouseWheelDeltaY.length <= 0) this._mouseWheelDeltaY.push(0); if (this._mouseWheelChanged.length <= 0) this._mouseWheelChanged.push(false); this._mouseWheelChanged[0] = true; const current = this._mouseWheelDeltaY[0]; this._mouseWheelDeltaY[0] = current + evt.deltaY; } private onPointerDown = (evt: PointerEvent) => { if (this.context.isInAR) return; if (this.canReceiveInput(evt) === false) return; if (evt.target instanceof HTMLElement) { try { evt.target.setPointerCapture(evt.pointerId); } catch { /* may fail during pointer lock */ } } const id = this.getPointerId(evt); if (debug) showBalloonMessage(`pointer down #${id}, identifier:${evt.pointerId}`); const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY); const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: evt.button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType as PointerTypeNames, buttonName: this.getButtonName(evt), device: space, pressure: evt.pressure }); this.onDown(ne); } private onPointerMove = (evt: PointerEvent) => { if (this.context.isInAR) return; // Prevent multiple pointerMove events per frame if (this._receivedPointerMoveEventsThisFrame.includes(evt.pointerId)) return; this._receivedPointerMoveEventsThisFrame.push(evt.pointerId); // We want to keep receiving move events until pointerUp and not stop handling events just because we're hovering over *some* HTML element // if (this.canReceiveInput(evt) === false) return; let button = evt.button; if (evt.pointerType === "mouse") { const pressedButton = this.getFirstPressedButtonForPointer(0); button = pressedButton ?? 0; } const id = this.getPointerId(evt, button); if (button === -1) { button = id; } const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY); const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType as PointerTypeNames, buttonName: this.getButtonName(evt), device: space, pressure: evt.pressure }); this.onMove(ne); } private onPointerCancel = (evt: PointerEvent) => { if (this.context.isInAR) return; if (debug) console.log("Pointer cancel", evt); // we treat this as an up event for now to make sure we don't have any pointers stuck in a pressed state etc. Technically we dont want to invoke a up event for cancels... this.onPointerUp(evt); } private onPointerUp = (evt: PointerEvent) => { if (this.context.isInAR) return; if (evt.target instanceof HTMLElement) { evt.target.releasePointerCapture(evt.pointerId); } // the pointer up event should always be handled // if (this.canReceiveInput(evt) === false) return; const id = this.getPointerId(evt); // if (!this.isNewEvent(evt.timeStamp, id, this._pointerUpTimestamp)) return; const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: evt.button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType as PointerTypeNames, buttonName: this.getButtonName(evt), device: this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY), pressure: evt.pressure }); this.onUp(ne); this._pointerIds[id] = -1; if (debug) console.log("ID=" + id, "PointerId=" + evt.pointerId, "ALL:", [...this._pointerIds]); } private getPointerId(evt: PointerEvent, button?: number): number { if (evt.pointerType === "mouse") return 0 + (button ?? evt.button); return this.getPointerIndex(evt.pointerId); } private getButtonName(evt: PointerEvent): MouseButtonName | "unknown" { const button = evt.button; if (evt.pointerType === "mouse") { switch (button) { case 0: return "left"; case 1: return "middle"; case 2: return "right"; } } return "unknown"; } // the touch events are currently only used for AR support on android private onTouchStart = (evt: TouchEvent) => { if (!this.context.isInAR) return; for (let i = 0; i < evt.changedTouches.length; i++) { const touch = evt.changedTouches[i]; const id = this.getPointerIndex(touch.identifier); const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY); const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: space, pressure: touch.force }); this.onDown(ne); }; }; private onTouchMove = (evt: TouchEvent) => { if (!this.context.isInAR) return; for (let i = 0; i < evt.changedTouches.length; i++) { const touch = evt.changedTouches[i]; const id = this.getPointerIndex(touch.identifier); const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY); const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: space, pressure: touch.force }); this.onMove(ne); }; } private onTouchEnd = (evt: TouchEvent) => { if (!this.context.isInAR) return; for (let i = 0; i < evt.changedTouches.length; i++) { const touch = evt.changedTouches[i]; const id = this.getPointerIndex(touch.identifier); const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY), pressure: touch.force }); this.onUp(ne); this._pointerIds[id] = -1; }; } private readonly tempNearPlaneVector = new Vector3(); private readonly tempFarPlaneVector = new Vector3(); private readonly tempLookMatrix = new Matrix4(); private getAndUpdateSpatialObjectForScreenPosition(id: number, screenX: number, screenY: number): IGameObject { let space = this._pointerSpace[id] if (!space) { space = new Object3D() as unknown as IGameObject; this._pointerSpace[id] = space; } this._pointerSpace[id] = space; const camera = this.context.mainCamera; if (camera) { const pointOnNearPlane = this.tempNearPlaneVector.set(screenX, screenY, -1); this.convertScreenspaceToRaycastSpace(pointOnNearPlane); const pointOnFarPlane = this.tempFarPlaneVector.set(pointOnNearPlane.x, pointOnNearPlane.y, 1); pointOnNearPlane.unproject(camera); pointOnFarPlane.unproject(camera); const worldUp = (camera as any as IGameObject).worldUp || getTempVector(0, 1, 0).applyQuaternion(getWorldQuaternion(camera)) this.tempLookMatrix.lookAt(pointOnFarPlane, pointOnNearPlane, worldUp); space.position.set(pointOnNearPlane.x, pointOnNearPlane.y, pointOnNearPlane.z); space.quaternion.setFromRotationMatrix(this.tempLookMatrix); } return space; } // Prevent the same event being handled twice (e.g. on touch we get a mouseUp and touchUp evt with the same timestamp) // private isNewEvent(timestamp: number, index: number, arr: number[]): boolean { // while (arr.length <= index) arr.push(-1); // if (timestamp === arr[index]) return false; // arr[index] = timestamp; // return true; // } private isInRect(e: { clientX: number, clientY: number }): boolean { if (this.context.isInXR) return true; const rect = this.context.domElement.getBoundingClientRect(); const px = e.clientX; const py = e.clientY; const isInRect = px >= rect.x && px <= rect.right && py >= rect.y && py <= rect.bottom; if (debug && !isInRect) console.log("Not in rect", rect, px, py); return isInRect; } private onDown(evt: NEPointerEvent) { const index = evt.pointerId; if (this.getPointerPressed(index)) { // see https://linear.app/needle/issue/NE-6855#comment-3b0e3365 if (debug) console.warn(`Received pointerDown event for pointerId that is already pressed: ${index}/${evt.button}`, debug ? evt : ''); return; } if (debug) console.log(evt.pointerType, "DOWN", index, evt.button); if (!this.isInRect(evt)) return; // if (this.isMouseEventFromTouch(evt)) return; this.setPointerState(index, this._pointerPressed, true); this.setPointerState(index, this._pointerDown, true); this.setPointerStateT(index, this._pointerEvent, evt.source); while (index >= this._pointerTypes.length) this._pointerTypes.push(evt.pointerType); this._pointerTypes[index] = evt.pointerType; while (index >= this._pointerPositionDown.length) this._pointerPositionDown.push(new Vector3()); this._pointerPositionDown[index].set(evt.clientX, evt.clientY, evt.clientZ ?? 0); while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2()); this._pointerPositions[index].set(evt.clientX, evt.clientY); if (index >= this._pointerDownTime.length) this._pointerDownTime.push(0); this._pointerDownTime[index] = this.context.time.realtimeSinceStartup; this.updatePointerPosition(evt); this._pointerEventsPressed.push(evt); this.onDispatchEvent(evt); } // moveEvent?: Event; private onMove(evt: NEPointerEvent) { const index = evt.pointerId; const isDown = this.getPointerPressed(index); if (isDown === false && !this.isInRect(evt)) return; if (evt.pointerType === PointerType.Touch && !isDown) return; // if (this.isMouseEventFromTouch(evt)) return; // if (debug) console.log(evt.pointerType, "MOVE", index, "hasSpace=" + evt.space != null); this.updatePointerPosition(evt); this.setPointerStateT(index, this._pointerEvent, evt.source); this.onDispatchEvent(evt); } private onUp(evt: NEPointerEvent) { const index = evt.pointerId; const wasDown = this.getPointerPressed(index); if (!wasDown) { if (debug) console.warn(`Received pointerUp for pointerId that is not pressed: ${index}/${evt.button}`, debug ? evt : ''); return; } // if (this.isMouseEventFromTouch(evt)) return; if (debug) console.log(evt.pointerType, "UP", index); this.setPointerState(index, this._pointerPressed, false); this.setPointerStateT(index, this._pointerEvent, evt.source); this.setPointerState(index, this._pointerUp, true); this.updatePointerPosition(evt); for (let i = this._pointerEventsPressed.length - 1; i >= 0; i--) { const ptr = this._pointerEventsPressed[i]; if (ptr.pointerId === index) { this._pointerEventsPressed.splice(i, 1); break; } } if (!this._pointerPositionDown[index]) { if (debug) showBalloonWarning("[Received pointer up event without matching down event for button: " + index); console.warn("Received pointer up event without matching down event for button: " + index) return; } const lastUpTime = this._pointerUpTime[index]; const downTime = this._pointerDownTime[index]; const upTime = this.context.time.realtimeSinceStartup; const dt = upTime - downTime; if (index >= this._pointerUpTime.length) this._pointerUpTime.push(-99); this._pointerUpTime[index] = upTime; // If the time from down to up is less than 1 second check if it was a click if (!evt.cancelled && dt < 1) { let dx = evt.clientX - this._pointerPositionDown[index].x; let dy = evt.clientY - this._pointerPositionDown[index].y; let dz = 0; if (evt.isSpatial && evt.clientZ != undefined) { dz = evt.clientZ - this._pointerPositionDown[index].z; // spatial clicks remapping dx *= 200; dy *= 200; dz *= 200; } if (Math.abs(dx) < 5 && Math.abs(dy) < 5 && Math.abs(dz) < 5) { this.setPointerState(index, this._pointerClick, true); evt.isClick = true; // handle double click const dt = upTime - lastUpTime; if (debug) console.log("CLICK", index, dx, dy, dz, dt) if (dt < this._doubleClickTimeThreshold && dt > 0) { this.setPointerState(index, this._pointerDoubleClick, true); evt.isDoubleClick = true; } } } this.onDispatchEvent(evt); } private updatePointerPosition(evt: NEPointerEvent) { const index = evt.pointerId; while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2()); while (index >= this._pointerPositionsLastFrame.length) this._pointerPositionsLastFrame.push(new Vector2()); while (index >= this._pointerPositionsDelta.length) this._pointerPositionsDelta.push(new Vector2()); const lf = this._pointerPositionsLastFrame[index]; lf.copy(this._pointerPositions[index]); // accumulate delta (it's reset in end of frame), if we just write it here it's not correct when the browser console is open const delta = this._pointerPositionsDelta[index]; let dx = evt.clientX - lf.x; let dy = evt.clientY - lf.y; // if pointer is locked, clientX and Y are not changed, but Movement is. if (evt.source instanceof MouseEvent || evt.source instanceof TouchEvent) { const source = evt.source as PointerEvent; if (dx === 0 && source.movementX !== 0) dx = source.movementX || 0; if (dy === 0 && source.movementY !== 0) dy = source.movementY || 0; } delta.x += dx; delta.y += dy; this._pointerPositions[index].x = evt.clientX; this._pointerPositions[index].y = evt.clientY; // we want to have the position 01 on the canvas for raycasting const px = evt.clientX; const py = evt.clientY; while (index >= this._pointerPositionsRC.length) this._pointerPositionsRC.push(new Vector2()); const rc = this._pointerPositionsRC[index]; rc.set(px, py); this.convertScreenspaceToRaycastSpace(rc); // console.log(this.context.alias, rc); // this._pointerPositionsRC[evt.button].x = (px - this.context.domX) / this.context.domWidth * 2 - 1; // this._pointerPositionsRC[evt.button].y = -((py - this.context.domY) / this.context.domHeight) * 2 + 1; // console.log(evt.button) } /** get the next free id */ private getPointerIndex(pointerId: number) { // test if theres a pointer with the id let firstFreeIndex = -1; for (let i = 0; i < this._pointerIds.length; i++) { if (this._pointerIds[i] === pointerId) return i; else if (firstFreeIndex === -1 && this._pointerIds[i] === -1) { firstFreeIndex = i; } } // if not take the free slot if any if (firstFreeIndex !== -1) { this._pointerIds[firstFreeIndex] = pointerId; return firstFreeIndex; } if (debug) console.log("PUSH pointerId:", pointerId) this._pointerIds.push(pointerId); return this._pointerIds.length - 1; } private setPointerState(index: number, arr: boolean[], value: boolean) { arr[index] = value; } private setPointerStateT(index: number, arr: T[], value: T) { // while (arr.length <= index) arr.push(null as any); arr[index] = value; return value; } private onDispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) { const prevContext = Context.Current; try { Context.Current = this.context; this.dispatchEvent(evt); } finally { Context.Current = prevContext; } } } export declare type KeyCode = | "Tab" | "Enter" | "ShiftLeft" | "ShiftRight" | "ControlLeft" | "ControlRight" | "AltLeft" | "AltRight" | "Pause" | "CapsLock" | "Escape" | "Space" | "PageUp" | "PageDown" | "End" | "Home" | "ArrowLeft" | "ArrowUp" | "ArrowRight" | "ArrowDown" | "Insert" | "Delete" | "Digit0" | "Digit1" | "Digit2" | "Digit3" | "Digit4" | "Digit5" | "Digit6" | "Digit7" | "Digit8" | "Digit9" | "KeyA" | "KeyB" | "KeyC" | "KeyD" | "KeyE" | "KeyF" | "KeyG" | "KeyH" | "KeyI" | "KeyJ" | "KeyK" | "KeyL" | "KeyM" | "KeyN" | "KeyO" | "KeyP" | "KeyQ" | "KeyR" | "KeyS" | "KeyT" | "KeyU" | "KeyV" | "KeyW" | "KeyX" | "KeyY" | "KeyZ" | "Select" | "Numpad0" | "Numpad1" | "Numpad2" | "Numpad3" | "Numpad4" | "Numpad5" | "Numpad6" | "Numpad7" | "Numpad8" | "Numpad9" | "Multiply" | "Add" | "Subtract" | "Decimal" | "Divide" | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12"; // KEY_1 = 49, // KEY_2 = 50, // KEY_3 = 51, // KEY_4 = 52, // KEY_5 = 53, // KEY_6 = 54, // KEY_7 = 55, // KEY_8 = 56, // KEY_9 = 57, // KEY_A = 65, // KEY_B = 66, // KEY_C = 67, // KEY_D = "d", // KEY_E = 69, // KEY_F = 70, // KEY_G = 71, // KEY_H = 72, // KEY_I = 73, // KEY_J = 74, // KEY_K = 75, // KEY_L = 76, // KEY_M = 77, // KEY_N = 78, // KEY_O = 79, // KEY_P = 80, // KEY_Q = 81, // KEY_R = 82, // KEY_S = 83, // KEY_T = 84, // KEY_U = 85, // KEY_V = 86, // KEY_W = 87, // KEY_X = 88, // KEY_Y = 89, // KEY_Z = 90, // LEFT_META = 91, // RIGHT_META = 92, // SELECT = 93, // NUMPAD_0 = 96, // NUMPAD_1 = 97, // NUMPAD_2 = 98, // NUMPAD_3 = 99, // NUMPAD_4 = 100, // NUMPAD_5 = 101, // NUMPAD_6 = 102, // NUMPAD_7 = 103, // NUMPAD_8 = 104, // NUMPAD_9 = 105, // MULTIPLY = 106, // ADD = 107, // SUBTRACT = 109, // DECIMAL = 110, // DIVIDE = 111, // F1 = 112, // F2 = 113, // F3 = 114, // F4 = 115, // F5 = 116, // F6 = 117, // F7 = 118, // F8 = 119, // F9 = 120, // F10 = 121, // F11 = 122, // F12 = 123, // NUM_LOCK = 144, // SCROLL_LOCK = 145, // SEMICOLON = 186, // EQUALS = 187, // COMMA = 188, // DASH = 189, // PERIOD = 190, // FORWARD_SLASH = 191, // GRAVE_ACCENT = 192, // OPEN_BRACKET = 219, // BACK_SLASH = 220, // CLOSE_BRACKET = 221, // SINGLE_QUOTE = 222