diff --git a/editor/js/Config.js b/editor/js/Config.js index 620596d1dd62bd..5210b74ff682d3 100644 --- a/editor/js/Config.js +++ b/editor/js/Config.js @@ -32,7 +32,8 @@ function Config() { 'settings/shortcuts/undo': 'z', 'settings/shortcuts/focus': 'f', 'settings/shortcuts/perspective': 'p', - 'settings/shortcuts/orthographic': 'o' + 'settings/shortcuts/orthographic': 'o', + 'settings/shortcuts/selectAll': 'a' }; if ( window.localStorage[ name ] === undefined ) { diff --git a/editor/js/Selector.js b/editor/js/Selector.js index b6703b8f1fde6c..d32d079496f45d 100644 --- a/editor/js/Selector.js +++ b/editor/js/Selector.js @@ -3,6 +3,12 @@ import * as THREE from 'three'; const mouse = new THREE.Vector2(); const raycaster = new THREE.Raycaster(); +const _box = new THREE.Box3(); +const _vector = new THREE.Vector3(); +const _deltaMatrix = new THREE.Matrix4(); +const _objectMatrix = new THREE.Matrix4(); +const _parentMatrixInverse = new THREE.Matrix4(); + class Selector { constructor( editor ) { @@ -12,9 +18,18 @@ class Selector { this.editor = editor; this.signals = signals; + this.selection = []; + + // an intermediate group used as pivot when transforming multiple objects + + this.group = new THREE.Group(); + + this._groupMatrixWorldInverse = new THREE.Matrix4(); + this._memberStates = []; + // signals - signals.intersectionsDetected.add( ( intersects ) => { + signals.intersectionsDetected.add( ( intersects, shiftKey ) => { if ( intersects.length > 0 ) { @@ -40,23 +55,53 @@ class Selector { } - // Cycle through objects if the first one is already selected - - const index = objects.indexOf( editor.selected ); + if ( shiftKey === true && this.selection.length > 0 && editor.selected !== editor.scene && editor.selected !== editor.camera ) { - if ( index !== - 1 && index < objects.length - 1 ) { + // Shift-click toggles membership in the current selection - this.select( objects[ index + 1 ] ); + this.toggle( objects[ 0 ] ); } else { - this.select( objects[ 0 ] ); + // Cycle through objects if the first one is already selected + + const index = objects.indexOf( editor.selected ); + + if ( index !== - 1 && index < objects.length - 1 ) { + + this.select( objects[ index + 1 ] ); + + } else { + + this.select( objects[ 0 ] ); + + } } } else { - this.select( null ); + if ( shiftKey !== true ) this.select( null ); // keep the selection when shift-clicking empty space + + } + + } ); + + signals.objectChanged.add( ( object ) => { + + if ( this.selection.length < 2 ) return; + + if ( object === this.group ) { + + // the group has been transformed (e.g. via TransformControls), sync its members + + this.applyGroupTransform(); + + } else if ( this.selection.indexOf( object ) !== - 1 ) { + + // a member has been changed independently (e.g. via undo/redo), re-anchor the pivot + + this.updateGroup(); } @@ -94,22 +139,132 @@ class Selector { } + getSelectionBox( target ) { + + target.makeEmpty(); + + for ( let i = 0; i < this.selection.length; i ++ ) { + + const object = this.selection[ i ]; + + target.expandByObject( object, true ); + target.expandByPoint( object.getWorldPosition( _vector ) ); // objects without geometry (e.g. lights) + + } + + return target; + + } + select( object ) { - if ( this.editor.selected === object ) return; + this.setSelection( object === null ? [] : [ object ] ); - let uuid = null; + } - if ( object !== null ) { + toggle( object ) { - uuid = object.uuid; + const selection = this.selection.slice(); + + const index = selection.indexOf( object ); + + if ( index === - 1 ) { + + selection.push( object ); + + } else { + + selection.splice( index, 1 ); } - this.editor.selected = object; - this.editor.config.setKey( 'selected', uuid ); + this.setSelection( selection ); + + } + + setSelection( objects ) { + + const editor = this.editor; + + const hadGroupSelection = this.group.parent !== null; + + this.selection = objects.slice(); - this.signals.objectSelected.dispatch( object ); + if ( objects.length > 1 ) { + + editor.sceneHelpers.add( this.group ); + this.updateGroup(); + + editor.selected = null; + editor.config.setKey( 'selected', null ); + + this.signals.objectSelected.dispatch( null ); + + } else { + + const object = ( objects.length === 1 ) ? objects[ 0 ] : null; + + editor.sceneHelpers.remove( this.group ); + + if ( editor.selected === object && hadGroupSelection === false ) return; + + editor.selected = object; + editor.config.setKey( 'selected', ( object !== null ) ? object.uuid : null ); + + this.signals.objectSelected.dispatch( object ); + + } + + } + + updateGroup() { + + const group = this.group; + + // use the center of the selection's AABB as pivot point + + this.getSelectionBox( _box ).getCenter( group.position ); + group.quaternion.identity(); + group.scale.set( 1, 1, 1 ); + group.updateWorldMatrix(); + + this._groupMatrixWorldInverse.copy( group.matrixWorld ).invert(); + + // members with a selected ancestor are updated by that ancestor's transform + + const members = this.selection.filter( ( object ) => hasSelectedAncestor( object, this.selection ) === false ); + + this._memberStates = members.map( ( object ) => ( { object: object, matrixWorld: object.matrixWorld.clone() } ) ); + + } + + applyGroupTransform() { + + const group = this.group; + + group.updateWorldMatrix(); + + _deltaMatrix.multiplyMatrices( group.matrixWorld, this._groupMatrixWorldInverse ); + + const states = this._memberStates; + + for ( let i = 0; i < states.length; i ++ ) { + + const object = states[ i ].object; + const parent = object.parent; + + _objectMatrix.multiplyMatrices( _deltaMatrix, states[ i ].matrixWorld ); + _parentMatrixInverse.copy( parent.matrixWorld ).invert(); + _objectMatrix.premultiply( _parentMatrixInverse ); + _objectMatrix.decompose( object.position, object.quaternion, object.scale ); + + object.updateWorldMatrix( false, true ); + + const helper = this.editor.helpers[ object.id ]; + + if ( helper !== undefined && helper.isSkeletonHelper !== true ) helper.update(); + + } } @@ -121,4 +276,20 @@ class Selector { } +function hasSelectedAncestor( object, selection ) { + + let parent = object.parent; + + while ( parent !== null ) { + + if ( selection.indexOf( parent ) !== - 1 ) return true; + + parent = parent.parent; + + } + + return false; + +} + export { Selector }; diff --git a/editor/js/Sidebar.Scene.js b/editor/js/Sidebar.Scene.js index 4d517cab4747d0..deec653b589527 100644 --- a/editor/js/Sidebar.Scene.js +++ b/editor/js/Sidebar.Scene.js @@ -131,11 +131,28 @@ function SidebarScene( editor ) { const outliner = new UIOutliner( editor ); outliner.setId( 'outliner' ); - outliner.onChange( function () { + outliner.onChange( function ( event ) { + + const id = parseInt( outliner.getValue() ); + + if ( event.shiftKey === true && editor.selector.selection.length > 0 && editor.selected !== editor.scene && editor.selected !== editor.camera ) { + + const object = ( id === editor.camera.id ) ? editor.camera : editor.scene.getObjectById( id ); + + // scene and main camera can't be part of a group selection + + if ( object !== editor.scene && object !== editor.camera ) { + + editor.selector.toggle( object ); + return; + + } + + } ignoreObjectSelectedSignal = true; - editor.selectById( parseInt( outliner.getValue() ) ); + editor.selectById( id ); ignoreObjectSelectedSignal = false; @@ -414,6 +431,18 @@ function SidebarScene( editor ) { outliner.setValue( editor.selected.id ); + } else { + + const selection = editor.selector.selection; + + if ( selection.length > 1 ) { + + // restore highlights of a group selection (e.g. after a graph change) + + outliner.setValues( selection.map( ( object ) => object.id ) ); + + } + } backgroundType.setValue( editor.backgroundType ); @@ -542,30 +571,57 @@ function SidebarScene( editor ) { } ); + function expandAncestors( object ) { + + let needsRefresh = false; + let parent = object.parent; + + while ( parent !== editor.scene ) { + + if ( nodeStates.get( parent ) !== true ) { + + nodeStates.set( parent, true ); + needsRefresh = true; + + } + + parent = parent.parent; + + } + + return needsRefresh; + + } + signals.objectSelected.add( function ( object ) { if ( ignoreObjectSelectedSignal === true ) return; - if ( object !== null && object.parent !== null ) { + const selection = editor.selector.selection; - let needsRefresh = false; - let parent = object.parent; + if ( selection.length > 1 ) { - while ( parent !== editor.scene ) { + // highlight all members of a group selection - if ( nodeStates.get( parent ) !== true ) { + let needsRefresh = false; - nodeStates.set( parent, true ); - needsRefresh = true; + const values = []; - } + for ( let i = 0; i < selection.length; i ++ ) { - parent = parent.parent; + values.push( selection[ i ].id ); + needsRefresh = expandAncestors( selection[ i ] ) || needsRefresh; } if ( needsRefresh ) refreshUI(); + outliner.setValues( values ); + + } else if ( object !== null && object.parent !== null ) { + + if ( expandAncestors( object ) ) refreshUI(); + outliner.setValue( object.id ); } else { diff --git a/editor/js/Sidebar.Settings.Shortcuts.js b/editor/js/Sidebar.Settings.Shortcuts.js index 20b92a5ba0a513..5e6c37e4407f3d 100644 --- a/editor/js/Sidebar.Settings.Shortcuts.js +++ b/editor/js/Sidebar.Settings.Shortcuts.js @@ -24,7 +24,7 @@ function SidebarSettingsShortcuts( editor ) { headerRow.add( new UIText( strings.getKey( 'sidebar/settings/shortcuts' ).toUpperCase() ) ); container.add( headerRow ); - const shortcuts = [ 'translate', 'rotate', 'scale', 'undo', 'focus', 'perspective', 'orthographic' ]; + const shortcuts = [ 'translate', 'rotate', 'scale', 'undo', 'focus', 'perspective', 'orthographic', 'selectAll' ]; function createShortcutInput( name ) { @@ -106,27 +106,81 @@ function SidebarSettingsShortcuts( editor ) { // fall-through - case 'delete': + case 'delete': { - const object = editor.selected; + const objects = editor.selector.selection; - if ( object === null || object.parent === null ) return; + const commands = []; - if ( object.isSpotLight || object.isDirectionalLight ) { + for ( let i = 0; i < objects.length; i ++ ) { - editor.execute( new MultiCmdsCommand( editor, [ - new RemoveObjectCommand( editor, object ), - new RemoveObjectCommand( editor, object.target ) - ] ) ); + const object = objects[ i ]; + + if ( object.parent === null ) continue; // avoid deleting the camera or scene + + if ( object.isSpotLight || object.isDirectionalLight ) { + + commands.push( new RemoveObjectCommand( editor, object ) ); + commands.push( new RemoveObjectCommand( editor, object.target ) ); + + } else { + + commands.push( new RemoveObjectCommand( editor, object ) ); + + } + + } + + if ( commands.length === 1 ) { + + editor.execute( commands[ 0 ] ); + + } else if ( commands.length > 1 ) { + + editor.execute( new MultiCmdsCommand( editor, commands ) ); + + } + + break; + + } + + case config.getKey( 'settings/shortcuts/selectAll' ): { + + if ( event.altKey === true || event.ctrlKey === true || event.metaKey === true ) break; + + // toggle between selecting and deselecting all scene objects + + const objects = editor.scene.children; + const selection = editor.selector.selection; + + let allSelected = objects.length > 0; + + for ( let i = 0; i < objects.length; i ++ ) { + + if ( selection.indexOf( objects[ i ] ) === - 1 ) { + + allSelected = false; + break; + + } + + } + + if ( allSelected === true ) { + + editor.deselect(); } else { - editor.execute( new RemoveObjectCommand( editor, object ) ); + editor.selector.setSelection( objects ); } break; + } + case config.getKey( 'settings/shortcuts/translate' ): signals.transformModeChanged.dispatch( 'translate' ); diff --git a/editor/js/Strings.js b/editor/js/Strings.js index 7627143f21731a..cc429b3c599dd8 100644 --- a/editor/js/Strings.js +++ b/editor/js/Strings.js @@ -400,6 +400,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': 'مقیاس', 'sidebar/settings/shortcuts/undo': 'بازگشت به عقب', 'sidebar/settings/shortcuts/focus': 'فوکوس', + 'sidebar/settings/shortcuts/selectAll': 'انتخاب همه', 'sidebar/history': 'هیستوری', 'sidebar/history/clear': 'پاک کردن', @@ -852,6 +853,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/focus': 'Focus', 'sidebar/settings/shortcuts/perspective': 'Perspective', 'sidebar/settings/shortcuts/orthographic': 'Orthographic', + 'sidebar/settings/shortcuts/selectAll': 'Select All', 'sidebar/history': 'History', 'sidebar/history/clear': 'Clear', @@ -1302,6 +1304,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': 'Échelle', 'sidebar/settings/shortcuts/undo': 'Annuler', 'sidebar/settings/shortcuts/focus': 'Focus', + 'sidebar/settings/shortcuts/selectAll': 'Tout sélectionner', 'sidebar/history': 'Historique', 'sidebar/history/clear': 'Supprimer', @@ -1752,6 +1755,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': '缩放', 'sidebar/settings/shortcuts/undo': '撤销', 'sidebar/settings/shortcuts/focus': '聚焦', + 'sidebar/settings/shortcuts/selectAll': '全选', 'sidebar/history': '历史记录', 'sidebar/history/clear': '清空', @@ -2202,6 +2206,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': 'スケール', 'sidebar/settings/shortcuts/undo': '元に戻す', 'sidebar/settings/shortcuts/focus': 'フォーカス', + 'sidebar/settings/shortcuts/selectAll': 'すべて選択', 'sidebar/history': '履歴', 'sidebar/history/clear': 'クリア', @@ -2651,6 +2656,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': '스케일', 'sidebar/settings/shortcuts/undo': '되돌리기', 'sidebar/settings/shortcuts/focus': '포커스', + 'sidebar/settings/shortcuts/selectAll': '모두 선택', 'sidebar/history': '기록', 'sidebar/history/clear': '지우기', diff --git a/editor/js/Viewport.js b/editor/js/Viewport.js index f399da7235bf74..2a9027f0d3e95a 100644 --- a/editor/js/Viewport.js +++ b/editor/js/Viewport.js @@ -16,6 +16,7 @@ import { XR } from './Viewport.XR.js'; import { SetPositionCommand } from './commands/SetPositionCommand.js'; import { SetRotationCommand } from './commands/SetRotationCommand.js'; import { SetScaleCommand } from './commands/SetScaleCommand.js'; +import { MultiCmdsCommand } from './commands/MultiCmdsCommand.js'; import { ColorEnvironment } from 'three/addons/environments/ColorEnvironment.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; @@ -72,9 +73,7 @@ function Viewport( editor ) { selectionBox.visible = false; sceneHelpers.add( selectionBox ); - let objectPositionOnDown = null; - let objectRotationOnDown = null; - let objectScaleOnDown = null; + let objectStatesOnDown = []; const transformControls = new TransformControls( camera ); transformControls.addEventListener( 'axis-changed', function () { @@ -91,50 +90,56 @@ function Viewport( editor ) { const object = transformControls.object; - objectPositionOnDown = object.position.clone(); - objectRotationOnDown = object.rotation.clone(); - objectScaleOnDown = object.scale.clone(); + const objects = ( object === selector.group ) ? selector.selection : [ object ]; + + objectStatesOnDown = objects.map( ( object ) => ( { + object: object, + position: object.position.clone(), + rotation: object.rotation.clone(), + scale: object.scale.clone() + } ) ); controls.enabled = false; } ); transformControls.addEventListener( 'mouseUp', function () { - const object = transformControls.object; + if ( transformControls.object !== undefined ) { - if ( object !== undefined ) { + const commands = []; - switch ( transformControls.getMode() ) { + for ( let i = 0; i < objectStatesOnDown.length; i ++ ) { - case 'translate': + const state = objectStatesOnDown[ i ]; + const object = state.object; - if ( ! objectPositionOnDown.equals( object.position ) ) { + if ( ! state.position.equals( object.position ) ) { - editor.execute( new SetPositionCommand( editor, object, object.position, objectPositionOnDown ) ); + commands.push( new SetPositionCommand( editor, object, object.position, state.position ) ); - } + } - break; + if ( ! state.rotation.equals( object.rotation ) ) { - case 'rotate': + commands.push( new SetRotationCommand( editor, object, object.rotation, state.rotation ) ); - if ( ! objectRotationOnDown.equals( object.rotation ) ) { + } - editor.execute( new SetRotationCommand( editor, object, object.rotation, objectRotationOnDown ) ); + if ( ! state.scale.equals( object.scale ) ) { - } + commands.push( new SetScaleCommand( editor, object, object.scale, state.scale ) ); - break; + } - case 'scale': + } - if ( ! objectScaleOnDown.equals( object.scale ) ) { + if ( commands.length === 1 ) { - editor.execute( new SetScaleCommand( editor, object, object.scale, objectScaleOnDown ) ); + editor.execute( commands[ 0 ] ); - } + } else if ( commands.length > 1 ) { - break; + editor.execute( new MultiCmdsCommand( editor, commands ) ); } @@ -193,12 +198,12 @@ function Viewport( editor ) { } - function handleClick() { + function handleClick( event ) { if ( onDownPosition.distanceTo( onUpPosition ) === 0 ) { const intersects = selector.getPointerIntersects( onUpPosition, camera ); - signals.intersectionsDetected.dispatch( intersects ); + signals.intersectionsDetected.dispatch( intersects, event.shiftKey ); render(); @@ -224,7 +229,7 @@ function Viewport( editor ) { const array = getMousePosition( container.dom, event.clientX, event.clientY ); onUpPosition.fromArray( array ); - handleClick(); + handleClick( event ); document.removeEventListener( 'mouseup', onMouseUp ); @@ -248,7 +253,7 @@ function Viewport( editor ) { const array = getMousePosition( container.dom, touch.clientX, touch.clientY ); onUpPosition.fromArray( array ); - handleClick(); + handleClick( event ); document.removeEventListener( 'touchend', onTouchEnd ); @@ -441,7 +446,14 @@ function Viewport( editor ) { selectionBox.visible = false; transformControls.detach(); - if ( object !== null && object !== scene && object !== camera ) { + if ( selector.selection.length > 1 ) { + + selector.getSelectionBox( box ); + + selectionBox.visible = true; + transformControls.attach( selector.group ); + + } else if ( object !== null && object !== scene && object !== camera ) { box.setFromObject( object, true ); @@ -484,6 +496,10 @@ function Viewport( editor ) { box.setFromObject( object, true ); + } else if ( selector.selection.length > 1 && ( object === selector.group || selector.selection.indexOf( object ) !== - 1 ) ) { + + selector.getSelectionBox( box ); + } if ( object.isPerspectiveCamera ) { diff --git a/editor/js/libs/ui.three.js b/editor/js/libs/ui.three.js index 0ac5490810715c..8d84c7007eb237 100644 --- a/editor/js/libs/ui.three.js +++ b/editor/js/libs/ui.three.js @@ -349,11 +349,12 @@ class UIOutliner extends UIDiv { } - function onClick() { + function onClick( event ) { scope.setValue( this.value ); const changeEvent = new Event( 'change', { bubbles: true, cancelable: true } ); + changeEvent.shiftKey = event.shiftKey; scope.dom.dispatchEvent( changeEvent ); } @@ -551,6 +552,31 @@ class UIOutliner extends UIDiv { } + setValues( values ) { + + for ( let i = 0; i < this.options.length; i ++ ) { + + const element = this.options[ i ]; + + if ( values.indexOf( element.value ) !== - 1 ) { + + element.classList.add( 'active' ); + + } else { + + element.classList.remove( 'active' ); + + } + + } + + this.selectedIndex = - 1; + this.selectedValue = null; + + return this; + + } + } class UIPoints extends UISpan { diff --git a/examples/jsm/libs/ecsy.module.js b/examples/jsm/libs/ecsy.module.js deleted file mode 100644 index 6f6759921b9c7b..00000000000000 --- a/examples/jsm/libs/ecsy.module.js +++ /dev/null @@ -1,1792 +0,0 @@ -/** - * Return the name of a component - * @param {Component} Component - * @private - */ - -/** - * Get a key from a list of components - * @param {Array(Component)} Components Array of components to generate the key - * @private - */ -function queryKey(Components) { - var ids = []; - for (var n = 0; n < Components.length; n++) { - var T = Components[n]; - - if (!componentRegistered(T)) { - throw new Error(`Tried to create a query with an unregistered component`); - } - - if (typeof T === "object") { - var operator = T.operator === "not" ? "!" : T.operator; - ids.push(operator + T.Component._typeId); - } else { - ids.push(T._typeId); - } - } - - return ids.sort().join("-"); -} - -// Detector for browser's "window" -const hasWindow = typeof window !== "undefined"; - -// performance.now() "polyfill" -const now = - hasWindow && typeof window.performance !== "undefined" - ? performance.now.bind(performance) - : Date.now.bind(Date); - -function componentRegistered(T) { - return ( - (typeof T === "object" && T.Component._typeId !== undefined) || - (T.isComponent && T._typeId !== undefined) - ); -} - -class SystemManager { - constructor(world) { - this._systems = []; - this._executeSystems = []; // Systems that have `execute` method - this.world = world; - this.lastExecutedSystem = null; - } - - registerSystem(SystemClass, attributes) { - if (!SystemClass.isSystem) { - throw new Error( - `System '${SystemClass.name}' does not extend 'System' class` - ); - } - - if (this.getSystem(SystemClass) !== undefined) { - console.warn(`System '${SystemClass.getName()}' already registered.`); - return this; - } - - var system = new SystemClass(this.world, attributes); - if (system.init) system.init(attributes); - system.order = this._systems.length; - this._systems.push(system); - if (system.execute) { - this._executeSystems.push(system); - this.sortSystems(); - } - return this; - } - - unregisterSystem(SystemClass) { - let system = this.getSystem(SystemClass); - if (system === undefined) { - console.warn( - `Can unregister system '${SystemClass.getName()}'. It doesn't exist.` - ); - return this; - } - - this._systems.splice(this._systems.indexOf(system), 1); - - if (system.execute) { - this._executeSystems.splice(this._executeSystems.indexOf(system), 1); - } - - // @todo Add system.unregister() call to free resources - return this; - } - - sortSystems() { - this._executeSystems.sort((a, b) => { - return a.priority - b.priority || a.order - b.order; - }); - } - - getSystem(SystemClass) { - return this._systems.find((s) => s instanceof SystemClass); - } - - getSystems() { - return this._systems; - } - - removeSystem(SystemClass) { - var index = this._systems.indexOf(SystemClass); - if (!~index) return; - - this._systems.splice(index, 1); - } - - executeSystem(system, delta, time) { - if (system.initialized) { - if (system.canExecute()) { - let startTime = now(); - system.execute(delta, time); - system.executeTime = now() - startTime; - this.lastExecutedSystem = system; - system.clearEvents(); - } - } - } - - stop() { - this._executeSystems.forEach((system) => system.stop()); - } - - execute(delta, time, forcePlay) { - this._executeSystems.forEach( - (system) => - (forcePlay || system.enabled) && this.executeSystem(system, delta, time) - ); - } - - stats() { - var stats = { - numSystems: this._systems.length, - systems: {}, - }; - - for (var i = 0; i < this._systems.length; i++) { - var system = this._systems[i]; - var systemStats = (stats.systems[system.getName()] = { - queries: {}, - executeTime: system.executeTime, - }); - for (var name in system.ctx) { - systemStats.queries[name] = system.ctx[name].stats(); - } - } - - return stats; - } -} - -class ObjectPool { - // @todo Add initial size - constructor(T, initialSize) { - this.freeList = []; - this.count = 0; - this.T = T; - this.isObjectPool = true; - - if (typeof initialSize !== "undefined") { - this.expand(initialSize); - } - } - - acquire() { - // Grow the list by 20%ish if we're out - if (this.freeList.length <= 0) { - this.expand(Math.round(this.count * 0.2) + 1); - } - - var item = this.freeList.pop(); - - return item; - } - - release(item) { - item.reset(); - this.freeList.push(item); - } - - expand(count) { - for (var n = 0; n < count; n++) { - var clone = new this.T(); - clone._pool = this; - this.freeList.push(clone); - } - this.count += count; - } - - totalSize() { - return this.count; - } - - totalFree() { - return this.freeList.length; - } - - totalUsed() { - return this.count - this.freeList.length; - } -} - -/** - * @private - * @class EventDispatcher - */ -class EventDispatcher { - constructor() { - this._listeners = {}; - this.stats = { - fired: 0, - handled: 0, - }; - } - - /** - * Add an event listener - * @param {String} eventName Name of the event to listen - * @param {Function} listener Callback to trigger when the event is fired - */ - addEventListener(eventName, listener) { - let listeners = this._listeners; - if (listeners[eventName] === undefined) { - listeners[eventName] = []; - } - - if (listeners[eventName].indexOf(listener) === -1) { - listeners[eventName].push(listener); - } - } - - /** - * Check if an event listener is already added to the list of listeners - * @param {String} eventName Name of the event to check - * @param {Function} listener Callback for the specified event - */ - hasEventListener(eventName, listener) { - return ( - this._listeners[eventName] !== undefined && - this._listeners[eventName].indexOf(listener) !== -1 - ); - } - - /** - * Remove an event listener - * @param {String} eventName Name of the event to remove - * @param {Function} listener Callback for the specified event - */ - removeEventListener(eventName, listener) { - var listenerArray = this._listeners[eventName]; - if (listenerArray !== undefined) { - var index = listenerArray.indexOf(listener); - if (index !== -1) { - listenerArray.splice(index, 1); - } - } - } - - /** - * Dispatch an event - * @param {String} eventName Name of the event to dispatch - * @param {Entity} entity (Optional) Entity to emit - * @param {Component} component - */ - dispatchEvent(eventName, entity, component) { - this.stats.fired++; - - var listenerArray = this._listeners[eventName]; - if (listenerArray !== undefined) { - var array = listenerArray.slice(0); - - for (var i = 0; i < array.length; i++) { - array[i].call(this, entity, component); - } - } - } - - /** - * Reset stats counters - */ - resetCounters() { - this.stats.fired = this.stats.handled = 0; - } -} - -class Query { - /** - * @param {Array(Component)} Components List of types of components to query - */ - constructor(Components, manager) { - this.Components = []; - this.NotComponents = []; - - Components.forEach((component) => { - if (typeof component === "object") { - this.NotComponents.push(component.Component); - } else { - this.Components.push(component); - } - }); - - if (this.Components.length === 0) { - throw new Error("Can't create a query without components"); - } - - this.entities = []; - - this.eventDispatcher = new EventDispatcher(); - - // This query is being used by a reactive system - this.reactive = false; - - this.key = queryKey(Components); - - // Fill the query with the existing entities - for (var i = 0; i < manager._entities.length; i++) { - var entity = manager._entities[i]; - if (this.match(entity)) { - // @todo ??? this.addEntity(entity); => preventing the event to be generated - entity.queries.push(this); - this.entities.push(entity); - } - } - } - - /** - * Add entity to this query - * @param {Entity} entity - */ - addEntity(entity) { - entity.queries.push(this); - this.entities.push(entity); - - this.eventDispatcher.dispatchEvent(Query.prototype.ENTITY_ADDED, entity); - } - - /** - * Remove entity from this query - * @param {Entity} entity - */ - removeEntity(entity) { - let index = this.entities.indexOf(entity); - if (~index) { - this.entities.splice(index, 1); - - index = entity.queries.indexOf(this); - entity.queries.splice(index, 1); - - this.eventDispatcher.dispatchEvent( - Query.prototype.ENTITY_REMOVED, - entity - ); - } - } - - match(entity) { - return ( - entity.hasAllComponents(this.Components) && - !entity.hasAnyComponents(this.NotComponents) - ); - } - - toJSON() { - return { - key: this.key, - reactive: this.reactive, - components: { - included: this.Components.map((C) => C.name), - not: this.NotComponents.map((C) => C.name), - }, - numEntities: this.entities.length, - }; - } - - /** - * Return stats for this query - */ - stats() { - return { - numComponents: this.Components.length, - numEntities: this.entities.length, - }; - } -} - -Query.prototype.ENTITY_ADDED = "Query#ENTITY_ADDED"; -Query.prototype.ENTITY_REMOVED = "Query#ENTITY_REMOVED"; -Query.prototype.COMPONENT_CHANGED = "Query#COMPONENT_CHANGED"; - -/** - * @private - * @class QueryManager - */ -class QueryManager { - constructor(world) { - this._world = world; - - // Queries indexed by a unique identifier for the components it has - this._queries = {}; - } - - onEntityRemoved(entity) { - for (var queryName in this._queries) { - var query = this._queries[queryName]; - if (entity.queries.indexOf(query) !== -1) { - query.removeEntity(entity); - } - } - } - - /** - * Callback when a component is added to an entity - * @param {Entity} entity Entity that just got the new component - * @param {Component} Component Component added to the entity - */ - onEntityComponentAdded(entity, Component) { - // @todo Use bitmask for checking components? - - // Check each indexed query to see if we need to add this entity to the list - for (var queryName in this._queries) { - var query = this._queries[queryName]; - - if ( - !!~query.NotComponents.indexOf(Component) && - ~query.entities.indexOf(entity) - ) { - query.removeEntity(entity); - continue; - } - - // Add the entity only if: - // Component is in the query - // and Entity has ALL the components of the query - // and Entity is not already in the query - if ( - !~query.Components.indexOf(Component) || - !query.match(entity) || - ~query.entities.indexOf(entity) - ) - continue; - - query.addEntity(entity); - } - } - - /** - * Callback when a component is removed from an entity - * @param {Entity} entity Entity to remove the component from - * @param {Component} Component Component to remove from the entity - */ - onEntityComponentRemoved(entity, Component) { - for (var queryName in this._queries) { - var query = this._queries[queryName]; - - if ( - !!~query.NotComponents.indexOf(Component) && - !~query.entities.indexOf(entity) && - query.match(entity) - ) { - query.addEntity(entity); - continue; - } - - if ( - !!~query.Components.indexOf(Component) && - !!~query.entities.indexOf(entity) && - !query.match(entity) - ) { - query.removeEntity(entity); - continue; - } - } - } - - /** - * Get a query for the specified components - * @param {Component} Components Components that the query should have - */ - getQuery(Components) { - var key = queryKey(Components); - var query = this._queries[key]; - if (!query) { - this._queries[key] = query = new Query(Components, this._world); - } - return query; - } - - /** - * Return some stats from this class - */ - stats() { - var stats = {}; - for (var queryName in this._queries) { - stats[queryName] = this._queries[queryName].stats(); - } - return stats; - } -} - -class Component { - constructor(props) { - if (props !== false) { - const schema = this.constructor.schema; - - for (const key in schema) { - if (props && props.hasOwnProperty(key)) { - this[key] = props[key]; - } else { - const schemaProp = schema[key]; - if (schemaProp.hasOwnProperty("default")) { - this[key] = schemaProp.type.clone(schemaProp.default); - } else { - const type = schemaProp.type; - this[key] = type.clone(type.default); - } - } - } - - if ( props !== undefined) { - this.checkUndefinedAttributes(props); - } - } - - this._pool = null; - } - - copy(source) { - const schema = this.constructor.schema; - - for (const key in schema) { - const prop = schema[key]; - - if (source.hasOwnProperty(key)) { - this[key] = prop.type.copy(source[key], this[key]); - } - } - - // @DEBUG - { - this.checkUndefinedAttributes(source); - } - - return this; - } - - clone() { - return new this.constructor().copy(this); - } - - reset() { - const schema = this.constructor.schema; - - for (const key in schema) { - const schemaProp = schema[key]; - - if (schemaProp.hasOwnProperty("default")) { - this[key] = schemaProp.type.copy(schemaProp.default, this[key]); - } else { - const type = schemaProp.type; - this[key] = type.copy(type.default, this[key]); - } - } - } - - dispose() { - if (this._pool) { - this._pool.release(this); - } - } - - getName() { - return this.constructor.getName(); - } - - checkUndefinedAttributes(src) { - const schema = this.constructor.schema; - - // Check that the attributes defined in source are also defined in the schema - Object.keys(src).forEach((srcKey) => { - if (!schema.hasOwnProperty(srcKey)) { - console.warn( - `Trying to set attribute '${srcKey}' not defined in the '${this.constructor.name}' schema. Please fix the schema, the attribute value won't be set` - ); - } - }); - } -} - -Component.schema = {}; -Component.isComponent = true; -Component.getName = function () { - return this.displayName || this.name; -}; - -class SystemStateComponent extends Component {} - -SystemStateComponent.isSystemStateComponent = true; - -class EntityPool extends ObjectPool { - constructor(entityManager, entityClass, initialSize) { - super(entityClass, undefined); - this.entityManager = entityManager; - - if (typeof initialSize !== "undefined") { - this.expand(initialSize); - } - } - - expand(count) { - for (var n = 0; n < count; n++) { - var clone = new this.T(this.entityManager); - clone._pool = this; - this.freeList.push(clone); - } - this.count += count; - } -} - -/** - * @private - * @class EntityManager - */ -class EntityManager { - constructor(world) { - this.world = world; - this.componentsManager = world.componentsManager; - - // All the entities in this instance - this._entities = []; - this._nextEntityId = 0; - - this._entitiesByNames = {}; - - this._queryManager = new QueryManager(this); - this.eventDispatcher = new EventDispatcher(); - this._entityPool = new EntityPool( - this, - this.world.options.entityClass, - this.world.options.entityPoolSize - ); - - // Deferred deletion - this.entitiesWithComponentsToRemove = []; - this.entitiesToRemove = []; - this.deferredRemovalEnabled = true; - } - - getEntityByName(name) { - return this._entitiesByNames[name]; - } - - /** - * Create a new entity - */ - createEntity(name) { - var entity = this._entityPool.acquire(); - entity.alive = true; - entity.name = name || ""; - if (name) { - if (this._entitiesByNames[name]) { - console.warn(`Entity name '${name}' already exist`); - } else { - this._entitiesByNames[name] = entity; - } - } - - this._entities.push(entity); - this.eventDispatcher.dispatchEvent(ENTITY_CREATED, entity); - return entity; - } - - // COMPONENTS - - /** - * Add a component to an entity - * @param {Entity} entity Entity where the component will be added - * @param {Component} Component Component to be added to the entity - * @param {Object} values Optional values to replace the default attributes - */ - entityAddComponent(entity, Component, values) { - // @todo Probably define Component._typeId with a default value and avoid using typeof - if ( - typeof Component._typeId === "undefined" && - !this.world.componentsManager._ComponentsMap[Component._typeId] - ) { - throw new Error( - `Attempted to add unregistered component "${Component.getName()}"` - ); - } - - if (~entity._ComponentTypes.indexOf(Component)) { - { - console.warn( - "Component type already exists on entity.", - entity, - Component.getName() - ); - } - return; - } - - entity._ComponentTypes.push(Component); - - if (Component.__proto__ === SystemStateComponent) { - entity.numStateComponents++; - } - - var componentPool = this.world.componentsManager.getComponentsPool( - Component - ); - - var component = componentPool - ? componentPool.acquire() - : new Component(values); - - if (componentPool && values) { - component.copy(values); - } - - entity._components[Component._typeId] = component; - - this._queryManager.onEntityComponentAdded(entity, Component); - this.world.componentsManager.componentAddedToEntity(Component); - - this.eventDispatcher.dispatchEvent(COMPONENT_ADDED, entity, Component); - } - - /** - * Remove a component from an entity - * @param {Entity} entity Entity which will get removed the component - * @param {*} Component Component to remove from the entity - * @param {Bool} immediately If you want to remove the component immediately instead of deferred (Default is false) - */ - entityRemoveComponent(entity, Component, immediately) { - var index = entity._ComponentTypes.indexOf(Component); - if (!~index) return; - - this.eventDispatcher.dispatchEvent(COMPONENT_REMOVE, entity, Component); - - if (immediately) { - this._entityRemoveComponentSync(entity, Component, index); - } else { - if (entity._ComponentTypesToRemove.length === 0) - this.entitiesWithComponentsToRemove.push(entity); - - entity._ComponentTypes.splice(index, 1); - entity._ComponentTypesToRemove.push(Component); - - entity._componentsToRemove[Component._typeId] = - entity._components[Component._typeId]; - delete entity._components[Component._typeId]; - } - - // Check each indexed query to see if we need to remove it - this._queryManager.onEntityComponentRemoved(entity, Component); - - if (Component.__proto__ === SystemStateComponent) { - entity.numStateComponents--; - - // Check if the entity was a ghost waiting for the last system state component to be removed - if (entity.numStateComponents === 0 && !entity.alive) { - entity.remove(); - } - } - } - - _entityRemoveComponentSync(entity, Component, index) { - // Remove T listing on entity and property ref, then free the component. - entity._ComponentTypes.splice(index, 1); - var component = entity._components[Component._typeId]; - delete entity._components[Component._typeId]; - component.dispose(); - this.world.componentsManager.componentRemovedFromEntity(Component); - } - - /** - * Remove all the components from an entity - * @param {Entity} entity Entity from which the components will be removed - */ - entityRemoveAllComponents(entity, immediately) { - let Components = entity._ComponentTypes; - - for (let j = Components.length - 1; j >= 0; j--) { - if (Components[j].__proto__ !== SystemStateComponent) - this.entityRemoveComponent(entity, Components[j], immediately); - } - } - - /** - * Remove the entity from this manager. It will clear also its components - * @param {Entity} entity Entity to remove from the manager - * @param {Bool} immediately If you want to remove the component immediately instead of deferred (Default is false) - */ - removeEntity(entity, immediately) { - var index = this._entities.indexOf(entity); - - if (!~index) throw new Error("Tried to remove entity not in list"); - - entity.alive = false; - this.entityRemoveAllComponents(entity, immediately); - - if (entity.numStateComponents === 0) { - // Remove from entity list - this.eventDispatcher.dispatchEvent(ENTITY_REMOVED, entity); - this._queryManager.onEntityRemoved(entity); - if (immediately === true) { - this._releaseEntity(entity, index); - } else { - this.entitiesToRemove.push(entity); - } - } - } - - _releaseEntity(entity, index) { - this._entities.splice(index, 1); - - if (this._entitiesByNames[entity.name]) { - delete this._entitiesByNames[entity.name]; - } - entity._pool.release(entity); - } - - /** - * Remove all entities from this manager - */ - removeAllEntities() { - for (var i = this._entities.length - 1; i >= 0; i--) { - this.removeEntity(this._entities[i]); - } - } - - processDeferredRemoval() { - if (!this.deferredRemovalEnabled) { - return; - } - - for (let i = 0; i < this.entitiesToRemove.length; i++) { - let entity = this.entitiesToRemove[i]; - let index = this._entities.indexOf(entity); - this._releaseEntity(entity, index); - } - this.entitiesToRemove.length = 0; - - for (let i = 0; i < this.entitiesWithComponentsToRemove.length; i++) { - let entity = this.entitiesWithComponentsToRemove[i]; - while (entity._ComponentTypesToRemove.length > 0) { - let Component = entity._ComponentTypesToRemove.pop(); - - var component = entity._componentsToRemove[Component._typeId]; - delete entity._componentsToRemove[Component._typeId]; - component.dispose(); - this.world.componentsManager.componentRemovedFromEntity(Component); - - //this._entityRemoveComponentSync(entity, Component, index); - } - } - - this.entitiesWithComponentsToRemove.length = 0; - } - - /** - * Get a query based on a list of components - * @param {Array(Component)} Components List of components that will form the query - */ - queryComponents(Components) { - return this._queryManager.getQuery(Components); - } - - // EXTRAS - - /** - * Return number of entities - */ - count() { - return this._entities.length; - } - - /** - * Return some stats - */ - stats() { - var stats = { - numEntities: this._entities.length, - numQueries: Object.keys(this._queryManager._queries).length, - queries: this._queryManager.stats(), - numComponentPool: Object.keys(this.componentsManager._componentPool) - .length, - componentPool: {}, - eventDispatcher: this.eventDispatcher.stats, - }; - - for (var ecsyComponentId in this.componentsManager._componentPool) { - var pool = this.componentsManager._componentPool[ecsyComponentId]; - stats.componentPool[pool.T.getName()] = { - used: pool.totalUsed(), - size: pool.count, - }; - } - - return stats; - } -} - -const ENTITY_CREATED = "EntityManager#ENTITY_CREATE"; -const ENTITY_REMOVED = "EntityManager#ENTITY_REMOVED"; -const COMPONENT_ADDED = "EntityManager#COMPONENT_ADDED"; -const COMPONENT_REMOVE = "EntityManager#COMPONENT_REMOVE"; - -class ComponentManager { - constructor() { - this.Components = []; - this._ComponentsMap = {}; - - this._componentPool = {}; - this.numComponents = {}; - this.nextComponentId = 0; - } - - hasComponent(Component) { - return this.Components.indexOf(Component) !== -1; - } - - registerComponent(Component, objectPool) { - if (this.Components.indexOf(Component) !== -1) { - console.warn( - `Component type: '${Component.getName()}' already registered.` - ); - return; - } - - const schema = Component.schema; - - if (!schema) { - throw new Error( - `Component "${Component.getName()}" has no schema property.` - ); - } - - for (const propName in schema) { - const prop = schema[propName]; - - if (!prop.type) { - throw new Error( - `Invalid schema for component "${Component.getName()}". Missing type for "${propName}" property.` - ); - } - } - - Component._typeId = this.nextComponentId++; - this.Components.push(Component); - this._ComponentsMap[Component._typeId] = Component; - this.numComponents[Component._typeId] = 0; - - if (objectPool === undefined) { - objectPool = new ObjectPool(Component); - } else if (objectPool === false) { - objectPool = undefined; - } - - this._componentPool[Component._typeId] = objectPool; - } - - componentAddedToEntity(Component) { - this.numComponents[Component._typeId]++; - } - - componentRemovedFromEntity(Component) { - this.numComponents[Component._typeId]--; - } - - getComponentsPool(Component) { - return this._componentPool[Component._typeId]; - } -} - -const Version = "0.3.1"; - -const proxyMap = new WeakMap(); - -const proxyHandler = { - set(target, prop) { - throw new Error( - `Tried to write to "${target.constructor.getName()}#${String( - prop - )}" on immutable component. Use .getMutableComponent() to modify a component.` - ); - }, -}; - -function wrapImmutableComponent(T, component) { - if (component === undefined) { - return undefined; - } - - let wrappedComponent = proxyMap.get(component); - - if (!wrappedComponent) { - wrappedComponent = new Proxy(component, proxyHandler); - proxyMap.set(component, wrappedComponent); - } - - return wrappedComponent; -} - -class Entity { - constructor(entityManager) { - this._entityManager = entityManager || null; - - // Unique ID for this entity - this.id = entityManager._nextEntityId++; - - // List of components types the entity has - this._ComponentTypes = []; - - // Instance of the components - this._components = {}; - - this._componentsToRemove = {}; - - // Queries where the entity is added - this.queries = []; - - // Used for deferred removal - this._ComponentTypesToRemove = []; - - this.alive = false; - - //if there are state components on a entity, it can't be removed completely - this.numStateComponents = 0; - } - - // COMPONENTS - - getComponent(Component, includeRemoved) { - var component = this._components[Component._typeId]; - - if (!component && includeRemoved === true) { - component = this._componentsToRemove[Component._typeId]; - } - - return wrapImmutableComponent(Component, component) - ; - } - - getRemovedComponent(Component) { - const component = this._componentsToRemove[Component._typeId]; - - return wrapImmutableComponent(Component, component) - ; - } - - getComponents() { - return this._components; - } - - getComponentsToRemove() { - return this._componentsToRemove; - } - - getComponentTypes() { - return this._ComponentTypes; - } - - getMutableComponent(Component) { - var component = this._components[Component._typeId]; - - if (!component) { - return; - } - - for (var i = 0; i < this.queries.length; i++) { - var query = this.queries[i]; - // @todo accelerate this check. Maybe having query._Components as an object - // @todo add Not components - if (query.reactive && query.Components.indexOf(Component) !== -1) { - query.eventDispatcher.dispatchEvent( - Query.prototype.COMPONENT_CHANGED, - this, - component - ); - } - } - return component; - } - - addComponent(Component, values) { - this._entityManager.entityAddComponent(this, Component, values); - return this; - } - - removeComponent(Component, forceImmediate) { - this._entityManager.entityRemoveComponent(this, Component, forceImmediate); - return this; - } - - hasComponent(Component, includeRemoved) { - return ( - !!~this._ComponentTypes.indexOf(Component) || - (includeRemoved === true && this.hasRemovedComponent(Component)) - ); - } - - hasRemovedComponent(Component) { - return !!~this._ComponentTypesToRemove.indexOf(Component); - } - - hasAllComponents(Components) { - for (var i = 0; i < Components.length; i++) { - if (!this.hasComponent(Components[i])) return false; - } - return true; - } - - hasAnyComponents(Components) { - for (var i = 0; i < Components.length; i++) { - if (this.hasComponent(Components[i])) return true; - } - return false; - } - - removeAllComponents(forceImmediate) { - return this._entityManager.entityRemoveAllComponents(this, forceImmediate); - } - - copy(src) { - // TODO: This can definitely be optimized - for (var ecsyComponentId in src._components) { - var srcComponent = src._components[ecsyComponentId]; - this.addComponent(srcComponent.constructor); - var component = this.getComponent(srcComponent.constructor); - component.copy(srcComponent); - } - - return this; - } - - clone() { - return new Entity(this._entityManager).copy(this); - } - - reset() { - this.id = this._entityManager._nextEntityId++; - this._ComponentTypes.length = 0; - this.queries.length = 0; - - for (var ecsyComponentId in this._components) { - delete this._components[ecsyComponentId]; - } - } - - remove(forceImmediate) { - return this._entityManager.removeEntity(this, forceImmediate); - } -} - -const DEFAULT_OPTIONS = { - entityPoolSize: 0, - entityClass: Entity, -}; - -class World { - constructor(options = {}) { - this.options = Object.assign({}, DEFAULT_OPTIONS, options); - - this.componentsManager = new ComponentManager(this); - this.entityManager = new EntityManager(this); - this.systemManager = new SystemManager(this); - - this.enabled = true; - - this.eventQueues = {}; - - if (hasWindow && typeof CustomEvent !== "undefined") { - var event = new CustomEvent("ecsy-world-created", { - detail: { world: this, version: Version }, - }); - window.dispatchEvent(event); - } - - this.lastTime = now() / 1000; - } - - registerComponent(Component, objectPool) { - this.componentsManager.registerComponent(Component, objectPool); - return this; - } - - registerSystem(System, attributes) { - this.systemManager.registerSystem(System, attributes); - return this; - } - - hasRegisteredComponent(Component) { - return this.componentsManager.hasComponent(Component); - } - - unregisterSystem(System) { - this.systemManager.unregisterSystem(System); - return this; - } - - getSystem(SystemClass) { - return this.systemManager.getSystem(SystemClass); - } - - getSystems() { - return this.systemManager.getSystems(); - } - - execute(delta, time) { - if (!delta) { - time = now() / 1000; - delta = time - this.lastTime; - this.lastTime = time; - } - - if (this.enabled) { - this.systemManager.execute(delta, time); - this.entityManager.processDeferredRemoval(); - } - } - - stop() { - this.enabled = false; - } - - play() { - this.enabled = true; - } - - createEntity(name) { - return this.entityManager.createEntity(name); - } - - stats() { - var stats = { - entities: this.entityManager.stats(), - system: this.systemManager.stats(), - }; - - return stats; - } -} - -class System { - canExecute() { - if (this._mandatoryQueries.length === 0) return true; - - for (let i = 0; i < this._mandatoryQueries.length; i++) { - var query = this._mandatoryQueries[i]; - if (query.entities.length === 0) { - return false; - } - } - - return true; - } - - getName() { - return this.constructor.getName(); - } - - constructor(world, attributes) { - this.world = world; - this.enabled = true; - - // @todo Better naming :) - this._queries = {}; - this.queries = {}; - - this.priority = 0; - - // Used for stats - this.executeTime = 0; - - if (attributes && attributes.priority) { - this.priority = attributes.priority; - } - - this._mandatoryQueries = []; - - this.initialized = true; - - if (this.constructor.queries) { - for (var queryName in this.constructor.queries) { - var queryConfig = this.constructor.queries[queryName]; - var Components = queryConfig.components; - if (!Components || Components.length === 0) { - throw new Error("'components' attribute can't be empty in a query"); - } - - // Detect if the components have already been registered - let unregisteredComponents = Components.filter( - (Component) => !componentRegistered(Component) - ); - - if (unregisteredComponents.length > 0) { - throw new Error( - `Tried to create a query '${ - this.constructor.name - }.${queryName}' with unregistered components: [${unregisteredComponents - .map((c) => c.getName()) - .join(", ")}]` - ); - } - - var query = this.world.entityManager.queryComponents(Components); - - this._queries[queryName] = query; - if (queryConfig.mandatory === true) { - this._mandatoryQueries.push(query); - } - this.queries[queryName] = { - results: query.entities, - }; - - // Reactive configuration added/removed/changed - var validEvents = ["added", "removed", "changed"]; - - const eventMapping = { - added: Query.prototype.ENTITY_ADDED, - removed: Query.prototype.ENTITY_REMOVED, - changed: Query.prototype.COMPONENT_CHANGED, // Query.prototype.ENTITY_CHANGED - }; - - if (queryConfig.listen) { - validEvents.forEach((eventName) => { - if (!this.execute) { - console.warn( - `System '${this.getName()}' has defined listen events (${validEvents.join( - ", " - )}) for query '${queryName}' but it does not implement the 'execute' method.` - ); - } - - // Is the event enabled on this system's query? - if (queryConfig.listen[eventName]) { - let event = queryConfig.listen[eventName]; - - if (eventName === "changed") { - query.reactive = true; - if (event === true) { - // Any change on the entity from the components in the query - let eventList = (this.queries[queryName][eventName] = []); - query.eventDispatcher.addEventListener( - Query.prototype.COMPONENT_CHANGED, - (entity) => { - // Avoid duplicates - if (eventList.indexOf(entity) === -1) { - eventList.push(entity); - } - } - ); - } else if (Array.isArray(event)) { - let eventList = (this.queries[queryName][eventName] = []); - query.eventDispatcher.addEventListener( - Query.prototype.COMPONENT_CHANGED, - (entity, changedComponent) => { - // Avoid duplicates - if ( - event.indexOf(changedComponent.constructor) !== -1 && - eventList.indexOf(entity) === -1 - ) { - eventList.push(entity); - } - } - ); - } - } else { - let eventList = (this.queries[queryName][eventName] = []); - - query.eventDispatcher.addEventListener( - eventMapping[eventName], - (entity) => { - // @fixme overhead? - if (eventList.indexOf(entity) === -1) - eventList.push(entity); - } - ); - } - } - }); - } - } - } - } - - stop() { - this.executeTime = 0; - this.enabled = false; - } - - play() { - this.enabled = true; - } - - // @question rename to clear queues? - clearEvents() { - for (let queryName in this.queries) { - var query = this.queries[queryName]; - if (query.added) { - query.added.length = 0; - } - if (query.removed) { - query.removed.length = 0; - } - if (query.changed) { - if (Array.isArray(query.changed)) { - query.changed.length = 0; - } else { - for (let name in query.changed) { - query.changed[name].length = 0; - } - } - } - } - } - - toJSON() { - var json = { - name: this.getName(), - enabled: this.enabled, - executeTime: this.executeTime, - priority: this.priority, - queries: {}, - }; - - if (this.constructor.queries) { - var queries = this.constructor.queries; - for (let queryName in queries) { - let query = this.queries[queryName]; - let queryDefinition = queries[queryName]; - let jsonQuery = (json.queries[queryName] = { - key: this._queries[queryName].key, - }); - - jsonQuery.mandatory = queryDefinition.mandatory === true; - jsonQuery.reactive = - queryDefinition.listen && - (queryDefinition.listen.added === true || - queryDefinition.listen.removed === true || - queryDefinition.listen.changed === true || - Array.isArray(queryDefinition.listen.changed)); - - if (jsonQuery.reactive) { - jsonQuery.listen = {}; - - const methods = ["added", "removed", "changed"]; - methods.forEach((method) => { - if (query[method]) { - jsonQuery.listen[method] = { - entities: query[method].length, - }; - } - }); - } - } - } - - return json; - } -} - -System.isSystem = true; -System.getName = function () { - return this.displayName || this.name; -}; - -function Not(Component) { - return { - operator: "not", - Component: Component, - }; -} - -class TagComponent extends Component { - constructor() { - super(false); - } -} - -TagComponent.isTagComponent = true; - -const copyValue = (src) => src; - -const cloneValue = (src) => src; - -const copyArray = (src, dest) => { - if (!src) { - return src; - } - - if (!dest) { - return src.slice(); - } - - dest.length = 0; - - for (let i = 0; i < src.length; i++) { - dest.push(src[i]); - } - - return dest; -}; - -const cloneArray = (src) => src && src.slice(); - -const copyJSON = (src) => JSON.parse(JSON.stringify(src)); - -const cloneJSON = (src) => JSON.parse(JSON.stringify(src)); - -const copyCopyable = (src, dest) => { - if (!src) { - return src; - } - - if (!dest) { - return src.clone(); - } - - return dest.copy(src); -}; - -const cloneClonable = (src) => src && src.clone(); - -function createType(typeDefinition) { - var mandatoryProperties = ["name", "default", "copy", "clone"]; - - var undefinedProperties = mandatoryProperties.filter((p) => { - return !typeDefinition.hasOwnProperty(p); - }); - - if (undefinedProperties.length > 0) { - throw new Error( - `createType expects a type definition with the following properties: ${undefinedProperties.join( - ", " - )}` - ); - } - - typeDefinition.isType = true; - - return typeDefinition; -} - -/** - * Standard types - */ -const Types = { - Number: createType({ - name: "Number", - default: 0, - copy: copyValue, - clone: cloneValue, - }), - - Boolean: createType({ - name: "Boolean", - default: false, - copy: copyValue, - clone: cloneValue, - }), - - String: createType({ - name: "String", - default: "", - copy: copyValue, - clone: cloneValue, - }), - - Array: createType({ - name: "Array", - default: [], - copy: copyArray, - clone: cloneArray, - }), - - Ref: createType({ - name: "Ref", - default: undefined, - copy: copyValue, - clone: cloneValue, - }), - - JSON: createType({ - name: "JSON", - default: null, - copy: copyJSON, - clone: cloneJSON, - }), -}; - -function generateId(length) { - var result = ""; - var characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - var charactersLength = characters.length; - for (var i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; -} - -function injectScript(src, onLoad) { - var script = document.createElement("script"); - // @todo Use link to the ecsy-devtools repo? - script.src = src; - script.onload = onLoad; - (document.head || document.documentElement).appendChild(script); -} - -/* global Peer */ - -function hookConsoleAndErrors(connection) { - var wrapFunctions = ["error", "warning", "log"]; - wrapFunctions.forEach((key) => { - if (typeof console[key] === "function") { - var fn = console[key].bind(console); - console[key] = (...args) => { - connection.send({ - method: "console", - type: key, - args: JSON.stringify(args), - }); - return fn.apply(null, args); - }; - } - }); - - window.addEventListener("error", (error) => { - connection.send({ - method: "error", - error: JSON.stringify({ - message: error.error.message, - stack: error.error.stack, - }), - }); - }); -} - -function includeRemoteIdHTML(remoteId) { - let infoDiv = document.createElement("div"); - infoDiv.style.cssText = ` - align-items: center; - background-color: #333; - color: #aaa; - display:flex; - font-family: Arial; - font-size: 1.1em; - height: 40px; - justify-content: center; - left: 0; - opacity: 0.9; - position: absolute; - right: 0; - text-align: center; - top: 0; - `; - - infoDiv.innerHTML = `Open ECSY devtools to connect to this page using the code: ${remoteId} `; - document.body.appendChild(infoDiv); - - return infoDiv; -} - -function enableRemoteDevtools(remoteId) { - if (!hasWindow) { - console.warn("Remote devtools not available outside the browser"); - return; - } - - window.generateNewCode = () => { - window.localStorage.clear(); - remoteId = generateId(6); - window.localStorage.setItem("ecsyRemoteId", remoteId); - window.location.reload(false); - }; - - remoteId = remoteId || window.localStorage.getItem("ecsyRemoteId"); - if (!remoteId) { - remoteId = generateId(6); - window.localStorage.setItem("ecsyRemoteId", remoteId); - } - - let infoDiv = includeRemoteIdHTML(remoteId); - - window.__ECSY_REMOTE_DEVTOOLS_INJECTED = true; - window.__ECSY_REMOTE_DEVTOOLS = {}; - - let Version = ""; - - // This is used to collect the worlds created before the communication is being established - let worldsBeforeLoading = []; - let onWorldCreated = (e) => { - var world = e.detail.world; - Version = e.detail.version; - worldsBeforeLoading.push(world); - }; - window.addEventListener("ecsy-world-created", onWorldCreated); - - let onLoaded = () => { - // var peer = new Peer(remoteId); - var peer = new Peer(remoteId, { - host: "peerjs.ecsy.io", - secure: true, - port: 443, - config: { - iceServers: [ - { url: "stun:stun.l.google.com:19302" }, - { url: "stun:stun1.l.google.com:19302" }, - { url: "stun:stun2.l.google.com:19302" }, - { url: "stun:stun3.l.google.com:19302" }, - { url: "stun:stun4.l.google.com:19302" }, - ], - }, - debug: 3, - }); - - peer.on("open", (/* id */) => { - peer.on("connection", (connection) => { - window.__ECSY_REMOTE_DEVTOOLS.connection = connection; - connection.on("open", function () { - // infoDiv.style.visibility = "hidden"; - infoDiv.innerHTML = "Connected"; - - // Receive messages - connection.on("data", function (data) { - if (data.type === "init") { - var script = document.createElement("script"); - script.setAttribute("type", "text/javascript"); - script.onload = () => { - script.parentNode.removeChild(script); - - // Once the script is injected we don't need to listen - window.removeEventListener( - "ecsy-world-created", - onWorldCreated - ); - worldsBeforeLoading.forEach((world) => { - var event = new CustomEvent("ecsy-world-created", { - detail: { world: world, version: Version }, - }); - window.dispatchEvent(event); - }); - }; - script.innerHTML = data.script; - (document.head || document.documentElement).appendChild(script); - script.onload(); - - hookConsoleAndErrors(connection); - } else if (data.type === "executeScript") { - let value = eval(data.script); - if (data.returnEval) { - connection.send({ - method: "evalReturn", - value: value, - }); - } - } - }); - }); - }); - }); - }; - - // Inject PeerJS script - injectScript( - "https://cdn.jsdelivr.net/npm/peerjs@0.3.20/dist/peer.min.js", - onLoaded - ); -} - -if (hasWindow) { - const urlParams = new URLSearchParams(window.location.search); - - // @todo Provide a way to disable it if needed - if (urlParams.has("enable-remote-devtools")) { - enableRemoteDevtools(); - } -} - -export { Component, Not, ObjectPool, System, SystemStateComponent, TagComponent, Types, Version, World, Entity as _Entity, cloneArray, cloneClonable, cloneJSON, cloneValue, copyArray, copyCopyable, copyJSON, copyValue, createType, enableRemoteDevtools }; diff --git a/examples/webxr_vr_handinput_pointerclick.html b/examples/webxr_vr_handinput_pointerclick.html index c0041e0f9d430c..7969d4fa679350 100644 --- a/examples/webxr_vr_handinput_pointerclick.html +++ b/examples/webxr_vr_handinput_pointerclick.html @@ -36,247 +36,18 @@ import { OculusHandPointerModel } from 'three/addons/webxr/OculusHandPointerModel.js'; import { createText } from 'three/addons/webxr/Text2D.js'; - import { World, System, Component, TagComponent, Types } from 'three/addons/libs/ecsy.module.js'; - - class Object3D extends Component { } - - Object3D.schema = { - object: { type: Types.Ref } - }; - - class Button extends Component { } - - Button.schema = { - // button states: [none, hovered, pressed] - currState: { type: Types.String, default: 'none' }, - prevState: { type: Types.String, default: 'none' }, - action: { type: Types.Ref, default: () => { } } - }; - - class ButtonSystem extends System { - - execute( /* delta, time */ ) { - - this.queries.buttons.results.forEach( entity => { - - const button = entity.getMutableComponent( Button ); - const buttonMesh = entity.getComponent( Object3D ).object; - if ( button.currState == 'none' ) { - - buttonMesh.scale.set( 1, 1, 1 ); - - } else { - - buttonMesh.scale.set( 1.1, 1.1, 1.1 ); - - } - - if ( button.currState == 'pressed' && button.prevState != 'pressed' ) { - - button.action(); - - } - - // preserve prevState, clear currState - // HandRaySystem will update currState - button.prevState = button.currState; - button.currState = 'none'; - - } ); - - } - - } - - ButtonSystem.queries = { - buttons: { - components: [ Button ] - } - }; - - class Intersectable extends TagComponent { } - - class HandRaySystem extends System { - - init( attributes ) { - - this.handPointers = attributes.handPointers; - - } - - execute( /* delta, time */ ) { - - this.handPointers.forEach( hp => { - - let distance = null; - let intersectingEntity = null; - this.queries.intersectable.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - const intersections = hp.intersectObject( object, false ); - if ( intersections && intersections.length > 0 ) { - - if ( distance == null || intersections[ 0 ].distance < distance ) { - - distance = intersections[ 0 ].distance; - intersectingEntity = entity; - - } - - } - - } ); - if ( distance ) { - - hp.setCursor( distance ); - if ( intersectingEntity.hasComponent( Button ) ) { - - const button = intersectingEntity.getMutableComponent( Button ); - if ( hp.isPinched() ) { - - button.currState = 'pressed'; - - } else if ( button.currState != 'pressed' ) { - - button.currState = 'hovered'; - - } - - } - - } else { - - hp.setCursor( 1.5 ); - - } - - } ); - - } - - } - - HandRaySystem.queries = { - intersectable: { - components: [ Intersectable ] - } - }; - - class Rotating extends TagComponent { } - - class RotatingSystem extends System { - - execute( delta/*, time*/ ) { - - this.queries.rotatingObjects.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - object.rotation.x += 0.4 * delta; - object.rotation.y += 0.4 * delta; - - } ); - - } - - } - - RotatingSystem.queries = { - rotatingObjects: { - components: [ Rotating ] - } - }; - - class HandsInstructionText extends TagComponent { } - - class InstructionSystem extends System { - - init( attributes ) { - - this.controllers = attributes.controllers; - - } - - execute( /* delta, time */ ) { - - let visible = false; - this.controllers.forEach( controller => { - - if ( controller.visible ) { - - visible = true; - - } - - } ); - - this.queries.instructionTexts.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - object.visible = visible; - - } ); - - } - - } - - InstructionSystem.queries = { - instructionTexts: { - components: [ HandsInstructionText ] - } - }; - - class OffsetFromCamera extends Component { } - - OffsetFromCamera.schema = { - x: { type: Types.Number, default: 0 }, - y: { type: Types.Number, default: 0 }, - z: { type: Types.Number, default: 0 }, - }; - - class NeedCalibration extends TagComponent { } - - class CalibrationSystem extends System { - - init( attributes ) { - - this.camera = attributes.camera; - this.renderer = attributes.renderer; - - } - - execute( /* delta, time */ ) { - - this.queries.needCalibration.results.forEach( entity => { - - if ( this.renderer.xr.getSession() ) { - - const offset = entity.getComponent( OffsetFromCamera ); - const object = entity.getComponent( Object3D ).object; - const xrCamera = this.renderer.xr.getCamera(); - object.position.x = xrCamera.position.x + offset.x; - object.position.y = xrCamera.position.y + offset.y; - object.position.z = xrCamera.position.z + offset.z; - entity.removeComponent( NeedCalibration ); - - } - - } ); - - } - - } - - CalibrationSystem.queries = { - needCalibration: { - components: [ NeedCalibration ] - } - }; - - const world = new World(); const timer = new THREE.Timer(); timer.connect( document ); let camera, scene, renderer; + let menuMesh, torusKnot, instructionText; + let controllers, handPointers; + let needsCalibration = true; + + // meshes the hand rays can intersect + const intersectables = []; + + // button states: [none, hovered, pressed] + const buttons = []; init(); @@ -362,6 +133,9 @@ hand2.add( handPointer2 ); scene.add( hand2 ); + controllers = [ controllerGrip1, controllerGrip2 ]; + handPointers = [ handPointer1, handPointer2 ]; + // setup objects in scene and entities const floorGeometry = new THREE.PlaneGeometry( 4, 4 ); const floorMaterial = new THREE.MeshPhongMaterial( { color: 0x222222 } ); @@ -375,7 +149,7 @@ opacity: 0, transparent: true, } ); - const menuMesh = new THREE.Mesh( menuGeometry, menuMaterial ); + menuMesh = new THREE.Mesh( menuGeometry, menuMaterial ); menuMesh.position.set( 0.4, 1, - 1 ); menuMesh.rotation.y = - Math.PI / 12; scene.add( menuMesh ); @@ -405,11 +179,11 @@ const tkGeometry = new THREE.TorusKnotGeometry( 0.5, 0.2, 200, 32 ); const tkMaterial = new THREE.MeshPhongMaterial( { color: 0xffffff } ); tkMaterial.metalness = 0.8; - const torusKnot = new THREE.Mesh( tkGeometry, tkMaterial ); + torusKnot = new THREE.Mesh( tkGeometry, tkMaterial ); torusKnot.position.set( 0, 1, - 5 ); scene.add( torusKnot ); - const instructionText = createText( 'This is a WebXR Hands demo, please explore with hands.', 0.04 ); + instructionText = createText( 'This is a WebXR Hands demo, please explore with hands.', 0.04 ); instructionText.position.set( 0, 1.6, - 0.6 ); scene.add( instructionText ); @@ -418,95 +192,149 @@ exitText.visible = false; scene.add( exitText ); - world - .registerComponent( Object3D ) - .registerComponent( Button ) - .registerComponent( Intersectable ) - .registerComponent( Rotating ) - .registerComponent( HandsInstructionText ) - .registerComponent( OffsetFromCamera ) - .registerComponent( NeedCalibration ); - - world - .registerSystem( RotatingSystem ) - .registerSystem( InstructionSystem, { controllers: [ controllerGrip1, controllerGrip2 ] } ) - .registerSystem( CalibrationSystem, { renderer: renderer, camera: camera } ) - .registerSystem( ButtonSystem ) - .registerSystem( HandRaySystem, { handPointers: [ handPointer1, handPointer2 ] } ); - - const menuEntity = world.createEntity(); - menuEntity.addComponent( Intersectable ); - menuEntity.addComponent( OffsetFromCamera, { x: 0.4, y: 0, z: - 1 } ); - menuEntity.addComponent( NeedCalibration ); - menuEntity.addComponent( Object3D, { object: menuMesh } ); - - const obEntity = world.createEntity(); - obEntity.addComponent( Intersectable ); - obEntity.addComponent( Object3D, { object: orangeButton } ); - const obAction = function () { - - torusKnot.material.color.setHex( 0xffd3b5 ); + intersectables.push( menuMesh, orangeButton, pinkButton, resetButton, exitButton ); - }; + buttons.push( + { mesh: orangeButton, currState: 'none', prevState: 'none', action: function () { - obEntity.addComponent( Button, { action: obAction } ); + torusKnot.material.color.setHex( 0xffd3b5 ); - const pbEntity = world.createEntity(); - pbEntity.addComponent( Intersectable ); - pbEntity.addComponent( Object3D, { object: pinkButton } ); - const pbAction = function () { + } }, + { mesh: pinkButton, currState: 'none', prevState: 'none', action: function () { - torusKnot.material.color.setHex( 0xe84a5f ); + torusKnot.material.color.setHex( 0xe84a5f ); - }; + } }, + { mesh: resetButton, currState: 'none', prevState: 'none', action: function () { - pbEntity.addComponent( Button, { action: pbAction } ); + torusKnot.material.color.setHex( 0xffffff ); - const rbEntity = world.createEntity(); - rbEntity.addComponent( Intersectable ); - rbEntity.addComponent( Object3D, { object: resetButton } ); - const rbAction = function () { + } }, + { mesh: exitButton, currState: 'none', prevState: 'none', action: function () { - torusKnot.material.color.setHex( 0xffffff ); + exitText.visible = true; + setTimeout( function () { - }; + exitText.visible = false; renderer.xr.getSession().end(); - rbEntity.addComponent( Button, { action: rbAction } ); + }, 2000 ); - const ebEntity = world.createEntity(); - ebEntity.addComponent( Intersectable ); - ebEntity.addComponent( Object3D, { object: exitButton } ); - const ebAction = function () { + } } + ); - exitText.visible = true; - setTimeout( function () { + window.addEventListener( 'resize', onWindowResize ); - exitText.visible = false; renderer.xr.getSession().end(); + } - }, 2000 ); + function onWindowResize() { - }; + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + + renderer.setSize( window.innerWidth, window.innerHeight ); - ebEntity.addComponent( Button, { action: ebAction } ); + } - const tkEntity = world.createEntity(); - tkEntity.addComponent( Rotating ); - tkEntity.addComponent( Object3D, { object: torusKnot } ); + function calibrateMenu() { - const itEntity = world.createEntity(); - itEntity.addComponent( HandsInstructionText ); - itEntity.addComponent( Object3D, { object: instructionText } ); + // position the menu relative to the camera once the session has started - window.addEventListener( 'resize', onWindowResize ); + if ( needsCalibration && renderer.xr.getSession() ) { + + const xrCamera = renderer.xr.getCamera(); + menuMesh.position.x = xrCamera.position.x + 0.4; + menuMesh.position.y = xrCamera.position.y; + menuMesh.position.z = xrCamera.position.z - 1; + needsCalibration = false; + + } } - function onWindowResize() { + function updateInstructionText() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); + // the instruction text is only visible as long as motion controllers are used - renderer.setSize( window.innerWidth, window.innerHeight ); + instructionText.visible = controllers.some( controller => controller.visible ); + + } + + function updateHandRays() { + + handPointers.forEach( hp => { + + let distance = null; + let intersectingMesh = null; + intersectables.forEach( object => { + + const intersections = hp.intersectObject( object, false ); + if ( intersections && intersections.length > 0 ) { + + if ( distance == null || intersections[ 0 ].distance < distance ) { + + distance = intersections[ 0 ].distance; + intersectingMesh = object; + + } + + } + + } ); + if ( distance ) { + + hp.setCursor( distance ); + const button = buttons.find( button => button.mesh === intersectingMesh ); + if ( button !== undefined ) { + + if ( hp.isPinched() ) { + + button.currState = 'pressed'; + + } else if ( button.currState != 'pressed' ) { + + button.currState = 'hovered'; + + } + + } + + } else { + + hp.setCursor( 1.5 ); + + } + + } ); + + } + + function updateButtons() { + + buttons.forEach( button => { + + if ( button.currState == 'none' ) { + + button.mesh.scale.set( 1, 1, 1 ); + + } else { + + button.mesh.scale.set( 1.1, 1.1, 1.1 ); + + } + + if ( button.currState == 'pressed' && button.prevState != 'pressed' ) { + + button.action(); + + } + + // preserve prevState, clear currState + // updateHandRays() will update currState + + button.prevState = button.currState; + button.currState = 'none'; + + } ); } @@ -515,9 +343,16 @@ timer.update(); const delta = timer.getDelta(); - const elapsedTime = timer.getElapsed(); renderer.xr.updateCamera( camera ); - world.execute( delta, elapsedTime ); + + calibrateMenu(); + updateInstructionText(); + updateHandRays(); + updateButtons(); + + torusKnot.rotation.x += 0.4 * delta; + torusKnot.rotation.y += 0.4 * delta; + renderer.render( scene, camera ); } diff --git a/examples/webxr_vr_handinput_pointerdrag.html b/examples/webxr_vr_handinput_pointerdrag.html index 3b3378fbdc5434..6d001f5159b83a 100644 --- a/examples/webxr_vr_handinput_pointerdrag.html +++ b/examples/webxr_vr_handinput_pointerdrag.html @@ -2,10 +2,10 @@ - three.js ve - handinput - point and drag + three.js vr - handinput - point and drag - + @@ -36,352 +36,19 @@ import { OculusHandPointerModel } from 'three/addons/webxr/OculusHandPointerModel.js'; import { createText } from 'three/addons/webxr/Text2D.js'; - import { World, System, Component, TagComponent, Types } from 'three/addons/libs/ecsy.module.js'; - - class Object3D extends Component { } - - Object3D.schema = { - object: { type: Types.Ref } - }; - - class Button extends Component { } - - Button.schema = { - // button states: [none, hovered, pressed] - currState: { type: Types.String, default: 'none' }, - prevState: { type: Types.String, default: 'none' }, - action: { type: Types.Ref, default: () => { } } - }; - - class ButtonSystem extends System { - - execute( /*delta, time*/ ) { - - this.queries.buttons.results.forEach( entity => { - - const button = entity.getMutableComponent( Button ); - const buttonMesh = entity.getComponent( Object3D ).object; - if ( button.currState == 'none' ) { - - buttonMesh.scale.set( 1, 1, 1 ); - - } else { - - buttonMesh.scale.set( 1.1, 1.1, 1.1 ); - - } - - if ( button.currState == 'pressed' && button.prevState != 'pressed' ) { - - button.action(); - - } - - // preserve prevState, clear currState - // HandRaySystem will update currState - button.prevState = button.currState; - button.currState = 'none'; - - } ); - - } - - } - - ButtonSystem.queries = { - buttons: { - components: [ Button ] - } - }; - - class Draggable extends Component { } - - Draggable.schema = { - // draggable states: [detached, hovered, to-be-attached, attached, to-be-detached] - state: { type: Types.String, default: 'none' }, - originalParent: { type: Types.Ref, default: null }, - attachedPointer: { type: Types.Ref, default: null } - }; - - class DraggableSystem extends System { - - execute( /*delta, time*/ ) { - - this.queries.draggable.results.forEach( entity => { - - const draggable = entity.getMutableComponent( Draggable ); - const object = entity.getComponent( Object3D ).object; - if ( draggable.originalParent == null ) { - - draggable.originalParent = object.parent; - - } - - switch ( draggable.state ) { - - case 'to-be-attached': - draggable.attachedPointer.children[ 0 ].attach( object ); - draggable.state = 'attached'; - break; - case 'to-be-detached': - draggable.originalParent.attach( object ); - draggable.state = 'detached'; - break; - default: - object.scale.set( 1, 1, 1 ); - - } - - } ); - - } - - } - - DraggableSystem.queries = { - draggable: { - components: [ Draggable ] - } - }; - - class Intersectable extends TagComponent { } - - class HandRaySystem extends System { - - init( attributes ) { - - this.handPointers = attributes.handPointers; - - } - - execute( /*delta, time*/ ) { - - this.handPointers.forEach( hp => { - - let distance = null; - let intersectingEntity = null; - this.queries.intersectable.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - const intersections = hp.intersectObject( object, false ); - if ( intersections && intersections.length > 0 ) { - - if ( distance == null || intersections[ 0 ].distance < distance ) { - - distance = intersections[ 0 ].distance; - intersectingEntity = entity; - - } - - } - - } ); - if ( distance ) { - - hp.setCursor( distance ); - if ( intersectingEntity.hasComponent( Button ) ) { - - const button = intersectingEntity.getMutableComponent( Button ); - if ( hp.isPinched() ) { - - button.currState = 'pressed'; - - } else if ( button.currState != 'pressed' ) { - - button.currState = 'hovered'; - - } - - } - - if ( intersectingEntity.hasComponent( Draggable ) ) { - - const draggable = intersectingEntity.getMutableComponent( Draggable ); - const object = intersectingEntity.getComponent( Object3D ).object; - object.scale.set( 1.1, 1.1, 1.1 ); - if ( hp.isPinched() ) { - - if ( ! hp.isAttached() && draggable.state != 'attached' ) { - - draggable.state = 'to-be-attached'; - draggable.attachedPointer = hp; - hp.setAttached( true ); - - } - - } else { - - if ( hp.isAttached() && draggable.state == 'attached' ) { - - console.log( 'hello' ); - draggable.state = 'to-be-detached'; - draggable.attachedPointer = null; - hp.setAttached( false ); - - } - - } - - } - - } else { - - hp.setCursor( 1.5 ); - - } - - } ); - - } - - } - - HandRaySystem.queries = { - intersectable: { - components: [ Intersectable ] - } - }; - - class HandsInstructionText extends TagComponent { } - - class InstructionSystem extends System { - - init( attributes ) { - - this.controllers = attributes.controllers; - - } - - execute( /*delta, time*/ ) { - - let visible = false; - this.controllers.forEach( controller => { - - if ( controller.visible ) { - - visible = true; - - } - - } ); - - this.queries.instructionTexts.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - object.visible = visible; - - } ); - - } - - } - - InstructionSystem.queries = { - instructionTexts: { - components: [ HandsInstructionText ] - } - }; - - class OffsetFromCamera extends Component { } - - OffsetFromCamera.schema = { - x: { type: Types.Number, default: 0 }, - y: { type: Types.Number, default: 0 }, - z: { type: Types.Number, default: 0 }, - }; - - class NeedCalibration extends TagComponent { } - - class CalibrationSystem extends System { - - init( attributes ) { - - this.camera = attributes.camera; - this.renderer = attributes.renderer; - - } - - execute( /*delta, time*/ ) { - - this.queries.needCalibration.results.forEach( entity => { - - if ( this.renderer.xr.getSession() ) { - - const offset = entity.getComponent( OffsetFromCamera ); - const object = entity.getComponent( Object3D ).object; - const xrCamera = this.renderer.xr.getCamera(); - object.position.x = xrCamera.position.x + offset.x; - object.position.y = xrCamera.position.y + offset.y; - object.position.z = xrCamera.position.z + offset.z; - entity.removeComponent( NeedCalibration ); - - } - - } ); - - } - - } - - CalibrationSystem.queries = { - needCalibration: { - components: [ NeedCalibration ] - } - }; - - class Randomizable extends TagComponent { } - - class RandomizerSystem extends System { - - init( /*attributes*/ ) { - - this.needRandomizing = true; - - } - - execute( /*delta, time*/ ) { - - if ( ! this.needRandomizing ) { - - return; - - } - - this.queries.randomizable.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - - object.material.color.setHex( Math.random() * 0xffffff ); - - object.position.x = Math.random() * 2 - 1; - object.position.y = Math.random() * 2; - object.position.z = Math.random() * 2 - 1; - - object.rotation.x = Math.random() * 2 * Math.PI; - object.rotation.y = Math.random() * 2 * Math.PI; - object.rotation.z = Math.random() * 2 * Math.PI; - - object.scale.x = Math.random() + 0.5; - object.scale.y = Math.random() + 0.5; - object.scale.z = Math.random() + 0.5; - this.needRandomizing = false; - - } ); - - } + let camera, scene, renderer; + let menuMesh, instructionText; + let controllers, handPointers; + let needsCalibration = true; - } + // meshes the hand rays can intersect + const intersectables = []; - RandomizerSystem.queries = { - randomizable: { - components: [ Randomizable ] - } - }; + // button states: [none, hovered, pressed] + const buttons = []; - const world = new World(); - const timer = new THREE.Timer(); - timer.connect( document ); - let camera, scene, renderer; + // draggable states: [detached, hovered, to-be-attached, attached, to-be-detached] + const draggables = []; init(); @@ -465,8 +132,10 @@ hand2.add( handPointer2 ); scene.add( hand2 ); + controllers = [ controllerGrip1, controllerGrip2 ]; + handPointers = [ handPointer1, handPointer2 ]; - // setup objects in scene and entities + // setup objects in scene const floorGeometry = new THREE.PlaneGeometry( 4, 4 ); const floorMaterial = new THREE.MeshPhongMaterial( { color: 0x222222 } ); const floor = new THREE.Mesh( floorGeometry, floorMaterial ); @@ -479,7 +148,7 @@ opacity: 0, transparent: true, } ); - const menuMesh = new THREE.Mesh( menuGeometry, menuMaterial ); + menuMesh = new THREE.Mesh( menuGeometry, menuMaterial ); menuMesh.position.set( 0.4, 1, - 1 ); menuMesh.rotation.y = - Math.PI / 12; scene.add( menuMesh ); @@ -498,7 +167,7 @@ exitButton.position.set( 0, - 0.18, 0 ); menuMesh.add( exitButton ); - const instructionText = createText( 'This is a WebXR Hands demo, please explore with hands.', 0.04 ); + instructionText = createText( 'This is a WebXR Hands demo, please explore with hands.', 0.04 ); instructionText.position.set( 0, 1.6, - 0.6 ); scene.add( instructionText ); @@ -507,73 +176,37 @@ exitText.visible = false; scene.add( exitText ); - world - .registerComponent( Object3D ) - .registerComponent( Button ) - .registerComponent( Intersectable ) - .registerComponent( HandsInstructionText ) - .registerComponent( OffsetFromCamera ) - .registerComponent( NeedCalibration ) - .registerComponent( Randomizable ) - .registerComponent( Draggable ); - - world - .registerSystem( RandomizerSystem ) - .registerSystem( InstructionSystem, { controllers: [ controllerGrip1, controllerGrip2 ] } ) - .registerSystem( CalibrationSystem, { renderer: renderer, camera: camera } ) - .registerSystem( ButtonSystem ) - .registerSystem( DraggableSystem ) - .registerSystem( HandRaySystem, { handPointers: [ handPointer1, handPointer2 ] } ); - for ( let i = 0; i < 20; i ++ ) { const object = new THREE.Mesh( new THREE.BoxGeometry( 0.15, 0.15, 0.15 ), new THREE.MeshLambertMaterial( { color: 0xffffff } ) ); scene.add( object ); - const entity = world.createEntity(); - entity.addComponent( Intersectable ); - entity.addComponent( Randomizable ); - entity.addComponent( Object3D, { object: object } ); - entity.addComponent( Draggable ); + intersectables.push( object ); + draggables.push( { mesh: object, state: 'detached', originalParent: scene, attachedPointer: null } ); } - const menuEntity = world.createEntity(); - menuEntity.addComponent( Intersectable ); - menuEntity.addComponent( OffsetFromCamera, { x: 0.4, y: 0, z: - 1 } ); - menuEntity.addComponent( NeedCalibration ); - menuEntity.addComponent( Object3D, { object: menuMesh } ); - - const rbEntity = world.createEntity(); - rbEntity.addComponent( Intersectable ); - rbEntity.addComponent( Object3D, { object: resetButton } ); - const rbAction = function () { + randomizeObjects(); - world.getSystem( RandomizerSystem ).needRandomizing = true; + intersectables.push( menuMesh, resetButton, exitButton ); - }; + buttons.push( + { mesh: resetButton, currState: 'none', prevState: 'none', action: function () { - rbEntity.addComponent( Button, { action: rbAction } ); + randomizeObjects(); - const ebEntity = world.createEntity(); - ebEntity.addComponent( Intersectable ); - ebEntity.addComponent( Object3D, { object: exitButton } ); - const ebAction = function () { + } }, + { mesh: exitButton, currState: 'none', prevState: 'none', action: function () { - exitText.visible = true; - setTimeout( function () { + exitText.visible = true; + setTimeout( function () { - exitText.visible = false; renderer.xr.getSession().end(); + exitText.visible = false; renderer.xr.getSession().end(); - }, 2000 ); + }, 2000 ); - }; - - ebEntity.addComponent( Button, { action: ebAction } ); - - const itEntity = world.createEntity(); - itEntity.addComponent( HandsInstructionText ); - itEntity.addComponent( Object3D, { object: instructionText } ); + } } + ); window.addEventListener( 'resize', onWindowResize ); @@ -588,14 +221,196 @@ } - function animate() { + function randomizeObjects() { + + draggables.forEach( draggable => { + + const object = draggable.mesh; + + object.material.color.setHex( Math.random() * 0xffffff ); + + object.position.x = Math.random() * 2 - 1; + object.position.y = Math.random() * 2; + object.position.z = Math.random() * 2 - 1; + + object.rotation.x = Math.random() * 2 * Math.PI; + object.rotation.y = Math.random() * 2 * Math.PI; + object.rotation.z = Math.random() * 2 * Math.PI; + + object.scale.x = Math.random() + 0.5; + object.scale.y = Math.random() + 0.5; + object.scale.z = Math.random() + 0.5; + + } ); + + } + + function calibrateMenu() { + + // position the menu relative to the camera once the session has started + + if ( needsCalibration && renderer.xr.getSession() ) { + + const xrCamera = renderer.xr.getCamera(); + menuMesh.position.x = xrCamera.position.x + 0.4; + menuMesh.position.y = xrCamera.position.y; + menuMesh.position.z = xrCamera.position.z - 1; + needsCalibration = false; - timer.update(); + } + + } + + function updateInstructionText() { + + // the instruction text is only visible as long as motion controllers are used + + instructionText.visible = controllers.some( controller => controller.visible ); + + } + + function updateDraggables() { + + draggables.forEach( draggable => { + + const object = draggable.mesh; + + switch ( draggable.state ) { + + case 'to-be-attached': + draggable.attachedPointer.children[ 0 ].attach( object ); + draggable.state = 'attached'; + break; + case 'to-be-detached': + draggable.originalParent.attach( object ); + draggable.state = 'detached'; + break; + default: + object.scale.set( 1, 1, 1 ); + + } + + } ); + + } + + function updateHandRays() { + + handPointers.forEach( hp => { + + let distance = null; + let intersectingMesh = null; + intersectables.forEach( object => { + + const intersections = hp.intersectObject( object, false ); + if ( intersections && intersections.length > 0 ) { + + if ( distance == null || intersections[ 0 ].distance < distance ) { + + distance = intersections[ 0 ].distance; + intersectingMesh = object; + + } + + } + + } ); + if ( distance ) { + + hp.setCursor( distance ); + const button = buttons.find( button => button.mesh === intersectingMesh ); + if ( button !== undefined ) { + + if ( hp.isPinched() ) { + + button.currState = 'pressed'; + + } else if ( button.currState != 'pressed' ) { + + button.currState = 'hovered'; + + } + + } + + const draggable = draggables.find( draggable => draggable.mesh === intersectingMesh ); + if ( draggable !== undefined ) { + + intersectingMesh.scale.set( 1.1, 1.1, 1.1 ); + if ( hp.isPinched() ) { + + if ( ! hp.isAttached() && draggable.state != 'attached' ) { + + draggable.state = 'to-be-attached'; + draggable.attachedPointer = hp; + hp.setAttached( true ); + + } + + } else { + + if ( hp.isAttached() && draggable.state == 'attached' ) { + + draggable.state = 'to-be-detached'; + draggable.attachedPointer = null; + hp.setAttached( false ); + + } + + } + + } + + } else { + + hp.setCursor( 1.5 ); + + } + + } ); + + } + + function updateButtons() { + + buttons.forEach( button => { + + if ( button.currState == 'none' ) { + + button.mesh.scale.set( 1, 1, 1 ); + + } else { + + button.mesh.scale.set( 1.1, 1.1, 1.1 ); + + } + + if ( button.currState == 'pressed' && button.prevState != 'pressed' ) { + + button.action(); + + } + + // preserve prevState, clear currState + // updateHandRays() will update currState + + button.prevState = button.currState; + button.currState = 'none'; + + } ); + + } + + function animate() { - const delta = timer.getDelta(); - const elapsedTime = timer.getElapsed(); renderer.xr.updateCamera( camera ); - world.execute( delta, elapsedTime ); + + calibrateMenu(); + updateInstructionText(); + updateDraggables(); + updateHandRays(); + updateButtons(); + renderer.render( scene, camera ); } diff --git a/examples/webxr_vr_handinput_pressbutton.html b/examples/webxr_vr_handinput_pressbutton.html index 567308cf0bf0b9..ca51659b3edf1d 100644 --- a/examples/webxr_vr_handinput_pressbutton.html +++ b/examples/webxr_vr_handinput_pressbutton.html @@ -35,308 +35,16 @@ import { OculusHandModel } from 'three/addons/webxr/OculusHandModel.js'; import { createText } from 'three/addons/webxr/Text2D.js'; - import { World, System, Component, TagComponent, Types } from 'three/addons/libs/ecsy.module.js'; - - class Object3D extends Component { } - - Object3D.schema = { - object: { type: Types.Ref } - }; - - class Button extends Component { } - - Button.schema = { - // button states: [resting, pressed, fully_pressed, recovering] - currState: { type: Types.String, default: 'resting' }, - prevState: { type: Types.String, default: 'resting' }, - pressSound: { type: Types.Ref, default: null }, - releaseSound: { type: Types.Ref, default: null }, - restingY: { type: Types.Number, default: null }, - surfaceY: { type: Types.Number, default: null }, - recoverySpeed: { type: Types.Number, default: 0.4 }, - fullPressDistance: { type: Types.Number, default: null }, - action: { type: Types.Ref, default: () => { } } - }; - - class ButtonSystem extends System { - - init( attributes ) { - - this.renderer = attributes.renderer; - this.soundAdded = false; - - } - - execute( /*delta, time*/ ) { - - let buttonPressSound, buttonReleaseSound; - if ( this.renderer.xr.getSession() && ! this.soundAdded ) { - - const xrCamera = this.renderer.xr.getCamera(); - - const listener = new THREE.AudioListener(); - xrCamera.add( listener ); - - // create a global audio source - buttonPressSound = new THREE.Audio( listener ); - buttonReleaseSound = new THREE.Audio( listener ); - - // load a sound and set it as the Audio object's buffer - const audioLoader = new THREE.AudioLoader(); - audioLoader.load( 'sounds/button-press.ogg', function ( buffer ) { - - buttonPressSound.setBuffer( buffer ); - - } ); - audioLoader.load( 'sounds/button-release.ogg', function ( buffer ) { - - buttonReleaseSound.setBuffer( buffer ); - - } ); - this.soundAdded = true; - - } - - this.queries.buttons.results.forEach( entity => { - - const button = entity.getMutableComponent( Button ); - const buttonMesh = entity.getComponent( Object3D ).object; - // populate restingY - if ( button.restingY == null ) { - - button.restingY = buttonMesh.position.y; - - } - - if ( buttonPressSound ) { - - button.pressSound = buttonPressSound; - - } - - if ( buttonReleaseSound ) { - - button.releaseSound = buttonReleaseSound; - - } - - if ( button.currState == 'fully_pressed' && button.prevState != 'fully_pressed' ) { - - if ( button.pressSound ) button.pressSound.play(); - button.action(); - - } - - if ( button.currState == 'recovering' && button.prevState != 'recovering' ) { - - if ( button.releaseSound ) button.releaseSound.play(); - - } - - // preserve prevState, clear currState - // FingerInputSystem will update currState - button.prevState = button.currState; - button.currState = 'resting'; - - } ); - - } - - } - - ButtonSystem.queries = { - buttons: { - components: [ Button ] - } - }; - - class Pressable extends TagComponent { } - - class FingerInputSystem extends System { - - init( attributes ) { - - this.hands = attributes.hands; - - } - - execute( delta/*, time*/ ) { - - this.queries.pressable.results.forEach( entity => { - - const button = entity.getMutableComponent( Button ); - const object = entity.getComponent( Object3D ).object; - const pressingDistances = []; - this.hands.forEach( hand => { - - if ( hand && hand.intersectBoxObject( object ) ) { - - const pressingPosition = hand.getPointerPosition(); - pressingDistances.push( button.surfaceY - object.worldToLocal( pressingPosition ).y ); - - } - - } ); - if ( pressingDistances.length == 0 ) { // not pressed this frame - - if ( object.position.y < button.restingY ) { - - object.position.y += button.recoverySpeed * delta; - button.currState = 'recovering'; - - } else { - - object.position.y = button.restingY; - button.currState = 'resting'; - - } - - } else { - - button.currState = 'pressed'; - const pressingDistance = Math.max( pressingDistances ); - if ( pressingDistance > 0 ) { - - object.position.y -= pressingDistance; - - } - - if ( object.position.y <= button.restingY - button.fullPressDistance ) { - - button.currState = 'fully_pressed'; - object.position.y = button.restingY - button.fullPressDistance; - - } - - } - - } ); - - } - - } - - FingerInputSystem.queries = { - pressable: { - components: [ Pressable ] - } - }; - - class Rotating extends TagComponent { } - - class RotatingSystem extends System { - - execute( delta/*, time*/ ) { - - this.queries.rotatingObjects.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - object.rotation.x += 0.4 * delta; - object.rotation.y += 0.4 * delta; - - } ); - - } - - } - - RotatingSystem.queries = { - rotatingObjects: { - components: [ Rotating ] - } - }; - - class HandsInstructionText extends TagComponent { } - - class InstructionSystem extends System { - - init( attributes ) { - - this.controllers = attributes.controllers; - - } - - execute( /*delta, time*/ ) { - - let visible = false; - this.controllers.forEach( controller => { - - if ( controller.visible ) { - - visible = true; - - } - - } ); - - this.queries.instructionTexts.results.forEach( entity => { - - const object = entity.getComponent( Object3D ).object; - object.visible = visible; - - } ); - - } - - } - - InstructionSystem.queries = { - instructionTexts: { - components: [ HandsInstructionText ] - } - }; - - class OffsetFromCamera extends Component { } - - OffsetFromCamera.schema = { - x: { type: Types.Number, default: 0 }, - y: { type: Types.Number, default: 0 }, - z: { type: Types.Number, default: 0 }, - }; - - class NeedCalibration extends TagComponent { } - - class CalibrationSystem extends System { - - init( attributes ) { - - this.camera = attributes.camera; - this.renderer = attributes.renderer; - - } - - execute( /*delta, time*/ ) { - - this.queries.needCalibration.results.forEach( entity => { - - if ( this.renderer.xr.getSession() ) { - - const offset = entity.getComponent( OffsetFromCamera ); - const object = entity.getComponent( Object3D ).object; - const xrCamera = this.renderer.xr.getCamera(); - object.position.x = xrCamera.position.x + offset.x; - object.position.y = xrCamera.position.y + offset.y; - object.position.z = xrCamera.position.z + offset.z; - entity.removeComponent( NeedCalibration ); - - } - - } ); - - } - - } - - CalibrationSystem.queries = { - needCalibration: { - components: [ NeedCalibration ] - } - }; - - const world = new World(); const timer = new THREE.Timer(); timer.connect( document ); let camera, scene, renderer; + let consoleMesh, torusKnot, instructionText; + let controllers, hands; + let needsCalibration = true; + let buttonPressSound, buttonReleaseSound; + + // button states: [resting, pressed, fully_pressed, recovering] + const buttons = []; init(); @@ -419,6 +127,8 @@ hand2.add( handModel2 ); scene.add( hand2 ); + controllers = [ controllerGrip1, controllerGrip2 ]; + hands = [ handModel1, handModel2 ]; // buttons const floorGeometry = new THREE.PlaneGeometry( 4, 4 ); @@ -430,7 +140,7 @@ const consoleGeometry = new THREE.BoxGeometry( 0.5, 0.12, 0.15 ); const consoleMaterial = new THREE.MeshPhongMaterial( { color: 0x595959 } ); - const consoleMesh = new THREE.Mesh( consoleGeometry, consoleMaterial ); + consoleMesh = new THREE.Mesh( consoleGeometry, consoleMaterial ); consoleMesh.position.set( 0, 1, - 0.3 ); consoleMesh.castShadow = true; consoleMesh.receiveShadow = true; @@ -463,11 +173,11 @@ const tkGeometry = new THREE.TorusKnotGeometry( 0.5, 0.2, 200, 32 ); const tkMaterial = new THREE.MeshPhongMaterial( { color: 0xffffff } ); tkMaterial.metalness = 0.8; - const torusKnot = new THREE.Mesh( tkGeometry, tkMaterial ); + torusKnot = new THREE.Mesh( tkGeometry, tkMaterial ); torusKnot.position.set( 0, 1, - 5 ); scene.add( torusKnot ); - const instructionText = createText( 'This is a WebXR Hands demo, please explore with hands.', 0.04 ); + instructionText = createText( 'This is a WebXR Hands demo, please explore with hands.', 0.04 ); instructionText.position.set( 0, 1.6, - 0.6 ); scene.add( instructionText ); @@ -476,94 +186,180 @@ exitText.visible = false; scene.add( exitText ); - world - .registerComponent( Object3D ) - .registerComponent( Button ) - .registerComponent( Pressable ) - .registerComponent( Rotating ) - .registerComponent( HandsInstructionText ) - .registerComponent( OffsetFromCamera ) - .registerComponent( NeedCalibration ); - - world - .registerSystem( RotatingSystem ) - .registerSystem( InstructionSystem, { controllers: [ controllerGrip1, controllerGrip2 ] } ) - .registerSystem( CalibrationSystem, { renderer: renderer, camera: camera } ) - .registerSystem( ButtonSystem, { renderer: renderer, camera: camera } ) - .registerSystem( FingerInputSystem, { hands: [ handModel1, handModel2 ] } ); - - const csEntity = world.createEntity(); - csEntity.addComponent( OffsetFromCamera, { x: 0, y: - 0.4, z: - 0.3 } ); - csEntity.addComponent( NeedCalibration ); - csEntity.addComponent( Object3D, { object: consoleMesh } ); - - const obEntity = world.createEntity(); - obEntity.addComponent( Pressable ); - obEntity.addComponent( Object3D, { object: orangeButton } ); - const obAction = function () { - - torusKnot.material.color.setHex( 0xffd3b5 ); + buttons.push( + { mesh: orangeButton, currState: 'resting', prevState: 'resting', restingY: orangeButton.position.y, surfaceY: 0.05, recoverySpeed: 0.4, fullPressDistance: 0.02, action: function () { - }; + torusKnot.material.color.setHex( 0xffd3b5 ); - obEntity.addComponent( Button, { action: obAction, surfaceY: 0.05, fullPressDistance: 0.02 } ); + } }, + { mesh: pinkButton, currState: 'resting', prevState: 'resting', restingY: pinkButton.position.y, surfaceY: 0.05, recoverySpeed: 0.4, fullPressDistance: 0.02, action: function () { - const pbEntity = world.createEntity(); - pbEntity.addComponent( Pressable ); - pbEntity.addComponent( Object3D, { object: pinkButton } ); - const pbAction = function () { + torusKnot.material.color.setHex( 0xe84a5f ); - torusKnot.material.color.setHex( 0xe84a5f ); + } }, + { mesh: resetButton, currState: 'resting', prevState: 'resting', restingY: resetButton.position.y, surfaceY: 0.05, recoverySpeed: 0.4, fullPressDistance: 0.02, action: function () { - }; + torusKnot.material.color.setHex( 0xffffff ); - pbEntity.addComponent( Button, { action: pbAction, surfaceY: 0.05, fullPressDistance: 0.02 } ); + } }, + { mesh: exitButton, currState: 'resting', prevState: 'resting', restingY: exitButton.position.y, surfaceY: 0.05, recoverySpeed: 0.2, fullPressDistance: 0.03, action: function () { - const rbEntity = world.createEntity(); - rbEntity.addComponent( Pressable ); - rbEntity.addComponent( Object3D, { object: resetButton } ); - const rbAction = function () { + exitText.visible = true; + setTimeout( function () { - torusKnot.material.color.setHex( 0xffffff ); + exitText.visible = false; renderer.xr.getSession().end(); - }; + }, 2000 ); - rbEntity.addComponent( Button, { action: rbAction, surfaceY: 0.05, fullPressDistance: 0.02 } ); + } } + ); - const ebEntity = world.createEntity(); - ebEntity.addComponent( Pressable ); - ebEntity.addComponent( Object3D, { object: exitButton } ); - const ebAction = function () { + window.addEventListener( 'resize', onWindowResize ); - exitText.visible = true; - setTimeout( function () { + } - exitText.visible = false; renderer.xr.getSession().end(); + function onWindowResize() { - }, 2000 ); + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); - }; + renderer.setSize( window.innerWidth, window.innerHeight ); - ebEntity.addComponent( Button, { action: ebAction, surfaceY: 0.05, recoverySpeed: 0.2, fullPressDistance: 0.03 } ); + } - const tkEntity = world.createEntity(); - tkEntity.addComponent( Rotating ); - tkEntity.addComponent( Object3D, { object: torusKnot } ); + function setupSounds() { - const itEntity = world.createEntity(); - itEntity.addComponent( HandsInstructionText ); - itEntity.addComponent( Object3D, { object: instructionText } ); + // the audio listener requires the XR camera, so the sounds can only be created within a session - window.addEventListener( 'resize', onWindowResize ); + if ( buttonPressSound === undefined && renderer.xr.getSession() ) { + + const xrCamera = renderer.xr.getCamera(); + + const listener = new THREE.AudioListener(); + xrCamera.add( listener ); + + // create a global audio source + buttonPressSound = new THREE.Audio( listener ); + buttonReleaseSound = new THREE.Audio( listener ); + + // load a sound and set it as the Audio object's buffer + const audioLoader = new THREE.AudioLoader(); + audioLoader.load( 'sounds/button-press.ogg', function ( buffer ) { + + buttonPressSound.setBuffer( buffer ); + + } ); + audioLoader.load( 'sounds/button-release.ogg', function ( buffer ) { + + buttonReleaseSound.setBuffer( buffer ); + + } ); + + } } - function onWindowResize() { + function calibrateConsole() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); + // position the console relative to the camera once the session has started - renderer.setSize( window.innerWidth, window.innerHeight ); + if ( needsCalibration && renderer.xr.getSession() ) { + + const xrCamera = renderer.xr.getCamera(); + consoleMesh.position.x = xrCamera.position.x; + consoleMesh.position.y = xrCamera.position.y - 0.4; + consoleMesh.position.z = xrCamera.position.z - 0.3; + needsCalibration = false; + + } + + } + + function updateInstructionText() { + + // the instruction text is only visible as long as motion controllers are used + + instructionText.visible = controllers.some( controller => controller.visible ); + + } + + function updateFingerInput( delta ) { + + buttons.forEach( button => { + + const object = button.mesh; + const pressingDistances = []; + hands.forEach( hand => { + + if ( hand && hand.intersectBoxObject( object ) ) { + + const pressingPosition = hand.getPointerPosition(); + pressingDistances.push( button.surfaceY - object.worldToLocal( pressingPosition ).y ); + + } + + } ); + if ( pressingDistances.length == 0 ) { // not pressed this frame + + if ( object.position.y < button.restingY ) { + + object.position.y += button.recoverySpeed * delta; + button.currState = 'recovering'; + + } else { + + object.position.y = button.restingY; + button.currState = 'resting'; + + } + + } else { + + button.currState = 'pressed'; + const pressingDistance = Math.max( ...pressingDistances ); + if ( pressingDistance > 0 ) { + + object.position.y -= pressingDistance; + + } + + if ( object.position.y <= button.restingY - button.fullPressDistance ) { + + button.currState = 'fully_pressed'; + object.position.y = button.restingY - button.fullPressDistance; + + } + + } + + } ); + + } + + function updateButtons() { + + buttons.forEach( button => { + + if ( button.currState == 'fully_pressed' && button.prevState != 'fully_pressed' ) { + + if ( buttonPressSound ) buttonPressSound.play(); + button.action(); + + } + + if ( button.currState == 'recovering' && button.prevState != 'recovering' ) { + + if ( buttonReleaseSound ) buttonReleaseSound.play(); + + } + + // preserve prevState, clear currState + // updateFingerInput() will update currState + + button.prevState = button.currState; + button.currState = 'resting'; + + } ); } @@ -572,9 +368,17 @@ timer.update(); const delta = timer.getDelta(); - const elapsedTime = timer.getElapsed(); renderer.xr.updateCamera( camera ); - world.execute( delta, elapsedTime ); + + setupSounds(); + calibrateConsole(); + updateInstructionText(); + updateFingerInput( delta ); + updateButtons(); + + torusKnot.rotation.x += 0.4 * delta; + torusKnot.rotation.y += 0.4 * delta; + renderer.render( scene, camera ); } diff --git a/src/nodes/lighting/LightsNode.js b/src/nodes/lighting/LightsNode.js index 9a1dabe607894f..4b547801cf7045 100644 --- a/src/nodes/lighting/LightsNode.js +++ b/src/nodes/lighting/LightsNode.js @@ -155,6 +155,12 @@ class LightsNode extends Node { _hashData.push( light.id ); _hashData.push( light.castShadow ? 1 : 0 ); + if ( light.castShadow === true && light.shadow !== undefined ) { + + _hashData.push( light.shadow.mapSize.width, light.shadow.mapSize.height ); + + } + if ( light.isSpotLight === true ) { const hashMap = ( light.map !== null ) ? light.map.id : - 1;