@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.
855 lines (736 loc) • 35.8 kB
text/typescript
import { type Intersection, Mesh, Object3D } from "three";
import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
import { type InputEventNames, InputEvents, NEPointerEvent, NEPointerEventIntersection, PointerType } from "../../engine/engine_input.js";
import { onInitialized } from "../../engine/engine_lifecycle_api.js";
import { Mathf } from "../../engine/engine_math.js";
import { RaycastOptions, type RaycastTestObjectReturnType } from "../../engine/engine_physics.js";
import { Context } from "../../engine/engine_setup.js";
import { HideFlags, type IComponent } from "../../engine/engine_types.js";
import { getParam } from "../../engine/engine_utils.js";
import { Behaviour, GameObject } from "../Component.js";
import type { ICanvasGroup } from "./Interfaces.js";
import { hasPointerEventComponent, type IPointerEventHandler, type IPointerUpHandler, PointerEventData } from "./PointerEvents.js";
import { ObjectRaycaster, Raycaster } from "./Raycaster.js";
import { UIRaycastUtils } from "./RaycastUtils.js";
import { $shadowDomOwner } from "./Symbols.js";
import { isUIObject } from "./Utils.js";
const debug = getParam("debugeventsystem");
export enum EventSystemEvents {
BeforeHandleInput = "BeforeHandleInput",
AfterHandleInput = "AfterHandleInput",
}
export declare type AfterHandleInputEvent = {
sender: EventSystem,
args: PointerEventData,
hasActiveUI: boolean
}
declare type IComponentCanMaybeReceiveEvents = IPointerEventHandler & IComponent & { interactable?: boolean };
onInitialized((ctx) => {
EventSystem.createIfNoneExists(ctx);
})
/**
* @category User Interface
* @group Components
*/
export class EventSystem extends Behaviour {
//@ts-ignore
static ensureUpdateMeshUI(instance, context: Context, force: boolean = false) {
MeshUIHelper.update(instance, context, force);
}
static markUIDirty(_context: Context) {
MeshUIHelper.markDirty();
}
static createIfNoneExists(context: Context) {
if (!context.scene.getComponent(EventSystem)) {
context.scene.addComponent(EventSystem);
}
}
static get(ctx: Context): EventSystem | null {
this.createIfNoneExists(ctx);
return ctx.scene.getComponent(EventSystem);
}
/** Get the currently active event system */
static get instance(): EventSystem | null {
return this.get(Context.Current);
}
private readonly raycaster: Raycaster[] = [];
register(rc: Raycaster) {
if (rc && this.raycaster && !this.raycaster.includes(rc))
this.raycaster?.push(rc);
}
unregister(rc: Raycaster) {
const i = this.raycaster?.indexOf(rc);
if (i !== undefined && i !== -1) {
this.raycaster?.splice(i, 1);
}
}
get hasActiveUI() {
return this.currentActiveMeshUIComponents.length > 0;
}
get isHoveringObjects() { return this.hoveredByID.size > 0; }
awake(): void {
// We only want ONE eventsystem on the root scene
// as long as this component is not implemented in core we need to check this here
if (this.gameObject as Object3D !== this.context.scene) {
console.debug(`[Needle Engine] EventSystem is only allowed on the scene root. Disabling EventSystem on '${this.gameObject.name}'`);
this.enabled = false;
}
}
start() {
if (!this.context.scene.getComponent(Raycaster)) {
this.context.scene.addComponent(ObjectRaycaster);
}
}
onEnable(): void {
this.context.input.addEventListener(InputEvents.PointerDown, this.onPointerEvent);
this.context.input.addEventListener(InputEvents.PointerUp, this.onPointerEvent);
this.context.input.addEventListener(InputEvents.PointerMove, this.onPointerEvent);
}
onDisable(): void {
this.context.input.removeEventListener(InputEvents.PointerDown, this.onPointerEvent);
this.context.input.removeEventListener(InputEvents.PointerUp, this.onPointerEvent);
this.context.input.removeEventListener(InputEvents.PointerMove, this.onPointerEvent);
}
/**
* all pointers that have pressed something
*
* key: pointerId
* value: object that was pressed, data of the pointer event, handlers that are releavant to the event
*/
private pressedByID: Map<number, { obj: Object3D | null, data: PointerEventData, handlers: Set<IPointerEventHandler> }> = new Map();
/**
* all hovered objects
*
* key: pointerId
* value: object that is hovered, data of the pointer event
*/
private hoveredByID: Map<number, { obj: Object3D, data: PointerEventData }> = new Map();
onBeforeRender() {
this.resetMeshUIStates();
}
/**
* Handle an pointer event from the input system
*/
private onPointerEvent = (pointerEvent: NEPointerEvent) => {
if (pointerEvent === undefined) return;
if (pointerEvent.propagationStopped) return;
if (pointerEvent.defaultPrevented) return;
if (pointerEvent.used) return;
// Use the pointerID, this is the touch id or the mouse (always 0, could be index of the mouse DEVICE) or the index of the XRInputSource
const data = new PointerEventData(this.context.input, pointerEvent);
this._currentPointerEventName = pointerEvent.type;
data.inputSource = this.context.input;
data.isClick = pointerEvent.isClick;
data.isDoubleClick = pointerEvent.isDoubleClick;
// using the input type directly instead of input API -> otherwise onMove events can sometimes be getPointerUp() == true
data.isDown = pointerEvent.type == InputEvents.PointerDown;
data.isUp = pointerEvent.type == InputEvents.PointerUp;
data.isPressed = this.context.input.getPointerPressed(pointerEvent.pointerId);
if (debug) {
if (data.isDown) console.log("DOWN", data.pointerId);
else if (data.isUp) console.log("UP", data.pointerId);
if (data.isClick) console.log("CLICK", data.pointerId);
}
// raycast
const options = new RaycastOptions();
if (pointerEvent.hasRay) {
options.ray = pointerEvent.ray;
}
else {
options.screenPoint = this.context.input.getPointerPositionRC(pointerEvent.pointerId)!;
}
const hits = this.performRaycast(options) as Array<NEPointerEventIntersection>;
if (hits) {
for (const hit of hits) {
hit.event = pointerEvent;
pointerEvent.intersections.push(hit);
}
if (pointerEvent.origin.onPointerHits) {
pointerEvent.origin.onPointerHits({
sender: this,
event: pointerEvent,
hits
});
}
}
if (debug && data.isClick) {
showBalloonMessage("EventSystem: " + data.pointerId + " - " + this.context.time.frame + " - Up:" + data.isUp + ", Down:" + data.isDown)
}
const evt: AfterHandleInputEvent = {
sender: this,
args: data,
hasActiveUI: this.currentActiveMeshUIComponents.length > 0,
}
this.dispatchEvent(new CustomEvent(EventSystemEvents.BeforeHandleInput, { detail: evt }));
// then handle the intersections and call the callbacks on the regular objects
this.handleIntersections(hits, data);
this.dispatchEvent(new CustomEvent<AfterHandleInputEvent>(EventSystemEvents.AfterHandleInput, { detail: evt }));
}
private readonly _sortedHits: Intersection[] = [];
/**
* cache for objects that we want to raycast against. It's cleared before each call to performRaycast invoking raycasters
*/
private readonly _testObjectsCache = new Map<Object3D, boolean>();
/** that's the raycaster that is CURRENTLY being used for raycasting (the shouldRaycastObject method uses this) */
private _currentlyActiveRaycaster: Raycaster | null = null;
private _currentPointerEventName: InputEventNames | null = null;
/**
* Checks if an object that we encounter has an event component and if it does, we add it to our objects cache
* If it doesnt we tell our raycasting system to ignore it and continue in the child hierarchy
* We do this to avoid raycasts against objects that are not going to be used by the event system
* Because there's no component callback to be invoked anyways.
* This is especially important to avoid expensive raycasts against SkinnedMeshes
*
* Further optimizations would be to check what type of event we're dealing with
* For example if an event component has only an onPointerClick method we don't need to raycast during movement events
* */
private shouldRaycastObject = (obj: Object3D): RaycastTestObjectReturnType => {
// TODO: this implementation below should be removed and we should regularly raycast objects in the scene unless marked as "do not raycast"
// with the introduction of the mesh-bvh based raycasting the performance impact should be greatly reduced. But this needs further testing
const raycasterOnObject = obj && "getComponent" in obj ? obj.getComponent(Raycaster) : null;
if (raycasterOnObject && raycasterOnObject != this._currentlyActiveRaycaster) {
return false;
}
// if (this._currentPointerEventName == "pointermove") {
// console.log(this.context.time.frame, obj.name, obj.type, obj.guid)
// }
// check if this object is actually a UI shadow hierarchy object
let uiOwner: Object3D | null = null;
const isUI = isUIObject(obj);
// if yes we want to grab the actual object that is the owner of the shadow dom
// and check that object for the event component
if (isUI) {
uiOwner = obj[$shadowDomOwner]?.gameObject;
}
// check if the object was seen previously
if (this._testObjectsCache.has(obj) || (uiOwner && this._testObjectsCache.has(uiOwner))) {
// if yes we check if it was previously stored as "YES WE NEED TO RAYCAST THIS"
const prev = this._testObjectsCache.get(obj)!;
if (prev === false) return "continue in children"
return true;
}
else {
// if the object has another raycaster component than the one that is currently raycasting, we ignore this here
// because then this other raycaster is responsible for raycasting this object
// const rc = GameObject.getComponent(obj, Raycaster);
// if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return false;
// the object was not yet seen so we test if it has an event component
let hasEventComponent = hasPointerEventComponent(obj, this._currentPointerEventName);
if (!hasEventComponent && uiOwner) hasEventComponent = hasPointerEventComponent(uiOwner, this._currentPointerEventName);
if (hasEventComponent) {
// it has an event component: we add it and all its children to the cache
// we don't need to do the same for the shadow component hierarchy
// because the next object that will be detecting that the shadow owner was already seen
this._testObjectsCache.set(obj, true);
for (const ch of obj.children) this.shouldRaycastObject_AddToYesCache(ch);
return true;
}
this._testObjectsCache.set(obj, false);
return "continue in children"
}
}
private shouldRaycastObject_AddToYesCache(obj: Object3D) {
// if the object has another raycaster component than the one that is currently raycasting, we ignore this here
// because then this other raycaster is responsible for raycasting this object
// const rc = GameObject.getComponent(obj, Raycaster);
// if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return;
this._testObjectsCache.set(obj, true);
for (const ch of obj.children) {
this.shouldRaycastObject_AddToYesCache(ch);
}
}
/** the raycast filter is always overriden */
private performRaycast(opts: RaycastOptions | null): Intersection[] | null {
if (!this.raycaster) return null;
// we clear the cache of previously seen objects
this._testObjectsCache.clear();
this._sortedHits.length = 0;
if (!opts) opts = new RaycastOptions();
opts.testObject = this.shouldRaycastObject;
for (const rc of this.raycaster) {
if (!rc.activeAndEnabled) continue;
this._currentlyActiveRaycaster = rc;
const res = rc.performRaycast(opts);
this._currentlyActiveRaycaster = null;
if (res && res.length > 0) {
// console.log(res.length, res.map(r => r.object.name));
this._sortedHits.push(...res);
}
}
this._sortedHits.sort((a, b) => {
return a.distance - b.distance;
});
return this._sortedHits;
}
private assignHitInformation(args: PointerEventData, hit?: Intersection) {
if (!hit) {
args.intersection = undefined;
args.point = undefined;
args.normal = undefined;
args.face = undefined;
args.distance = undefined;
args.instanceId = undefined;
}
else {
args.intersection = hit;
args.point = hit.point;
args.normal = hit.normal;
args.face = hit.face;
args.distance = hit.distance;
args.instanceId = hit.instanceId;
}
}
private handleIntersections(hits: Intersection[] | null | undefined, args: PointerEventData): boolean {
if (hits?.length) {
hits = this.sortCandidates(hits);
for (const hit of hits) {
if (args.event.immediatePropagationStopped) {
return false;
}
this.assignHitInformation(args, hit);
if (this.handleEventOnObject(hit.object, args)) {
return true;
}
}
}
// first invoke captured pointers
this.assignHitInformation(args, hits?.[0]);
this.invokePointerCapture(args);
// pointer has not hit any object to handle
// thus is not hovering over anything
const hoveredData = this.hoveredByID.get(args.pointerId);
if (hoveredData) {
this.propagatePointerExit(hoveredData.obj, hoveredData.data, null);
}
this.hoveredByID.delete(args.pointerId);
// if it was up, it means it should notify things that it down on before
if (args.isUp) {
this.pressedByID.get(args.pointerId)?.handlers.forEach(h => this.invokeOnPointerUp(args, h));
this.pressedByID.delete(args.pointerId);
}
return false;
}
private _sortingBuffer: Intersection[] = [];
private _noDepthTestingResults: Intersection[] = [];
private sortCandidates(hits: Intersection[]): Intersection[] {
// iterate over all hits and filter for nodepth objects and normal hit objects
// the no-depth objects will be handled first starting from the closest
// assuming the hits array is sorted by distance (closest > furthest)
this._sortingBuffer.length = 0;
this._noDepthTestingResults.length = 0;
for (let i = 0; i < hits.length; i++) {
const hit = hits[i];
const object = hit.object as Mesh;
if (object.material) {
if (object.material["depthTest"] === false) {
this._noDepthTestingResults.push(hit);
continue;
}
}
this._sortingBuffer.push(hit);
}
for (const obj of this._sortingBuffer) {
this._noDepthTestingResults.push(obj);
}
return this._noDepthTestingResults;
}
private out: { canvasGroup?: ICanvasGroup } = {};
/**
* Handle hit result by preparing all needed information before propagation.
* Then calling propagate.
*/
private handleEventOnObject(object: Object3D, args: PointerEventData): boolean {
// ensures that invisible objects are ignored
if (!this.testIsVisible(object)) {
if (args.isClick && debug)
console.log("not allowed", object);
return false;
}
// Event without pointer can't be handled
if (args.pointerId === undefined) {
if (debug) console.error("Event without pointer can't be handled", args);
return false;
}
// Correct the handled object to match the relevant object in shadow dom (?)
args.object = object;
const parent = object.parent as any;
let isShadow = false;
const clicked = args.isClick ?? false;
let canvasGroup: ICanvasGroup | null = null;
// handle potential shadow dom built from three mesh ui
if (parent && parent.isUI) {
const pressedOrClicked = (args.isPressed || args.isClick) ?? false;
if (parent[$shadowDomOwner]) {
const actualGo = parent[$shadowDomOwner].gameObject;
if (actualGo) {
const res = UIRaycastUtils.isInteractable(actualGo, this.out);
if (!res) return false;
canvasGroup = this.out.canvasGroup ?? null;
const handled = this.handleMeshUIIntersection(object, pressedOrClicked);
if (!clicked && handled) {
// return true;
}
object = actualGo;
isShadow = true;
}
}
// adding this to have a way for allowing to receive events on TMUI elements without shadow hierarchy
// if(parent["needle:use_eventsystem"] == true){
// // if use_eventsystem is true, we want to handle the event
// }
// else if (!isShadow) {
// const obj = this.handleMeshUiObjectWithoutShadowDom(parent, pressedOrClicked);
// if (obj) return true;
// }
}
if (clicked && debug)
console.log(this.context.time.frame, object);
// Handle OnPointerExit -> in case when we are about to hover something new
// TODO: we need to keep track of the components that already received a PointerEnterEvent -> we can have a hierarchy where the hovered object changes but the component is on the parent and should not receive a PointerExit event because it's still hovered (just another child object)
const hovering = this.hoveredByID.get(args.pointerId);
const prevHovering = hovering?.obj;
const isNewlyHovering = prevHovering !== object;
// trigger onPointerExit
if (isNewlyHovering && prevHovering) {
this.propagatePointerExit(prevHovering, hovering.data, object);
}
// save hovered object
const entry = this.hoveredByID.get(args.pointerId);
if (!entry)
this.hoveredByID.set(args.pointerId, { obj: object, data: args });
else {
entry.obj = object;
entry.data = args;
}
// create / update pressed entry
if (args.isDown) {
const data = this.pressedByID.get(args.pointerId);
if (!data)
this.pressedByID.set(args.pointerId, { obj: object, data: args, handlers: new Set<IPointerEventHandler>() });
else {
data.obj = object;
data.data = args;
}
}
if (canvasGroup === null || canvasGroup.interactable) {
this.handleMainInteraction(object, args, prevHovering ?? null);
}
return true;
}
/**
* Propagate up in hiearchy and call the callback for each component that is possibly a handler
*/
private propagate(object: Object3D | null, onComponent: (behaviour: Behaviour) => void) {
while (true) {
if (!object) break;
GameObject.foreachComponent(object, comp => {
// TODO: implement Stop Immediate Propagation
onComponent(comp);
}, false);
// walk up
object = object.parent;
}
}
/**
* Propagate up in hierarchy and call handlers based on the pointer event data
*/
private handleMainInteraction(object: Object3D, args: PointerEventData, prevHovering: Object3D | null) {
const pressedEvent = this.pressedByID.get(args.pointerId);
const hoveredObjectChanged = prevHovering !== object;
// TODO: should we not move this check up before we even raycast for "pointerMove" events? We dont need to do any processing if the pointer didnt move
let isMoving = true;
switch (args.event.pointerType) {
case "mouse":
case "touch":
const posLastFrame = this.context.input.getPointerPositionLastFrame(args.pointerId!)!;
const posThisFrame = this.context.input.getPointerPosition(args.pointerId!)!;
isMoving = posLastFrame && !Mathf.approximately(posLastFrame, posThisFrame);
break;
case "controller":
case "hand":
// for hands and controller we assume they are never totally still (except for simulated environments)
// we might want to add a threshold here (e.g. if a user holds their hand very still or controller)
// so maybe check the angle every frame?
break;
}
this.propagate(object, (behaviour) => {
const comp = behaviour as IComponentCanMaybeReceiveEvents;
if (comp.interactable === false) return;
if (!comp.activeAndEnabled || !comp.enabled) return;
if (comp.onPointerEnter) {
if (hoveredObjectChanged) {
this.handlePointerEnter(comp, args);
}
}
if (args.isDown) {
if (comp.onPointerDown) {
comp.onPointerDown(args);
// Set the handler that we called the down event on
// So we can call the up event on the same handler
// In a scenario where we Down on one object and Up on another
pressedEvent?.handlers.add(comp);
this.handlePointerCapture(args, comp);
}
}
if (comp.onPointerMove) {
if (isMoving)
comp.onPointerMove(args);
this.handlePointerCapture(args, comp);
}
if (args.isUp) {
if (comp.onPointerUp) {
this.invokeOnPointerUp(args, comp);
// We don't want to call Up twice if we Down and Up on the same object
// But if we Down on one and Up on another we want to call Up on the first one as well
// For example if the object was cloned by the Duplicatable
// The original component that received the down event SHOULD also receive the up event
pressedEvent?.handlers.delete(comp);
}
// handle touch onExit (touchUp) since the pointer stops existing
// mouse onExit (mouseUp) is handled when we hover over something else / on nothing
// Mouse 0 is always persistent
if (comp.onPointerExit && args.event?.pointerType === PointerType.Touch) {
this.handlePointerExit(comp, args);
this.hoveredByID.delete(args.pointerId!);
}
}
if (args.isClick) {
if (comp.onPointerClick) {
comp.onPointerClick(args);
}
}
});
// after the propagation end, call UP on any objects that were DOWNED and didn't recieve an UP while propagating
// If user drags away from the object, then it doesn't get the UP event
if (args.isUp) {
pressedEvent?.handlers.forEach((handler) => {
this.invokeOnPointerUp(args, handler);
});
this.pressedByID.delete(args.pointerId);
}
}
/** Propagate up in hierarchy and call onPointerExit */
private propagatePointerExit(object: Object3D, args: PointerEventData, newObject: Object3D | null) {
this.propagate(object, (behaviour) => {
if (!behaviour.gameObject || behaviour.destroyed) return;
const inst: any = behaviour;
if (inst.onPointerExit || inst.onPointerEnter) {
// if the newly hovered object is a child of the current object, we don't want to call onPointerExit
if (newObject && this.isChild(newObject, behaviour.gameObject)) {
return;
}
this.handlePointerExit(inst, args);
}
});
}
/** handles onPointerUp - this will also release the pointerCapture */
private invokeOnPointerUp(evt: PointerEventData, handler: IPointerUpHandler) {
handler.onPointerUp?.call(handler, evt);
this.releasePointerCapture(evt, handler);
}
/** Responsible for invoking onPointerEnter (and updating onPointerExit). We invoke onPointerEnter once per active pointerId */
private handlePointerEnter(comp: IComponentCanMaybeReceiveEvents, args: PointerEventData) {
if (comp.onPointerEnter) {
if (this.updatePointerState(comp, args.pointerId, this.pointerEnterSymbol, true)) {
comp.onPointerEnter(args);
}
}
this.updatePointerState(comp, args.pointerId, this.pointerExitSymbol, false);
}
/** Responsible for invoking onPointerExit (and updating onPointerEnter). We invoke onPointerExit once per active pointerId */
private handlePointerExit(comp: IComponentCanMaybeReceiveEvents, evt: PointerEventData) {
if (comp.onPointerExit) {
if (this.updatePointerState(comp, evt.pointerId, this.pointerExitSymbol, true)) {
comp.onPointerExit(evt);
}
}
this.updatePointerState(comp, evt.pointerId, this.pointerEnterSymbol, false);
}
/** updates the pointer state list for a component
* @param comp the component to update
* @param pointerId the pointerId to update
* @param symbol the symbol to use for the state
* @param add if true, the pointerId is added to the state list, if false the pointerId will be removed
*/
private updatePointerState(comp: IComponentCanMaybeReceiveEvents, pointerId: number, symbol: symbol, add: boolean) {
let state = comp[symbol];
if (add) {
// the pointer is already in the state list
if (state && state.includes(pointerId)) return false;
state = state || [];
state.push(pointerId);
comp[symbol] = state;
return true;
}
else {
if (!state || !state.includes(pointerId)) return false;
const i = state.indexOf(pointerId);
if (i !== -1) {
state.splice(i, 1);
}
return true;
}
}
/** the list of component handlers that requested pointerCapture for a specific pointerId */
private readonly _capturedPointer: { [id: number]: IPointerEventHandler[] } = {};
/** check if the event was marked to be captured: if yes add the current component to the captured list */
private handlePointerCapture(evt: PointerEventData, comp: IPointerEventHandler) {
if (evt.z__pointer_ctured) {
evt.z__pointer_ctured = false;
const id = evt.pointerId;
// only the onPointerMove event is called with captured pointers so we don't need to add it to our list if it doesnt implement onPointerMove
if (comp.onPointerMove) {
const list = this._capturedPointer[id] || [];
list.push(comp);
this._capturedPointer[id] = list;
}
else {
if (isDevEnvironment() && !comp["z__warned_no_pointermove"]) {
comp["z__warned_no_pointermove"] = true;
console.warn("PointerCapture was requested but the component doesn't implement onPointerMove. It will not receive any pointer events");
}
}
}
else if (evt.z__pointer_cture_rleased) {
evt.z__pointer_cture_rleased = false;
this.releasePointerCapture(evt, comp);
}
}
/** removes the component from the pointer capture list */
releasePointerCapture(evt: PointerEventData, component: IPointerEventHandler) {
const id = evt.pointerId;
if (this._capturedPointer[id]) {
const i = this._capturedPointer[id].indexOf(component);
if (i !== -1) {
this._capturedPointer[id].splice(i, 1);
if (debug) console.log("released pointer capture", id, component, this._capturedPointer)
}
}
}
/** invoke the pointerMove event on all captured handlers */
private invokePointerCapture(evt: PointerEventData) {
if (evt.event.type === InputEvents.PointerMove) {
const id = evt.pointerId;
const captured = this._capturedPointer[id];
if (captured) {
if (debug) console.log("Captured", id, captured)
for (let i = 0; i < captured.length; i++) {
const handler = captured[i];
// check if it was destroyed
const comp = handler as IComponent;
if (comp.destroyed) {
captured.splice(i, 1);
i--;
continue;
}
// invoke pointer move
handler.onPointerMove?.call(handler, evt);
}
}
}
}
private readonly pointerEnterSymbol = Symbol("pointerEnter");
private readonly pointerExitSymbol = Symbol("pointerExit");
private isChild(obj: Object3D, possibleChild: Object3D): boolean {
if (!obj || !possibleChild) return false;
if (obj === possibleChild) return true;
if (!obj.parent) return false;
return this.isChild(obj.parent, possibleChild);
}
private handleMeshUiObjectWithoutShadowDom(obj: any, pressed: boolean) {
if (!obj || !obj.isUI) return true;
const hit = this.handleMeshUIIntersection(obj, pressed);
return hit;
}
private currentActiveMeshUIComponents: Object3D[] = [];
private handleMeshUIIntersection(meshUiObject: Object3D, pressed: boolean): boolean {
const res = MeshUIHelper.updateState(meshUiObject, pressed);
if (res) {
this.currentActiveMeshUIComponents.push(res);
}
return res !== null;
}
private resetMeshUIStates() {
if (this.context.input.getPointerPressedCount() > 0) {
MeshUIHelper.resetLastSelected();
}
if (!this.currentActiveMeshUIComponents || this.currentActiveMeshUIComponents.length <= 0) return;
for (let i = 0; i < this.currentActiveMeshUIComponents.length; i++) {
const comp = this.currentActiveMeshUIComponents[i];
MeshUIHelper.resetState(comp);
}
this.currentActiveMeshUIComponents.length = 0;
}
private testIsVisible(obj: Object3D | null): boolean {
if (!obj) return true;
if (!GameObject.isActiveSelf(obj)) return false;
return this.testIsVisible(obj.parent);
}
}
class MeshUIHelper {
private static lastSelected: Object3D | null = null;
private static lastUpdateFrame: { context: Context, frame: number, nextUpdate: number }[] = [];
private static needsUpdate: boolean = false;
static markDirty() {
this.needsUpdate = true;
}
static update(threeMeshUI: any, context: Context, force: boolean = false) {
if (force) {
threeMeshUI.update();
return;
}
const currentFrame = context.time.frameCount;
for (const lu of this.lastUpdateFrame) {
if (lu.context === context) {
if (currentFrame === lu.frame) return;
lu.frame = currentFrame;
let shouldUpdate = this.needsUpdate || currentFrame < 1;
if (lu.nextUpdate <= currentFrame) shouldUpdate = true;
// if (this.needsUpdate) lu.nextUpdate = currentFrame + 3;
if (shouldUpdate) {
// console.warn(currentFrame, lu.nextUpdate, this.needsUpdate)
if (debug) console.log("Update threemeshui");
this.needsUpdate = false;
lu.nextUpdate = currentFrame + 60;
threeMeshUI.update();
}
return;
}
}
this.lastUpdateFrame = [{ context, frame: currentFrame, nextUpdate: currentFrame + 60 }];
threeMeshUI.update();
this.needsUpdate = false;
}
static updateState(intersect: Object3D, _selectState: boolean): Object3D | null {
let foundBlock: Object3D | null = null;
if (intersect) {
foundBlock = this.findBlockOrTextInParent(intersect);
// console.log(intersect, "-- found block:", foundBlock)
if (foundBlock && foundBlock !== this.lastSelected) {
const interactable = foundBlock["interactable"];
if (interactable === false) return null;
this.needsUpdate = true;
}
}
return foundBlock;
}
static resetLastSelected() {
const last = this.lastSelected;
if (!last) return;
this.lastSelected = null;
this.resetState(last);
}
static resetState(obj: any) {
if (!obj) return;
this.needsUpdate = true;
}
static findBlockOrTextInParent(elem: any): Object3D | null {
if (!elem) return null;
if (elem.isBlock || (elem.isText)) {
// @TODO : Replace states managements
// if (Object.keys(elem.states).length > 0)
return elem;
}
return this.findBlockOrTextInParent(elem.parent);
}
}