@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
text/typescript
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