// movement-utils.js -- helper classes for managing related Controller.*Event and input API bindings /* eslint-disable comma-dangle, no-empty */ /* global require: true, DriveKeys, console, __filename, __dirname */ /* eslint-env commonjs */ "use strict"; module.exports = { version: '0.0.2c', CameraControls: CameraControls, MovementEventMapper: MovementEventMapper, MovementMapping: MovementMapping, VelocityTracker: VelocityTracker, VirtualDriveKeys: VirtualDriveKeys, applyEasing: applyEasing, calculateThrust: calculateThrust, vec3damp: vec3damp, vec3eclamp: vec3eclamp, DriveModes: { POSITION: 'position', // ~ MyAvatar.position MOTOR: 'motor', // ~ MyAvatar.motorVelocity THRUST: 'thrust', // ~ MyAvatar.setThrust }, }; var MAPPING_TEMPLATE = require('./movement-utils.mapping.json'); var WANT_DEBUG = false; function log() { // eslint-disable-next-line no-console (typeof Script === 'object' ? print : console.log)('movement-utils | ' + [].slice.call(arguments).join(' ')); } var debugPrint = function() {}; log(module.exports.version); var _utils = require('./_utils.js'), assert = _utils.assert; if (WANT_DEBUG) { require = _utils.makeDebugRequire(__dirname); _utils = require('./_utils.js'); // re-require in debug mode debugPrint = log; } Object.assign = Object.assign || _utils.assign; var enumMeta = require('./EnumMeta.js'); assert(enumMeta.version >= '0.0.1', 'enumMeta >= 0.0.1 expected but got: ' + enumMeta.version); Object.assign(MovementEventMapper, { CAPTURE_DRIVE_KEYS: 'drive-keys', CAPTURE_ACTION_EVENTS: 'action-events', }); function MovementEventMapper(options) { assert('namespace' in options, '.namespace expected ' + Object.keys(options) ); this.namespace = options.namespace; this.enabled = false; this.options = Object.assign({ namespace: this.namespace, captureMode: MovementEventMapper.CAPTURE_ACTION_EVENTS, excludeNames: null, mouseSmooth: true, keyboardMultiplier: 1.0, mouseMultiplier: 1.0, eventFilter: null, controllerMapping: MAPPING_TEMPLATE, }, options); this.isShifted = false; this.isGrounded = false; this.isRightMouseButton = false; this.rightMouseButtonReleased = undefined; this.inputMapping = new MovementMapping(this.options); this.inputMapping.virtualActionEvent.connect(this, 'onVirtualActionEvent'); } MovementEventMapper.prototype = { constructor: MovementEventMapper, defaultEventFilter: function(from, event) { return event.actionValue; }, getState: function(options) { var state = this.states ? this.states.getDriveKeys(options) : {}; state.enabled = this.enabled; state.mouseSmooth = this.options.mouseSmooth; state.captureMode = this.options.captureMode; state.mouseMultiplier = this.options.mouseMultiplier; state.keyboardMultiplier = this.options.keyboardMultiplier; state.isGrounded = this.isGrounded; state.isShifted = this.isShifted; state.isRightMouseButton = this.isRightMouseButton; state.rightMouseButtonReleased = this.rightMouseButtonReleased; return state; }, updateOptions: function(options) { return _updateOptions(this.options, options || {}, this.constructor.name); }, applyOptions: function(options, applyNow) { if (this.updateOptions(options) && applyNow) { this.reset(); } }, reset: function() { if (this.enabled) { this.disable(); this.enable(); } }, disable: function() { this.inputMapping.disable(); this.bindEvents(false); this.enabled = false; }, enable: function() { if (!this.enabled) { this.enabled = true; this.states = new VirtualDriveKeys({ eventFilter: this.options.eventFilter && _utils.bind(this, this.options.eventFilter) }); this.bindEvents(true); this.inputMapping.updateOptions(this.options); this.inputMapping.enable(); } }, bindEvents: function bindEvents(capture) { var captureMode = this.options.captureMode; assert(function assertion() { return captureMode === MovementEventMapper.CAPTURE_ACTION_EVENTS || captureMode === MovementEventMapper.CAPTURE_DRIVE_KEYS; }); log('bindEvents....', capture, this.options.captureMode); var exclude = Array.isArray(this.options.excludeNames) && this.options.excludeNames; var tmp; if (!capture || this.options.captureMode === MovementEventMapper.CAPTURE_ACTION_EVENTS) { tmp = capture ? 'captureActionEvents' : 'releaseActionEvents'; log('bindEvents -- ', tmp.toUpperCase()); Controller[tmp](); } if (!capture || this.options.captureMode === MovementEventMapper.CAPTURE_DRIVE_KEYS) { tmp = capture ? 'disableDriveKey' : 'enableDriveKey'; log('bindEvents -- ', tmp.toUpperCase()); for (var p in DriveKeys) { if (capture && (exclude && ~exclude.indexOf(p))) { log(tmp.toUpperCase(), 'excluding DriveKey===' + p); } else { MyAvatar[tmp](DriveKeys[p]); } } } try { Controller.actionEvent[capture ? 'connect' : 'disconnect'](this, 'onActionEvent'); } catch (e) { } if (!capture || !/person/i.test(Camera.mode)) { Controller[capture ? 'captureWheelEvents' : 'releaseWheelEvents'](); try { Controller.wheelEvent[capture ? 'connect' : 'disconnect'](this, 'onWheelEvent'); } catch (e) { /* eslint-disable-line empty-block */ } } }, onWheelEvent: function onWheelEvent(event) { var actionID = enumMeta.ACTION_TRANSLATE_CAMERA_Z, actionValue = -event.delta; return this.onActionEvent(actionID, actionValue, event); }, onActionEvent: function(actionID, actionValue, extra) { var actionName = enumMeta.Controller.ActionNames[actionID], driveKeyName = enumMeta.getDriveKeyNameFromActionName(actionName), prefix = (actionValue > 0 ? '+' : actionValue < 0 ? '-' : ' '); var event = { id: prefix + actionName, actionName: actionName, driveKey: DriveKeys[driveKeyName], driveKeyName: driveKeyName, actionValue: actionValue, extra: extra }; // debugPrint('onActionEvent', actionID, actionName, driveKeyName); this.states.handleActionEvent('Actions.' + actionName, event); }, onVirtualActionEvent: function(from, event) { if (from === 'Application.Grounded') { this.isGrounded = !!event.applicationValue; } else if (from === 'Keyboard.Shift') { this.isShifted = !!event.value; } else if (from === 'Keyboard.RightMouseButton') { this.isRightMouseButton = !!event.value; this.rightMouseButtonReleased = !event.value ? new Date : undefined; } this.states.handleActionEvent(from, event); } }; // MovementEventMapper.prototype // ---------------------------------------------------------------------------- // helper JS class to track drive keys -> translation / rotation influences function VirtualDriveKeys(options) { options = options || {}; Object.defineProperties(this, { $pendingReset: { value: {} }, $eventFilter: { value: options.eventFilter }, $valueUpdated: { value: _utils.signal(function valueUpdated(action, newValue, oldValue){}) } }); } VirtualDriveKeys.prototype = { constructor: VirtualDriveKeys, update: function update(dt) { Object.keys(this.$pendingReset).forEach(_utils.bind(this, function(i) { var event = this.$pendingReset[i].event; (event.driveKey in this) && this.setValue(event, 0); })); }, getValue: function(driveKey, defaultValue) { return driveKey in this ? this[driveKey] : defaultValue; }, _defaultFilter: function(from, event) { return event.actionValue; }, handleActionEvent: function(from, event) { var value = this.$eventFilter ? this.$eventFilter(from, event, this._defaultFilter) : event.actionValue; return event.driveKeyName && this.setValue(event, value); }, setValue: function(event, value) { var driveKeyName = event.driveKeyName, driveKey = DriveKeys[driveKeyName], id = event.id, previous = this[driveKey], autoReset = (driveKeyName === 'ZOOM'); this[driveKey] = value; if (previous !== value) { this.$valueUpdated(event, value, previous); } if (value === 0.0) { delete this.$pendingReset[id]; } else if (autoReset) { this.$pendingReset[id] = { event: event, value: value }; } }, reset: function() { Object.keys(this).forEach(_utils.bind(this, function(p) { this[p] = 0.0; })); Object.keys(this.$pendingReset).forEach(_utils.bind(this, function(p) { delete this.$pendingReset[p]; })); }, toJSON: function() { var obj = {}; for (var key in this) { if (enumMeta.DriveKeyNames[key]) { obj[enumMeta.DriveKeyNames[key]] = this[key]; } } return obj; }, getDriveKeys: function(options) { options = options || {}; try { return { translation: { x: this.getValue(DriveKeys.TRANSLATE_X) || 0, y: this.getValue(DriveKeys.TRANSLATE_Y) || 0, z: this.getValue(DriveKeys.TRANSLATE_Z) || 0 }, rotation: { x: this.getValue(DriveKeys.PITCH) || 0, y: this.getValue(DriveKeys.YAW) || 0, z: 'ROLL' in DriveKeys && this.getValue(DriveKeys.ROLL) || 0 }, zoom: Vec3.multiply(this.getValue(DriveKeys.ZOOM) || 0, Vec3.ONE) }; } finally { options.update && this.update(options.update); } } }; // ---------------------------------------------------------------------------- // MovementMapping function MovementMapping(options) { options = options || {}; assert('namespace' in options && 'controllerMapping' in options); this.namespace = options.namespace; this.enabled = false; this.options = { keyboardMultiplier: 1.0, mouseMultiplier: 1.0, mouseSmooth: true, captureMode: MovementEventMapper.CAPTURE_ACTION_EVENTS, excludeNames: null, controllerMapping: MAPPING_TEMPLATE, }; this.updateOptions(options); this.virtualActionEvent = _utils.signal(function virtualActionEvent(from, event) {}); } MovementMapping.prototype = { constructor: MovementMapping, enable: function() { this.enabled = true; if (this.mapping) { this.mapping.disable(); } this.mapping = this._createMapping(); log('ENABLE CONTROLLER MAPPING', this.mapping.name); this.mapping.enable(); }, disable: function() { this.enabled = false; if (this.mapping) { log('DISABLE CONTROLLER MAPPING', this.mapping.name); this.mapping.disable(); } }, reset: function() { var enabled = this.enabled; enabled && this.disable(); this.mapping = this._createMapping(); enabled && this.enable(); }, updateOptions: function(options) { return _updateOptions(this.options, options || {}, this.constructor.name); }, applyOptions: function(options, applyNow) { if (this.updateOptions(options) && applyNow) { this.reset(); } }, onShiftKey: function onShiftKey(value, key) { var event = { type: value ? 'keypress' : 'keyrelease', keyboardKey: key, keyboardText: 'SHIFT', keyboardValue: value, actionName: 'Shift', actionValue: !!value, value: !!value, at: +new Date }; this.virtualActionEvent('Keyboard.Shift', event); }, onRightMouseButton: function onRightMouseButton(value, key) { var event = { type: value ? 'mousepress' : 'mouserelease', keyboardKey: key, keyboardValue: value, actionName: 'RightMouseButton', actionValue: !!value, value: !!value, at: +new Date }; this.virtualActionEvent('Keyboard.RightMouseButton', event); }, onApplicationEvent: function _onApplicationEvent(key, name, value) { var event = { type: 'application', actionName: 'Application.' + name, applicationKey: key, applicationName: name, applicationValue: value, actionValue: !!value, value: !!value }; this.virtualActionEvent('Application.' + name, event); }, _createMapping: function() { this._mapping = this._getTemplate(); var mappingJSON = JSON.stringify(this._mapping, 0, 2); var mapping = Controller.parseMapping(mappingJSON); debugPrint(mappingJSON); mapping.name = mapping.name || this._mapping.name; mapping.from(Controller.Hardware.Keyboard.Shift).peek().to(_utils.bind(this, 'onShiftKey')); mapping.from(Controller.Hardware.Keyboard.RightMouseButton).peek().to(_utils.bind(this, 'onRightMouseButton')); var boundApplicationHandler = _utils.bind(this, 'onApplicationEvent'); Object.keys(Controller.Hardware.Application).forEach(function(name) { var key = Controller.Hardware.Application[name]; debugPrint('observing Controller.Hardware.Application.'+ name, key); mapping.from(key).to(function(value) { boundApplicationHandler(key, name, value); }); }); return mapping; }, _getTemplate: function() { assert(this.options.controllerMapping, 'MovementMapping._getTemplate -- !this.options.controllerMapping'); var template = JSON.parse(JSON.stringify(this.options.controllerMapping)); // make a local copy template.name = this.namespace; template.channels = template.channels.filter(function(item) { // ignore any "JSON comment" or other bindings without a from spec return item.from && item.from.makeAxis; }); var exclude = Array.isArray(this.options.excludeNames) ? this.options.excludeNames : []; if (!this.options.mouseSmooth) { exclude.push('Keyboard.RightMouseButton'); } log('EXCLUSIONS:' + exclude); template.channels = template.channels.filter(_utils.bind(this, function(item, i) { debugPrint('channel['+i+']', item.from && item.from.makeAxis, item.to, JSON.stringify(item.filters) || ''); // var hasFilters = Array.isArray(item.filters) && !item.filters[1]; item.filters = Array.isArray(item.filters) ? item.filters : typeof item.filters === 'string' ? [ { type: item.filters }] : [ item.filters ]; if (/Mouse/.test(item.from && item.from.makeAxis)) { item.filters.push({ type: 'scale', scale: this.options.mouseMultiplier }); log('applied mouse multiplier:', item.from.makeAxis, item.when, item.to, this.options.mouseMultiplier); } else if (/Keyboard/.test(item.from && item.from.makeAxis)) { item.filters.push({ type: 'scale', scale: this.options.keyboardMultiplier }); log('applied keyboard multiplier:', item.from.makeAxis, item.when, item.to, this.options.keyboardMultiplier); } item.filters = item.filters.filter(Boolean); if (~exclude.indexOf(item.to)) { log('EXCLUDING item.to === ' + item.to); return false; } var when = Array.isArray(item.when) ? item.when : [item.when]; for (var j=0; j < when.length; j++) { if (~exclude.indexOf(when[j])) { log('EXCLUDING item.when contains ' + when[j]); return false; } } function shouldInclude(p, i) { if (~exclude.indexOf(p)) { log('EXCLUDING from.makeAxis[][' + i + '] === ' + p); return false; } return true; } if (item.from && Array.isArray(item.from.makeAxis)) { var makeAxis = item.from.makeAxis; item.from.makeAxis = makeAxis.map(function(axis) { if (Array.isArray(axis)) { return axis.filter(shouldInclude); } else { return shouldInclude(axis, -1) && axis; } }).filter(Boolean); } return true; })); debugPrint(JSON.stringify(template,0,2)); return template; } }; // MovementMapping.prototype // update target properties from source, but iff the property already exists in target function _updateOptions(target, source, debugName) { debugName = debugName || '_updateOptions'; var changed = 0; if (!source || typeof source !== 'object') { return changed; } for (var p in target) { if (p in source && target[p] !== source[p]) { log(debugName, 'updating source.'+p, target[p] + ' -> ' + source[p]); target[p] = source[p]; changed++; } } for (p in source) { (!(p in target)) && log(debugName, 'warning: ignoring unknown option:', p, (source[p] +'').substr(0, 40)+'...'); } return changed; } // ---------------------------------------------------------------------------- function calculateThrust(maxVelocity, targetVelocity, previousThrust) { var THRUST_FALLOFF = 0.1; // round to ZERO if component is below this threshold // Note: MyAvatar.setThrust might need an update to account for the recent avatar density changes... // For now, this discovered scaling factor seems to accomodate a similar easing effect to the other movement models. var magicScalingFactor = 12.0 * (maxVelocity + 120) / 16 - Math.sqrt( maxVelocity / 8 ); var targetThrust = Vec3.multiply(magicScalingFactor, targetVelocity); targetThrust = vec3eclamp(targetThrust, THRUST_FALLOFF, maxVelocity); if (Vec3.length(MyAvatar.velocity) > maxVelocity) { targetThrust = Vec3.multiply(0.5, targetThrust); } return targetThrust; } // ---------------------------------------------------------------------------- // clamp components and magnitude to maxVelocity, rounding to Vec3.ZERO if below epsilon function vec3eclamp(velocity, epsilon, maxVelocity) { velocity = { x: Math.max(-maxVelocity, Math.min(maxVelocity, velocity.x)), y: Math.max(-maxVelocity, Math.min(maxVelocity, velocity.y)), z: Math.max(-maxVelocity, Math.min(maxVelocity, velocity.z)) }; if (Math.abs(velocity.x) < epsilon) { velocity.x = 0; } if (Math.abs(velocity.y) < epsilon) { velocity.y = 0; } if (Math.abs(velocity.z) < epsilon) { velocity.z = 0; } var length = Vec3.length(velocity); if (length > maxVelocity) { velocity = Vec3.multiply(maxVelocity, Vec3.normalize(velocity)); } else if (length < epsilon) { velocity = Vec3.ZERO; } return velocity; } function vec3damp(active, positiveEffect, negativeEffect) { // If force isn't being applied in a direction, incorporate negative effect (drag); negativeEffect = { x: active.x ? 0 : negativeEffect.x, y: active.y ? 0 : negativeEffect.y, z: active.z ? 0 : negativeEffect.z, }; return Vec3.subtract(Vec3.sum(active, positiveEffect), negativeEffect); } // ---------------------------------------------------------------------------- function VelocityTracker(defaultValues) { Object.defineProperty(this, 'defaultValues', { configurable: true, value: defaultValues }); } VelocityTracker.prototype = { constructor: VelocityTracker, reset: function() { Object.assign(this, this.defaultValues); }, integrate: function(targetState, currentVelocities, drag, settings) { var args = [].slice.call(arguments); this._applyIntegration('translation', args); this._applyIntegration('rotation', args); this._applyIntegration('zoom', args); }, _applyIntegration: function(component, args) { return this._integrate.apply(this, [component].concat(args)); }, _integrate: function(component, targetState, currentVelocities, drag, settings) { assert(targetState[component], component + ' not found in targetState (which has: ' + Object.keys(targetState) + ')'); var result = vec3damp( targetState[component], currentVelocities[component], drag[component] ); var maxVelocity = settings[component].maxVelocity; return this[component] = vec3eclamp(result, settings.epsilon, maxVelocity); }, }; // ---------------------------------------------------------------------------- // ---------------------------------------------------------------------------- Object.assign(CameraControls, { SCRIPT_UPDATE: 'update', ANIMATION_FRAME: 'requestAnimationFrame', // emulated NEXT_TICK: 'nextTick', // emulated SET_IMMEDIATE: 'setImmediate', // emulated //WORKER_THREAD: 'workerThread', }); function CameraControls(options) { options = options || {}; assert('update' in options && 'threadMode' in options); this.updateObject = typeof options.update === 'function' ? options : options.update; assert(typeof this.updateObject.update === 'function', 'construction options expected either { update: function(){}... } object or a function(){}'); this.update = _utils.bind(this.updateObject, 'update'); this.threadMode = options.threadMode; this.fps = options.fps || 60; this.getRuntimeSeconds = options.getRuntimeSeconds || function() { return +new Date / 1000.0; }; this.backupOptions = _utils.DeferredUpdater.createGroup({ MyAvatar: MyAvatar, Camera: Camera, Reticle: Reticle, }); this.enabled = false; this.enabledChanged = _utils.signal(function enabledChanged(enabled){}); this.modeChanged = _utils.signal(function modeChanged(mode, oldMode){}); } CameraControls.prototype = { constructor: CameraControls, $animate: null, $start: function() { if (this.$animate) { return; } var lastTime; switch (this.threadMode) { case CameraControls.SCRIPT_UPDATE: { this.$animate = this.update; Script.update.connect(this, '$animate'); this.$animate.disconnect = _utils.bind(this, function() { Script.update.disconnect(this, '$animate'); }); } break; case CameraControls.ANIMATION_FRAME: { this.requestAnimationFrame = _utils.createAnimationStepper({ getRuntimeSeconds: this.getRuntimeSeconds, fps: this.fps }); this.$animate = _utils.bind(this, function(dt) { this.update(dt); this.requestAnimationFrame(this.$animate); }); this.$animate.disconnect = _utils.bind(this.requestAnimationFrame, 'reset'); this.requestAnimationFrame(this.$animate); } break; case CameraControls.SET_IMMEDIATE: { // emulate process.setImmediate (attempt to execute at start of next update frame, sans Script.update throttling) lastTime = this.getRuntimeSeconds(); this.$animate = Script.setInterval(_utils.bind(this, function() { this.update(this.getRuntimeSeconds(lastTime)); lastTime = this.getRuntimeSeconds(); }), 5); this.$animate.disconnect = function() { Script.clearInterval(this); }; } break; case CameraControls.NEXT_TICK: { // emulate process.nextTick (attempt to queue at the very next opportunity beyond current scope) lastTime = this.getRuntimeSeconds(); this.$animate = _utils.bind(this, function() { this.$animate.timeout = 0; if (this.$animate.quit) { return; } this.update(this.getRuntimeSeconds(lastTime)); lastTime = this.getRuntimeSeconds(); this.$animate.timeout = Script.setTimeout(this.$animate, 0); }); this.$animate.quit = false; this.$animate.disconnect = function() { this.timeout && Script.clearTimeout(this.timeout); this.timeout = 0; this.quit = true; }; this.$animate(); } break; default: throw new Error('unknown threadMode: ' + this.threadMode); } log( '...$started update thread', '(threadMode: ' + this.threadMode + ')', this.threadMode === CameraControls.ANIMATION_FRAME && this.fps ); }, $stop: function() { if (!this.$animate) { return; } try { this.$animate.disconnect(); } catch (e) { log('$animate.disconnect error: ' + e, '(threadMode: ' + this.threadMode +')'); } this.$animate = null; log('...$stopped updated thread', '(threadMode: ' + this.threadMode +')'); }, onModeUpdated: function onModeUpdated(mode, oldMode) { oldMode = oldMode || this.previousMode; this.previousMode = mode; log('onModeUpdated', oldMode + '->' + mode); // user changed modes, so leave the current mode intact later when restoring backup values delete this.backupOptions.Camera.$setModeString; if (/person/.test(oldMode) && /person/.test(mode)) { return; // disregard first -> third and third ->first transitions } this.modeChanged(mode, oldMode); }, reset: function() { if (this.enabled) { this.disable(); this.enable(); } }, setEnabled: function setEnabled(enabled) { if (!this.enabled && enabled) { this.enable(); } else if (this.enabled && !enabled) { this.disable(); } }, enable: function enable() { if (this.enabled) { throw new Error('CameraControls.enable -- already enabled..'); } log('ENABLE enableCameraMove', this.threadMode); this._backup(); this.previousMode = Camera.mode; Camera.modeUpdated.connect(this, 'onModeUpdated'); this.$start(); this.enabledChanged(this.enabled = true); }, disable: function disable() { log("DISABLE CameraControls"); try { Camera.modeUpdated.disconnect(this, 'onModeUpdated'); } catch (e) { debugPrint(e); } this.$stop(); this._restore(); if (this.enabled !== false) { this.enabledChanged(this.enabled = false); } }, _restore: function() { var submitted = this.backupOptions.submit(); log('restored previous values: ' + JSON.stringify(submitted,0,2)); return submitted; }, _backup: function() { this.backupOptions.reset(); Object.assign(this.backupOptions.Reticle, { scale: Reticle.scale, }); Object.assign(this.backupOptions.Camera, { $setModeString: Camera.mode, }); Object.assign(this.backupOptions.MyAvatar, { motorTimescale: MyAvatar.motorTimescale, motorReferenceFrame: MyAvatar.motorReferenceFrame, motorVelocity: Vec3.ZERO, velocity: Vec3.ZERO, angularVelocity: Vec3.ZERO, }); }, }; // CameraControls // ---------------------------------------------------------------------------- function applyEasing(deltaTime, direction, settings, state, scaling) { var obj = {}; for (var p in scaling) { var group = settings[p], // translation | rotation | zoom easeConst = group[direction], // easeIn | easeOut scale = scaling[p], stateVector = state[p]; obj[p] = Vec3.multiply(easeConst * scale * deltaTime, stateVector); } return obj; }