UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

1,165 lines (1,055 loc) • 64.9 kB
import { Intersection, Matrix4, Object3D, Ray, Vector2, Vector3 } from 'three'; import { showBalloonMessage, showBalloonWarning } from './debug/debug.js'; import { Context } from './engine_setup.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<PointerType>; 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<PointerEnumType>; type KeyboardEventNames = EnumToPrimitiveUnion<KeyboardEnumType>; 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"; } 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 PointerEvent { /** * 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<IPointerHitEventReceiver>; /** 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; /** @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<NEPointerEventIntersection>(); 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; } 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 KeyboardEvent { 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<string, RegisteredEventListenerValue> = {}; /** Adds an event listener for the specified event type. The callback will be called when the event is triggered. * @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. * @example Basic usage * ```ts * input.addEventListener("pointerdown", (evt) => { * console.log("Pointer down", evt.pointerId, evt.pointerType); * }); * ``` * @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); addEventListener(type: KeyboardEventNames, callback: KeyboardEventListener, options?: EventListenerOptions); 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 }); } } /** 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<number> { 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<number> { 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<number, number[]>(); 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<T extends Vec2 | Vector2>(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; bindEvents() { this.unbindEvents(); // 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); } unbindEvents() { for (const key in this._eventListeners) { this._eventListeners[key].length = 0; // clear all listeners for this event } 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<number>; 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) { evt.target.setPointerCapture(evt.pointerId); } 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.ge