@wonderlandengine/components
Version:
Wonderland Engine's official component library.
533 lines • 21.7 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Component, InputComponent, ViewComponent, Emitter, } from '@wonderlandengine/api';
import { property } from '@wonderlandengine/api/decorators.js';
import { vec3, mat4 } from 'gl-matrix';
import { CursorTarget } from './cursor-target.js';
import { HitTestLocation } from './hit-test-location.js';
const tempVec = new Float32Array(3);
const ZERO = [0, 0, 0];
/** Global target for {@link Cursor} */
class CursorTargetEmitters {
/** Emitter for events when the target is hovered */
onHover = new Emitter();
/** Emitter for events when the target is unhovered */
onUnhover = new Emitter();
/** Emitter for events when the target is clicked */
onClick = new Emitter();
/** Emitter for events when the cursor moves on the target */
onMove = new Emitter();
/** Emitter for events when the user pressed the select button on the target */
onDown = new Emitter();
/** Emitter for events when the user unpressed the select button on the target */
onUp = new Emitter();
}
/**
* 3D cursor for desktop/mobile/VR.
*
* Implements a ray-casting cursor into the scene. To react to
* clicking/hover/unhover/cursor down/cursor up/move use a
* [cursor-target](#cursor-target).
*
* For VR, the ray is cast in direction of
* [this.object.getForward()](/jsapi/object/#getforward). For desktop and mobile, the
* forward vector is inverse-projected to account for where on screen the user clicked.
*
* `.globalTarget` can be used to call callbacks for all objects, even those that
* do not have a cursor target attached, but match the collision group.
*
* `.hitTestTarget` can be used to call callbacks WebXR hit test results,
*
* See [Animation Example](/showcase/animation).
*/
class Cursor extends Component {
static TypeName = 'cursor';
/* Dependencies is deprecated, but we keep it here for compatibility
* with 1.0.0-rc2 until 1.0.0 is released */
static Dependencies = [HitTestLocation];
static onRegister(engine) {
engine.registerComponent(HitTestLocation);
}
_collisionMask = 0;
_onDeactivateCallbacks = [];
_input = null;
_origin = new Float32Array(3);
_cursorObjScale = new Float32Array(3);
_direction = new Float32Array(3);
_projectionMatrix = new Float32Array(16);
_viewComponent = null;
_isDown = false;
_lastIsDown = false;
_arTouchDown = false;
_lastPointerPos = new Float32Array(2);
_lastCursorPosOnTarget = new Float32Array(3);
_hitTestLocation = null;
_hitTestObject = null;
_onSessionStartCallback = null;
/**
* Whether the cursor (and cursorObject) is visible, i.e. pointing at an object
* that matches the collision group
*/
visible = true;
/** Currently hovered object */
hoveringObject = null;
/** CursorTarget component of the currently hovered object */
hoveringObjectTarget = null;
/** Whether the cursor is hovering reality via hit-test */
hoveringReality = false;
/**
* Global target lets you receive global cursor events on any object.
*/
globalTarget = new CursorTargetEmitters();
/**
* Hit test target lets you receive cursor events for "reality", if
* `useWebXRHitTest` is set to `true`.
*
* @example
* ```js
* cursor.hitTestTarget.onClick.add((hit, cursor) => {
* // User clicked on reality
* });
* ```
*/
hitTestTarget = new CursorTargetEmitters();
/** World position of the cursor */
cursorPos = new Float32Array(3);
/** Collision group for the ray cast. Only objects in this group will be affected by this cursor. */
collisionGroup = 1;
/** (optional) Object that visualizes the cursor's ray. */
cursorRayObject = null;
/** Axis along which to scale the `cursorRayObject`. */
cursorRayScalingAxis = 2;
/** (optional) Object that visualizes the cursor's hit location. */
cursorObject = null;
/** Handedness for VR cursors to accept trigger events only from respective controller. */
handedness = 0;
/** Object that has an input component. */
inputObject = null;
/** Object that has a view component. */
viewObject = null;
/** Mode for raycasting, whether to use PhysX or simple collision components */
rayCastMode = 0;
/** Maximum distance for the cursor's ray cast. */
maxDistance = 100;
/** Whether to set the CSS style of the mouse cursor on desktop */
styleCursor = true;
/**
* Use WebXR hit-test if available.
*
* Attaches a hit-test-location component to the cursorObject, which will be used
* by the cursor to send events to the hitTestTarget with HitTestResult.
*/
useWebXRHitTest = false;
_onViewportResize = () => {
if (!this._viewComponent)
return;
/* Projection matrix will change if the viewport is resized, which will affect the
* projection matrix because of the aspect ratio. */
mat4.invert(this._projectionMatrix, this._viewComponent.projectionMatrix);
};
start() {
this._collisionMask = 1 << this.collisionGroup;
if (this.handedness == 0) {
const inputCompObj = this.inputObject || this.object;
const inputComp = inputCompObj.getComponent(InputComponent);
if (!inputComp) {
console.warn('cursor component on object', this.object.name, 'was configured with handedness "input component", ' +
'but object has no input component.');
}
else {
this.handedness = inputComp.handedness || 'none';
this._input = inputComp;
}
}
else {
this.handedness = ['left', 'right', 'none'][this.handedness - 1];
}
const viewObject = this.viewObject || this.object;
this._viewComponent = viewObject.getComponent(ViewComponent);
if (this.useWebXRHitTest) {
this._hitTestObject = this.engine.scene.addObject(this.object);
this._hitTestLocation =
this._hitTestObject.addComponent(HitTestLocation, {
scaleObject: false,
}) ?? null;
}
this._onSessionStartCallback = this.setupVREvents.bind(this);
}
onActivate() {
this.engine.onXRSessionStart.add(this._onSessionStartCallback);
this.engine.onResize.add(this._onViewportResize);
this._setCursorVisibility(true);
/* If this object also has a view component, we will enable inverse-projected mouse clicks,
* otherwise just use the objects transformation */
if (this._viewComponent != null) {
const canvas = this.engine.canvas;
const onClick = this.onClick.bind(this);
const onPointerMove = this.onPointerMove.bind(this);
const onPointerDown = this.onPointerDown.bind(this);
const onPointerUp = this.onPointerUp.bind(this);
canvas.addEventListener('click', onClick);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointerup', onPointerUp);
this._onDeactivateCallbacks.push(() => {
canvas.removeEventListener('click', onClick);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointerup', onPointerUp);
});
}
this._onViewportResize();
}
_setCursorRayTransform(hitPosition) {
if (!this.cursorRayObject)
return;
const dist = vec3.dist(this._origin, hitPosition);
this.cursorRayObject.setPositionLocal([0.0, 0.0, -dist / 2]);
if (this.cursorRayScalingAxis != 4) {
/* Scale only the requested axis, relative to the original scaling */
tempVec.fill(1);
tempVec[this.cursorRayScalingAxis] = dist / 2;
this.cursorRayObject.setScalingLocal(tempVec);
}
}
_setCursorVisibility(visible) {
if (this.visible == visible)
return;
this.visible = visible;
if (!this.cursorObject)
return;
if (visible) {
this.cursorObject.setScalingWorld(this._cursorObjScale);
}
else {
this.cursorObject.getScalingWorld(this._cursorObjScale);
this.cursorObject.scaleLocal([0, 0, 0]);
}
}
update() {
/* If in VR, set the cursor ray based on object transform */
/* Since Google Cardboard tap is registered as arTouchDown without a gamepad, we need to check for gamepad presence */
if (this.engine.xr &&
this._arTouchDown &&
this._input &&
this.engine.xr.session.inputSources[0].handedness === 'none' &&
this.engine.xr.session.inputSources[0].gamepad) {
/* WebXR AR input */
const p = this.engine.xr.session.inputSources[0].gamepad.axes;
/* Screenspace Y is inverted */
this._direction[0] = p[0];
this._direction[1] = -p[1];
this._direction[2] = -1.0;
this.applyTransformAndProjectDirection();
}
else if (this.engine.xr && this._input && this._input.xrInputSource) {
/* WebXR VR input */
this._direction[0] = 0;
this._direction[1] = 0;
this._direction[2] = -1.0;
this.applyTransformToDirection();
}
else if (this._viewComponent) {
/* Apply potentially changed transform to last stored pointer
* position */
this.updateDirection();
}
this.rayCast(null, this.engine.xr?.frame);
if (this.cursorObject) {
if (this.hoveringObject &&
(this.cursorPos[0] != 0 || this.cursorPos[1] != 0 || this.cursorPos[2] != 0)) {
this._setCursorVisibility(true);
this.cursorObject.setPositionWorld(this.cursorPos);
this._setCursorRayTransform(this.cursorPos);
}
else {
this._setCursorVisibility(false);
}
}
}
/* Returns the hovered cursor target, if available */
notify(event, originalEvent) {
const target = this.hoveringObject;
if (target) {
const cursorTarget = this.hoveringObjectTarget;
if (cursorTarget)
cursorTarget[event].notify(target, this, originalEvent ?? undefined);
this.globalTarget[event].notify(target, this, originalEvent ?? undefined);
}
}
hoverBehaviour(rayHit, hitTestResult, doClick, originalEvent) {
/* Old API version does not return null for objects[0] if no hit */
const hit = !this.hoveringReality && rayHit.hitCount > 0 ? rayHit.objects[0] : null;
if (hit) {
if (!this.hoveringObject || !this.hoveringObject.equals(hit)) {
/* Unhover previous, if exists */
if (this.hoveringObject) {
this.notify('onUnhover', originalEvent);
}
/* Hover new object */
this.hoveringObject = hit;
this.hoveringObjectTarget = this.hoveringObject.getComponent(CursorTarget);
if (this.styleCursor)
this.engine.canvas.style.cursor = 'pointer';
this.notify('onHover', originalEvent);
}
}
else if (this.hoveringObject) {
/* Previously hovering object, now hovering nothing */
this.notify('onUnhover', originalEvent);
this.hoveringObject = null;
this.hoveringObjectTarget = null;
if (this.styleCursor)
this.engine.canvas.style.cursor = 'default';
}
if (this.hoveringObject) {
/* onDown/onUp for object */
if (this._isDown !== this._lastIsDown) {
this.notify(this._isDown ? 'onDown' : 'onUp', originalEvent);
}
/* onClick for object */
if (doClick)
this.notify('onClick', originalEvent);
}
else if (this.hoveringReality) {
/* onDown/onUp for hit test */
if (this._isDown !== this._lastIsDown) {
(this._isDown ? this.hitTestTarget.onDown : this.hitTestTarget.onUp).notify(hitTestResult, this, originalEvent ?? undefined);
}
/* onClick for hit test */
if (doClick)
this.hitTestTarget.onClick.notify(hitTestResult, this, originalEvent ?? undefined);
}
/* onMove */
if (hit) {
if (this.hoveringObject) {
this.hoveringObject.transformPointInverseWorld(tempVec, this.cursorPos);
}
else {
tempVec.set(this.cursorPos);
}
if (!vec3.equals(this._lastCursorPosOnTarget, tempVec)) {
this.notify('onMove', originalEvent);
this._lastCursorPosOnTarget.set(tempVec);
}
}
else if (this.hoveringReality) {
if (!vec3.equals(this._lastCursorPosOnTarget, this.cursorPos)) {
this.hitTestTarget.onMove.notify(hitTestResult, this, originalEvent ?? undefined);
this._lastCursorPosOnTarget.set(this.cursorPos);
}
}
else {
this._lastCursorPosOnTarget.set(this.cursorPos);
}
this._lastIsDown = this._isDown;
}
/**
* Setup event listeners on session object
* @param s WebXR session
*
* Sets up 'select' and 'end' events.
*/
setupVREvents(s) {
if (!s)
console.error('setupVREvents called without a valid session');
/* Because we remove the callback in .active and another component might
* deactivate the cursor during onXRSessionStart, we make sure the cursor
* really is active when we run this */
if (!this.active)
return;
/* If in VR, one-time bind the listener */
const onSelect = this.onSelect.bind(this);
s.addEventListener('select', onSelect);
const onSelectStart = this.onSelectStart.bind(this);
s.addEventListener('selectstart', onSelectStart);
const onSelectEnd = this.onSelectEnd.bind(this);
s.addEventListener('selectend', onSelectEnd);
this._onDeactivateCallbacks.push(() => {
if (!this.engine.xr)
return;
s.removeEventListener('select', onSelect);
s.removeEventListener('selectstart', onSelectStart);
s.removeEventListener('selectend', onSelectEnd);
});
/* After AR session was entered, the projection matrix changed */
this._onViewportResize();
}
onDeactivate() {
this.engine.onXRSessionStart.remove(this._onSessionStartCallback);
this.engine.onResize.remove(this._onViewportResize);
this._setCursorVisibility(false);
if (this.hoveringObject)
this.notify('onUnhover', null);
if (this.cursorRayObject)
this.cursorRayObject.setScalingLocal(ZERO);
/* Ensure all event listeners are removed */
for (const f of this._onDeactivateCallbacks)
f();
this._onDeactivateCallbacks.length = 0;
}
onDestroy() {
this._hitTestObject?.destroy();
}
/** 'select' event listener */
onSelect(e) {
if (e.inputSource.handedness != this.handedness)
return;
this.rayCast(e, e.frame, true);
}
/** 'selectstart' event listener */
onSelectStart(e) {
this._arTouchDown = true;
if (e.inputSource.handedness == this.handedness) {
this._isDown = true;
this.rayCast(e, e.frame);
}
}
/** 'selectend' event listener */
onSelectEnd(e) {
this._arTouchDown = false;
if (e.inputSource.handedness == this.handedness) {
this._isDown = false;
this.rayCast(e, e.frame);
}
}
/** 'pointermove' event listener */
onPointerMove(e) {
/* Don't care about secondary pointers */
if (!e.isPrimary)
return;
this.updateMousePos(e);
this.rayCast(e, null);
}
/** 'click' event listener */
onClick(e) {
this.updateMousePos(e);
this.rayCast(e, null, true);
}
/** 'pointerdown' event listener */
onPointerDown(e) {
/* Don't care about secondary pointers or non-left clicks */
if (!e.isPrimary || e.button !== 0)
return;
this.updateMousePos(e);
this._isDown = true;
this.rayCast(e);
}
/** 'pointerup' event listener */
onPointerUp(e) {
/* Don't care about secondary pointers or non-left clicks */
if (!e.isPrimary || e.button !== 0)
return;
this.updateMousePos(e);
this._isDown = false;
this.rayCast(e);
}
/**
* Update mouse position in non-VR mode and raycast for new position
* @returns @ref RayHit for new position.
*/
updateMousePos(e) {
this._lastPointerPos[0] = e.clientX;
this._lastPointerPos[1] = e.clientY;
this.updateDirection();
}
updateDirection() {
const bounds = this.engine.canvas.getBoundingClientRect();
/* Get direction in normalized device coordinate space from mouse position */
const left = this._lastPointerPos[0] / bounds.width;
const top = this._lastPointerPos[1] / bounds.height;
this._direction[0] = left * 2 - 1;
this._direction[1] = -top * 2 + 1;
this._direction[2] = -1.0;
this.applyTransformAndProjectDirection();
}
applyTransformAndProjectDirection() {
/* Reverse-project the direction into view space */
vec3.transformMat4(this._direction, this._direction, this._projectionMatrix);
vec3.normalize(this._direction, this._direction);
this.applyTransformToDirection();
}
applyTransformToDirection() {
this.object.transformVectorWorld(this._direction, this._direction);
this.object.getPositionWorld(this._origin);
}
rayCast(originalEvent, frame = null, doClick = false) {
const rayHit = this.rayCastMode == 0
? this.engine.scene.rayCast(this._origin, this._direction, this._collisionMask)
: this.engine.physics.rayCast(this._origin, this._direction, this._collisionMask, this.maxDistance);
let hitResultDistance = Infinity;
let hitTestResult = null;
if (this._hitTestLocation?.visible) {
this._hitTestObject.getPositionWorld(this.cursorPos);
hitResultDistance = vec3.distance(this.object.getPositionWorld(tempVec), this.cursorPos);
hitTestResult = this._hitTestLocation?.getHitTestResults(frame)[0];
}
let hoveringReality = false;
if (rayHit.hitCount > 0) {
const d = rayHit.distances[0];
if (hitResultDistance >= d) {
/* Override cursorPos set by hit test location */
this.cursorPos.set(rayHit.locations[0]);
}
else {
hoveringReality = true;
}
}
else if (hitResultDistance < Infinity) {
/* cursorPos already set */
}
else {
this.cursorPos.fill(0);
}
if (hoveringReality && !this.hoveringReality) {
this.hitTestTarget.onHover.notify(hitTestResult, this);
}
else if (!hoveringReality && this.hoveringReality) {
this.hitTestTarget.onUnhover.notify(hitTestResult, this);
}
this.hoveringReality = hoveringReality;
this.hoverBehaviour(rayHit, hitTestResult, doClick, originalEvent);
return rayHit;
}
}
__decorate([
property.int(1)
], Cursor.prototype, "collisionGroup", void 0);
__decorate([
property.object()
], Cursor.prototype, "cursorRayObject", void 0);
__decorate([
property.enum(['x', 'y', 'z', 'none'], 'z')
], Cursor.prototype, "cursorRayScalingAxis", void 0);
__decorate([
property.object()
], Cursor.prototype, "cursorObject", void 0);
__decorate([
property.enum(['input component', 'left', 'right', 'none'], 'input component')
], Cursor.prototype, "handedness", void 0);
__decorate([
property.object()
], Cursor.prototype, "inputObject", void 0);
__decorate([
property.object()
], Cursor.prototype, "viewObject", void 0);
__decorate([
property.enum(['collision', 'physx'], 'collision')
], Cursor.prototype, "rayCastMode", void 0);
__decorate([
property.float(100)
], Cursor.prototype, "maxDistance", void 0);
__decorate([
property.bool(true)
], Cursor.prototype, "styleCursor", void 0);
__decorate([
property.bool(false)
], Cursor.prototype, "useWebXRHitTest", void 0);
export { Cursor };
//# sourceMappingURL=cursor.js.map