diff --git a/unpublishedScripts/marketplace/camera-move/Eye-Camera.svg b/unpublishedScripts/marketplace/camera-move/Eye-Camera.svg new file mode 100644 index 0000000000..c58700823d --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/Eye-Camera.svg @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 -270 2622 1198" enable-background="new 0 0 2622 1198" xml:space="preserve"> +<g transform="scale(1,.4546)"> + <path d="M2591,599c0,0-573.0756,558.7166-1280,558.7166S31,599,31,599S604.0755,40.2834,1311,40.2834S2591,599,2591,599z"/> + <g> + <path fill="#FFFFFF" d="M1507.3644,831.6261c8.1908-85.9868,6.8108-166.8081,5.306-206.6722l-92.7485,160.6452l0.1365,0.2027 + c-0.0315,0.0212-0.1725,0.1157-0.4128,0.2759l-0.3179,0.5509h-0.5111c-11.4204,7.5681-99.7214,65.3458-204.2842,113.1478 + c-65.7413,30.0543-125.0349,50.3848-176.2341,60.427c-33.0687,6.4862-62.8685,8.665-89.223,6.5692 + c93.1363,91.6643,220.923,148.226,361.9247,148.226c38.7316,0,76.463-4.2765,112.7566-12.3666 + c19.0804-22.5304,35.3513-53.2766,48.5972-91.9321C1488.853,962.5517,1500.6323,902.3027,1507.3644,831.6261z"/> + <path fill="#FFFFFF" d="M1202.6725,411.3712h0.5111c11.4207-7.5681,99.7214-65.3457,204.2842-113.1477 + c65.7412-30.0542,125.0348-50.3848,176.234-60.4269c33.0687-6.4863,62.8685-8.6651,89.223-6.5692 + C1579.7885,139.563,1452.0017,83.0015,1311,83.0015c-38.7316,0-76.463,4.2764-112.7566,12.3666 + c-19.0803,22.5305-35.3513,53.2766-48.5973,91.932c-16.499,48.1481-28.2784,108.3973-35.0105,179.0738 + c-8.1908,85.9868-6.8108,166.8082-5.306,206.6722l92.7484-160.6451l-0.1364-0.2027c0.0314-0.0211,0.1724-0.1157,0.4127-0.2759 + L1202.6725,411.3712z"/> + <path fill="#FFFFFF" d="M1586.8657,253.4136c-49.9471,9.7853-108.0142,29.7088-172.5878,59.2168 + c-78.5621,35.9-147.8654,77.5058-181.6364,98.741h185.4969l0.1073-0.2195c0.0342,0.0167,0.1865,0.0915,0.4454,0.2195h0.6364 + l0.2554,0.4426c12.2645,6.1064,106.4518,53.6884,200.1307,120.3414c58.8986,41.9065,106.1522,83.091,140.4484,122.4097 + c22.2083,25.4601,39.0232,50.2394,50.3865,74.1677c10.7344-41.452,16.45-84.9244,16.45-129.7325 + c0-134.8296-51.7194-257.5746-136.3927-349.4958C1661.5886,244.2816,1626.8857,245.5726,1586.8657,253.4136z"/> + <path fill="#FFFFFF" d="M1748.2195,665.1135c-33.4479-38.3628-79.7356-78.6885-137.5773-119.857 + c-70.3712-50.0869-141.0546-89.3023-176.3302-107.9312l92.7485,160.6452l0.2438-0.0168c0.0027,0.0379,0.014,0.2072,0.0326,0.4955 + l0.318,0.551l-0.2555,0.4425c0.844,13.6745,6.7305,119.0341-4.1532,233.4891c-6.8429,71.9608-18.8829,133.4758-35.7858,182.8367 + c-10.9449,31.963-23.9971,58.9147-39.0378,80.7199c168.875-46.5455,303.0535-176.7669,355.1321-343.2732 + C1793.5869,725.4113,1775.0889,695.9305,1748.2195,665.1135z"/> + <path fill="#FFFFFF" d="M1035.1344,944.5864c49.947-9.7854,108.014-29.7087,172.5876-59.2168 + c78.5623-35.9,147.8655-77.5058,181.6365-98.741h-185.4971l-0.1073,0.2195c-0.0342-0.0167-0.1865-0.0915-0.4454-0.2195h-0.6364 + l-0.2554-0.4426c-12.2645-6.1064-106.4518-53.6884-200.1309-120.3412c-58.8984-41.9065-106.152-83.091-140.4482-122.4097 + c-22.2083-25.46-39.0232-50.2393-50.3865-74.1676C800.7169,510.7194,795.0015,554.192,795.0015,599 + c0,134.8295,51.7193,257.5745,136.3928,349.4958C960.4114,953.7184,995.1143,952.4273,1035.1344,944.5864z"/> + <path fill="#FFFFFF" d="M873.7805,532.8865c33.4479,38.3627,79.7355,78.6885,137.5771,119.857 + c70.3715,50.0868,141.0547,89.3022,176.3304,107.9312l-92.7487-160.645l-0.2438,0.0167c-0.0026-0.0378-0.014-0.2072-0.0326-0.4954 + l-0.318-0.551l0.2555-0.4426c-0.8439-13.6744-6.7303-119.034,4.1532-233.4892c6.8429-71.9607,18.8829-133.4758,35.7858-182.8366 + c10.9451-31.963,23.9971-58.9148,39.0378-80.7198c-168.8749,46.5456-303.0535,176.7669-355.132,343.2733 + C828.413,472.5887,846.9111,502.0695,873.7805,532.8865z"/> + </g> +</g> +</svg> diff --git a/unpublishedScripts/marketplace/camera-move/_debug.js b/unpublishedScripts/marketplace/camera-move/_debug.js new file mode 100644 index 0000000000..1b90acffcf --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/_debug.js @@ -0,0 +1,99 @@ +_debug = { + handleUncaughtException: function onerror(message, fileName, lineNumber, colNumber, err) { + var output = _utils.normalizeStackTrace(err || { message: message }); + console.error('window.onerror: ' + output, err); + var errorNode = document.querySelector('#errors'), + textNode = errorNode && errorNode.querySelector('.output'); + if (textNode) textNode.innerText = output; + if (errorNode) errorNode.style.display = 'block'; + }, + loadScriptNodes: function loadScriptNodes(selector) { + // scripts are loaded this way to ensure refreshing the client script refreshes dependencies too + [].forEach.call(document.querySelectorAll(selector), function(script) { + script.parentNode.removeChild(script); + if (script.src) { + script.src += location.search; + } + script.type = 'application/javascript'; + document.write(script.outerHTML); + }); + }, + // TESTING MOCK (allows the UI to be tested using a normal web browser, outside of Interface + openEventBridgeMock: function openEventBridgeMock(onEventBridgeOpened) { + // emulate EventBridge's API + EventBridge = { + emitWebEvent: signal(function emitWebEvent(message){}), + scriptEventReceived: signal(function scriptEventReceived(message){}), + }; + EventBridge.emitWebEvent.connect(onEmitWebEvent); + onEventBridgeOpened(EventBridge); + setTimeout(function() { + assert(!bridgedSettings.onUnhandledMessage); + bridgedSettings.onUnhandledMessage = function(msg) { + return true; + }; + // manually trigger bootstrapping responses + $('.slider .control').parent().css('visibility','visible'); + bridgedSettings.handleExtraParams({uuid: PARAMS.uuid, ns: PARAMS.ns, extraParams: { + mock: true, + appVersion: 'browsermock', + toggleKey: { text: 'SPACE', isShifted: true }, + } }); + bridgedSettings.setValue('ui-show-advanced-options', true); + if (/fps/.test(location.hash)) setTimeout(function() { $('#fps').each(function(){ this.scrollIntoView(); }); }, 100); + },1); + + function log(msg) { + console.log.apply(console, ['[mock] ' + msg].concat([].slice.call(arguments,1))); + } + + var updatedValues = {}; + // generate mock data in response to outgoing web events + function onEmitWebEvent(message) { + try { var obj = JSON.parse(message); } catch(e) {} + if (!obj) { + // message isn't JSON or doesn't expect a reply so just log it and bail early + log('consuming non-callback web event', message); + return; + } + switch(obj.method) { + case 'valueUpdated': { + updatedValues[obj.params[0]] = obj.params[1]; + } break; + case 'Settings.getValue': { + var key = obj.params[0]; + var node = jquerySettings.findNodeByKey(key, true); + var type = node && (node.dataset.type || node.getAttribute('type')); + switch(type) { + case 'checkbox': { + obj.result = /tooltip/i.test(key) || PARAMS.tooltiptest ? true : Math.random() > .5; + } break; + case 'radio-group': { + var radios = $(node).find('input[type=radio]').toArray(); + while(Math.random() < .9) { radios.push(radios.shift()); } + obj.result = radios[0].value; + } break; + case 'number': { + var step = node.step || 1, precision = (1/step).toString().length - 1; + var magnitude = node.max || (precision >=1 ? Math.pow(10, precision-1) : 10); + obj.result = parseFloat((Math.random() * magnitude).toFixed(precision||1)); + } break; + default: { + log('unhandled node type for making dummy data: ' + [key, node && node.type, type, node && node.getAttribute('type')] + ' @ ' + (node && node.id)); + obj.result = updatedValues[key] || false; + } break; + } + log('mock getValue data %c%s = %c%s', 'color:blue', + JSON.stringify(key), 'color:green', JSON.stringify(obj.result)); + } break; + default: { + log('ignoring outbound method call', obj); + } break; + } + setTimeout(function() { + EventBridge.scriptEventReceived(JSON.stringify(obj)); + }, 100); + } + }, +}; + diff --git a/unpublishedScripts/marketplace/camera-move/app-camera-move.js b/unpublishedScripts/marketplace/camera-move/app-camera-move.js new file mode 100644 index 0000000000..25c11e5a93 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/app-camera-move.js @@ -0,0 +1,874 @@ +// app-camera-move.js +// +// Created by Timothy Dedischew on 05/05/2017. +// Copyright 2017 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// +// This Client script sets up the Camera Control Tablet App, which can be used to configure and +// drive your avatar with easing/smoothing movement constraints for a less jittery filming experience. + +/* eslint-disable comma-dangle */ +"use strict"; + +var VERSION = '0.0.0d', + NAMESPACE = 'app-camera-move', + APP_HTML_URL = Script.resolvePath('app.html'), + BUTTON_CONFIG = { + text: '\nCam Drive', + icon: Script.resolvePath('Eye-Camera.svg'), + }, + DEFAULT_TOGGLE_KEY = '{ "text": "SPACE" }'; + +var MINIMAL_CURSOR_SCALE = 0.5, + FILENAME = Script.resolvePath(''), + WANT_DEBUG = ( + false || (FILENAME.match(/[&#?]debug[=](\w+)/)||[])[1] || + Settings.getValue(NAMESPACE + '/debug') + ), + EPSILON = 1e-6, + DEG_TO_RAD = Math.PI / 180.0; + +WANT_DEBUG = 1; + +function log() { + print( NAMESPACE + ' | ' + [].slice.call(arguments).join(' ') ); +} + +var require = Script.require, + debugPrint = function(){}, + _debugChannel = NAMESPACE + '.stats', + overlayDebugOutput = function(){}; + +if (WANT_DEBUG) { + _instrumentDebugValues(); +} + +var _utils = require('./modules/_utils.js'), + assert = _utils.assert, + CustomSettingsApp = require('./modules/custom-settings-app/CustomSettingsApp.js'), + movementUtils = require('./modules/movement-utils.js?'+ +new Date), + configUtils = require('./modules/config-utils.js'); + +Object.assign = Object.assign || _utils.assign; + +var cameraControls, eventMapper, cameraConfig, applicationConfig; + +var DEFAULTS = { + 'namespace': NAMESPACE, + 'debug': WANT_DEBUG, + 'camera-move-enabled': false, + 'thread-update-mode': movementUtils.CameraControls.SCRIPT_UPDATE, + 'fps': 90, + 'drive-mode': movementUtils.DriveModes.MOTOR, + 'use-head': true, + 'stay-grounded': true, + 'prevent-roll': true, + 'constant-delta-time': false, + 'minimal-cursor': false, + 'normalize-inputs': false, + 'enable-mouse-smooth': true, + 'translation-max-velocity': 5.50, + 'translation-ease-in': 1.25, + 'translation-ease-out': 5.50, + 'rotation-max-velocity': 90.00, + 'rotation-ease-in': 1.00, + 'rotation-ease-out': 5.50, + 'rotation-x-speed': 45, + 'rotation-y-speed': 60, + 'rotation-z-speed': 1, + 'rotation-mouse-multiplier': 1.0, + 'rotation-keyboard-multiplier': 1.0, + + 'ui-enable-tooltips': true, + 'ui-show-advanced-options': false, +}; + +// map setting names to/from corresponding Menu and API properties +var APPLICATION_SETTINGS = { + 'Avatar/pitchSpeed': 'pitchSpeed' in MyAvatar && { + object: [ MyAvatar, 'pitchSpeed' ] + }, + 'Avatar/yawSpeed': 'yawSpeed' in MyAvatar && { + object: [ MyAvatar, 'yawSpeed' ] + }, + 'Avatar/Enable Avatar Collisions': { + menu: 'Avatar > Enable Avatar Collisions', + object: [ MyAvatar, 'collisionsEnabled' ], + }, + 'Avatar/Draw Mesh': { + menu: 'Developer > Draw Mesh', + // object: [ MyAvatar, 'shouldRenderLocally' ], // shouldRenderLocally seems to be broken... + object: [ MyAvatar, 'getEnableMeshVisible', 'setEnableMeshVisible' ], + }, + 'Avatar/useSnapTurn': { + object: [ MyAvatar, 'getSnapTurn', 'setSnapTurn' ], + }, + 'Avatar/lookAtSnappingEnabled': 'lookAtSnappingEnabled' in MyAvatar && { + menu: 'Developer > Enable LookAt Snapping', + object: [ MyAvatar, 'lookAtSnappingEnabled' ] + }, + 'camera-move-enabled': { + get: function() { + return cameraControls && cameraControls.enabled; + }, + set: function(nv) { + cameraControls.setEnabled(!!nv); + }, + }, +}; + + +var DEBUG_INFO = { + // these values are also sent to the tablet app after EventBridge initialization + appVersion: VERSION, + utilsVersion: _utils.version, + movementVersion: movementUtils.version, + configVersion: configUtils.version, + clientScript: Script.resolvePath(''), + MyAvatar: { + supportsPitchSpeed: 'pitchSpeed' in MyAvatar, + supportsYawSpeed: 'yawSpeed' in MyAvatar, + supportsLookAtSnappingEnabled: 'lookAtSnappingEnabled' in MyAvatar, + supportsDensity: 'density' in MyAvatar, + }, + Reticle: { + supportsScale: 'scale' in Reticle, + }, + protocolVersion: location.protocolVersion, +}; + +var globalState = { + // cached values from the last animation frame + previousValues: { + reset: function() { + this.pitchYawRoll = Vec3.ZERO; + this.thrust = Vec3.ZERO; + }, + }, + + // batch updates to MyAvatar/Camera properties (submitting together seems to help reduce timeslice jitter) + pendingChanges: _utils.DeferredUpdater.createGroup({ + Camera: Camera, + MyAvatar: MyAvatar, + }, { dedupe: false }), + + // current input controls' effective velocities + currentVelocities: new movementUtils.VelocityTracker({ + translation: Vec3.ZERO, + step_translation: Vec3.ZERO, + rotation: Vec3.ZERO, + step_rotation: Vec3.ZERO, + zoom: Vec3.ZERO, + }), +}; + +function main() { + log('initializing...', VERSION); + + var tablet = Tablet.getTablet('com.highfidelity.interface.tablet.system'), + button = tablet.addButton(BUTTON_CONFIG); + + Script.scriptEnding.connect(function() { + tablet.removeButton(button); + button = null; + }); + + // track both runtime state (applicationConfig) and settings state (cameraConfig) + // (this is necessary because Interface does not yet consistently keep config Menus, APIs and Settings in sync) + applicationConfig = new configUtils.ApplicationConfig({ + namespace: DEFAULTS.namespace, + config: APPLICATION_SETTINGS, + }); + cameraConfig = new configUtils.SettingsConfig({ + namespace: DEFAULTS.namespace, + defaultValues: DEFAULTS, + }); + + var toggleKey = JSON.parse(DEFAULT_TOGGLE_KEY); + if (cameraConfig.getValue('toggle-key')) { + try { toggleKey = JSON.parse(cameraConfig.getValue('toggle-key')); } catch(e) {} + } + // set up a monitor to observe configuration changes between the two sources + var MONITOR_INTERVAL_MS = 1000; + _startConfigationMonitor(applicationConfig, cameraConfig, MONITOR_INTERVAL_MS); + + // ---------------------------------------------------------------------------- + // set up the tablet webview app + log('APP_HTML_URL', APP_HTML_URL); + var settingsApp = new CustomSettingsApp({ + namespace: cameraConfig.namespace, + uuid: cameraConfig.uuid, + settingsAPI: cameraConfig, + url: APP_HTML_URL, + tablet: tablet, + extraParams: Object.assign({ + toggleKey: toggleKey, + }, _utils.getSystemMetadata(), DEBUG_INFO), + debug: WANT_DEBUG > 1, + }); + Script.scriptEnding.connect(settingsApp, 'cleanup'); + settingsApp.valueUpdated.connect(function(key, value, oldValue, origin) { + log('[settingsApp.valueUpdated @ ' + origin + ']', key + ' = ' + JSON.stringify(value) + ' (was: ' + JSON.stringify(oldValue) + ')'); + if (/tablet/i.test(origin)) { + log('cameraConfig applying immediate setting', key, value); + // apply relevant settings immediately when changed from the app UI + applicationConfig.applyValue(key, value, origin); + } + }); + + settingsApp.onUnhandledMessage = function(msg) { + switch(msg.method) { + case 'window.close': { + this.toggle(false); + } break; + case 'reloadClientScript': { + log('reloadClientScript...'); + _utils.reloadClientScript(FILENAME); + } break; + case 'resetSensors': { + Menu.triggerOption('Reset Sensors'); + Script.setTimeout(function() { + MyAvatar.bodyPitch = 0; + MyAvatar.bodyRoll = 0; + MyAvatar.orientation = Quat.cancelOutRollAndPitch(MyAvatar.orientation); + }, 500); + } break; + case 'reset': { + //if (!Window.confirm('Reset all camera move settings to system defaults?')) { + // return; + //} + var novalue = Uuid.generate(); + var resetValues = {}; + Object.keys(DEFAULTS).reduce(function(out, key) { + var resolved = cameraConfig.resolve(key); + out[resolved] = resolved in out ? out[resolved] : DEFAULTS[key]; + return out; + }, resetValues); + Object.keys(applicationConfig.config).reduce(function(out, key) { + var resolved = applicationConfig.resolve(key); + out[resolved] = resolved in out ? out[resolved] : applicationConfig.getValue(key); + return out; + }, resetValues); + log('resetValues', JSON.stringify(resetValues, 0, 2)); + for (var p in resetValues) { + var value = resetValues[p]; + applicationConfig.applyValue(p, value, 'reset'); + cameraConfig.setValue(p, value); + } + } break; + case 'overlayWebWindow': { + _overlayWebWindow(msg.options); + } break; + default: { + log('onUnhandledMessage', JSON.stringify(msg,0,2)); + } break; + } + }; + + // ---------------------------------------------------------------------------- + // set up the keyboard/mouse/controller/meta input state manager + eventMapper = new movementUtils.MovementEventMapper({ + namespace: DEFAULTS.namespace, + mouseSmooth: cameraConfig.getValue('enable-mouse-smooth'), + xexcludeNames: [ 'Keyboard.C', 'Keyboard.E', 'Actions.TranslateY' ], + mouseMultiplier: cameraConfig.getValue('rotation-mouse-multiplier'), + keyboardMultiplier: cameraConfig.getValue('rotation-keyboard-multiplier'), + eventFilter: function eventFilter(from, event, defaultFilter) { + var result = defaultFilter(from, event), + driveKeyName = event.driveKeyName; + if (!result || !driveKeyName) { + if (from === 'Keyboard.RightMouseButton') { + settingsApp.syncValue('Keyboard.RightMouseButton', event.actionValue, 'eventFilter'); + } + return 0; + } + if (cameraConfig.getValue('normalize-inputs')) { + result = _utils.sign(result); + } + if (from === 'Actions.Pitch') { + result *= cameraConfig.getFloat('rotation-x-speed'); + } + if (from === 'Actions.Yaw') { + result *= cameraConfig.getFloat('rotation-y-speed'); + } + return result; + }, + }); + Script.scriptEnding.connect(eventMapper, 'disable'); + applicationConfig.register({ + 'enable-mouse-smooth': { object: [ eventMapper.options, 'mouseSmooth' ] }, + 'rotation-keyboard-multiplier': { object: [ eventMapper.options, 'keyboardMultiplier' ] }, + 'rotation-mouse-multiplier': { object: [ eventMapper.options, 'mouseMultiplier' ] }, + }); + + // ---------------------------------------------------------------------------- + // set up the top-level camera controls manager / animator + cameraControls = new movementUtils.CameraControls({ + namespace: DEFAULTS.namespace, + update: update, + threadMode: cameraConfig.getValue('thread-update-mode'), + fps: cameraConfig.getValue('fps'), + getRuntimeSeconds: _utils.getRuntimeSeconds, + }); + Script.scriptEnding.connect(cameraControls, 'disable'); + applicationConfig.register({ + 'thread-update-mode': { object: [ cameraControls, 'threadMode' ] }, + 'fps': { object: [ cameraControls, 'fps' ] }, + }); + + // ---------------------------------------------------------------------------- + // set up SPACEBAR for toggling camera movement mode + var spacebar = new _utils.KeyListener(Object.assign(toggleKey, { + onKeyPressEvent: function(event) { + cameraControls.setEnabled(!cameraControls.enabled); + }, + })); + Script.scriptEnding.connect(spacebar, 'disconnect'); + + // ---------------------------------------------------------------------------- + // set up ESC for reset drive key states + Script.scriptEnding.connect(new _utils.KeyListener({ + text: 'ESC', + onKeyPressEvent: function(event) { + if (cameraControls.enabled) { + log('ESC pressed -- resetting drive keys values:', JSON.stringify({ + virtualDriveKeys: eventMapper.states, + movementState: eventMapper.getState(), + }, 0, 2)); + eventMapper.states.reset(); + } + }, + }), 'disconnect'); + + // set up the tablet button to toggle the app UI display + button.clicked.connect(settingsApp, function(enable) { + Object.assign(this.extraParams, _utils.getSystemMetadata()); + this.toggle(enable); + }); + + settingsApp.isActiveChanged.connect(function(isActive) { + updateButtonText(); + }); + + cameraControls.modeChanged.connect(onCameraModeChanged); + + var fpsTimeout = 0; + function updateButtonText() { + var lines = [ + settingsApp.isActive ? '(app open)' : '', + cameraControls.enabled ? (update.momentaryFPS||0).toFixed(2) + 'fps' : BUTTON_CONFIG.text.trim() + ]; + button && button.editProperties({ text: lines.join('\n') }); + } + + cameraControls.enabledChanged.connect(function(enabled) { + log('enabledChanged', enabled); + button && button.editProperties({ isActive: enabled }); + if (enabled) { + onCameraControlsEnabled(); + fpsTimeout = Script.setInterval(updateButtonText, 1000); + } else { + if (fpsTimeout) { + Script.clearInterval(fpsTimeout); + fpsTimeout = 0; + } + eventMapper.disable(); + _resetMyAvatarMotor({ MyAvatar: MyAvatar }); + updateButtonText(); + } + cameraConfig.getValue('debug') && Overlays.editOverlay(overlayDebugOutput.overlayID, { visible: enabled }); + }); + + var resetIfChanged = [ + 'minimal-cursor', 'drive-mode', 'fps', 'thread-update-mode', + 'rotation-mouse-multiplier', 'rotation-keyboard-multiplier', + 'enable-mouse-smooth', 'constant-delta-time', + ].filter(Boolean).map(_utils.bind(cameraConfig, 'resolve')); + + cameraConfig.valueUpdated.connect(function(key, value, oldValue, origin) { + var triggerReset = !!~resetIfChanged.indexOf(key); + log('[cameraConfig.valueUpdated @ ' + origin + ']', + key + ' = ' + JSON.stringify(value), '(was:' + JSON.stringify(oldValue) + ')', + 'triggerReset: ' + triggerReset); + + if (/tablet/i.test(origin)) { + log('cameraConfig applying immediate setting', key, value); + // apply relevant settings immediately when changed from the app UI + applicationConfig.applyValue(key, value, origin); + log(JSON.stringify(cameraConfig.getValue(key))); + } + 0&&debugPrint('//cameraConfig.valueUpdated', JSON.stringify({ + key: key, + cameraConfig: cameraConfig.getValue(key), + applicationConfig: applicationConfig.getValue(key), + value: value, + triggerReset: triggerReset, + },0,2)); + + if (triggerReset) { + log('KEYBOARD multiplier', eventMapper.options.keyboardMultiplier); + cameraControls.reset(); + } + }); + + if (cameraConfig.getValue('camera-move-enabled')) { + cameraControls.enable(); + } + + log('DEFAULTS', JSON.stringify(DEFAULTS, 0, 2)); +} // main() + +function onCameraControlsEnabled() { + log('onCameraControlsEnabled!!!! ----------------------------------------------'); + globalState.previousValues.reset(); + globalState.currentVelocities.reset(); + globalState.pendingChanges.reset(); + eventMapper.enable(); + if (cameraConfig.getValue('minimal-cursor')) { + Reticle.scale = MINIMAL_CURSOR_SCALE; + } + log('cameraConfig', JSON.stringify({ + cameraConfig: getCameraMovementSettings(cameraConfig), + //DEFAULTS: DEFAULTS + })); +} + +// reset values based on the selected Camera.mode (to help keep the visual display/orientation more reasonable) +function onCameraModeChanged(mode, oldMode) { + globalState.pendingChanges.reset(); + globalState.previousValues.reset(); + eventMapper.reset(); + var preventRoll = cameraConfig.getValue('prevent-roll'); + var avatarOrientation = cameraConfig.getValue('use-head') ? MyAvatar.headOrientation : MyAvatar.orientation; + if (preventRoll) { + avatarOrientation = Quat.cancelOutRollAndPitch(avatarOrientation); + } + switch (Camera.mode) { + case 'mirror': + case 'entity': + case 'independent': + globalState.currentVelocities.reset(); + break; + default: + Camera.position = MyAvatar.position; + Camera.orientation = avatarOrientation; + break; + } + MyAvatar.orientation = avatarOrientation; + if (preventRoll) { + MyAvatar.headPitch = MyAvatar.headRoll = 0; + } +} + +// consolidate and normalize cameraConfig settings +function getCameraMovementSettings(cameraConfig) { + return { + epsilon: EPSILON, + debug: cameraConfig.getValue('debug'), + driveMode: cameraConfig.getValue('drive-mode'), + threadMode: cameraConfig.getValue('thread-update-mode'), + useHead: cameraConfig.getValue('use-head'), + stayGrounded: cameraConfig.getValue('stay-grounded'), + preventRoll: cameraConfig.getValue('prevent-roll'), + useConstantDeltaTime: cameraConfig.getValue('constant-delta-time'), + + mouseSmooth: cameraConfig.getValue('enable-mouse-smooth'), + mouseMultiplier: cameraConfig.getValue('rotation-mouse-multiplier'), + keyboardMultiplier: cameraConfig.getValue('rotation-keyboard-multiplier'), + + rotation: _getEasingGroup(cameraConfig, 'rotation'), + translation: _getEasingGroup(cameraConfig, 'translation'), + zoom: _getEasingGroup(cameraConfig, 'zoom'), + }; + + // extract an easing group (translation, rotation, or zoom) from cameraConfig + function _getEasingGroup(cameraConfig, group) { + var multiplier = 1.0; + if (group === 'zoom') { + // BoomIn / TranslateCameraZ support is only partially plumbed -- for now use scaled translation easings + group = 'translation'; + multiplier = 0.01; + } else if (group === 'rotation') { + // degrees -> radians + //multiplier = DEG_TO_RAD; + } + + return { + easeIn: cameraConfig.getFloat(group + '-ease-in'), + easeOut: cameraConfig.getFloat(group + '-ease-out'), + maxVelocity: multiplier * cameraConfig.getFloat(group + '-max-velocity'), + speed: Vec3.multiply(multiplier, { + x: cameraConfig.getFloat(group + '-x-speed'), + y: cameraConfig.getFloat(group + '-y-speed'), + z: cameraConfig.getFloat(group + '-z-speed') + }), + }; + } +} + +var DEFAULT_MOTOR_TIMESCALE = 1e6; // a large value that matches Interface's default +var EASED_MOTOR_TIMESCALE = 0.01; // a small value to make Interface quickly apply MyAvatar.motorVelocity +var EASED_MOTOR_THRESHOLD = 0.1; // above this speed (m/s) EASED_MOTOR_TIMESCALE is used +var ACCELERATION_MULTIPLIERS = { translation: 1, rotation: 1, zoom: 1 }; +var STAYGROUNDED_PITCH_THRESHOLD = 45.0; // degrees; ground level is maintained when pitch is within this threshold +var MIN_DELTA_TIME = 0.0001; // to avoid math overflow, never consider dt less than this value + +update.frameCount = 0; +update.endTime = _utils.getRuntimeSeconds(); + +function update(dt) { + update.frameCount++; + var startTime = _utils.getRuntimeSeconds(); + var settings = getCameraMovementSettings(cameraConfig); + + var collisions = applicationConfig.getValue('Avatar/Enable Avatar Collisions'), + independentCamera = Camera.mode === 'independent', + headPitch = MyAvatar.headPitch; + + var actualDeltaTime = Math.max(MIN_DELTA_TIME, (startTime - update.endTime)), + deltaTime; + + if (settings.useConstantDeltaTime) { + deltaTime = settings.threadMode === movementUtils.CameraControls.ANIMATION_FRAME ? + (1 / cameraControls.fps) : (1 / 90); + } else if (settings.threadMode === movementUtils.CameraControls.SCRIPT_UPDATE) { + deltaTime = dt; + } else { + deltaTime = actualDeltaTime; + } + + var orientationProperty = settings.useHead ? 'headOrientation' : 'orientation', + currentOrientation = independentCamera ? Camera.orientation : MyAvatar[orientationProperty], + currentPosition = MyAvatar.position; + + var previousValues = globalState.previousValues, + pendingChanges = globalState.pendingChanges, + currentVelocities = globalState.currentVelocities; + + var movementState = eventMapper.getState({ update: deltaTime }), + targetState = movementUtils.applyEasing(deltaTime, 'easeIn', settings, movementState, ACCELERATION_MULTIPLIERS), + dragState = movementUtils.applyEasing(deltaTime, 'easeOut', settings, currentVelocities, ACCELERATION_MULTIPLIERS); + + currentVelocities.integrate(targetState, currentVelocities, dragState, settings); + + var currentSpeed = Vec3.length(currentVelocities.translation), + targetSpeed = Vec3.length(movementState.translation), + verticalHold = movementState.isGrounded && settings.stayGrounded && Math.abs(headPitch) < STAYGROUNDED_PITCH_THRESHOLD; + + var deltaOrientation = Quat.fromVec3Degrees(Vec3.multiply(deltaTime, currentVelocities.rotation)), + targetOrientation = Quat.normalize(Quat.multiply(currentOrientation, deltaOrientation)); + + var targetVelocity = Vec3.multiplyQbyV(targetOrientation, currentVelocities.translation); + + if (verticalHold) { + targetVelocity.y = 0; + } + + var deltaPosition = Vec3.multiply(deltaTime, targetVelocity); + + _resetMyAvatarMotor(pendingChanges); + + if (!independentCamera) { + var DriveModes = movementUtils.DriveModes; + switch(settings.driveMode) { + case DriveModes.MOTOR: { + if (currentSpeed > EPSILON || targetSpeed > EPSILON) { + var motorTimescale = (currentSpeed > EASED_MOTOR_THRESHOLD ? EASED_MOTOR_TIMESCALE : DEFAULT_MOTOR_TIMESCALE); + var motorPitch = Quat.fromPitchYawRollDegrees(headPitch, 180, 0), + motorVelocity = Vec3.multiplyQbyV(motorPitch, currentVelocities.translation); + if (verticalHold) { + motorVelocity.y = 0; + } + Object.assign(pendingChanges.MyAvatar, { + motorVelocity: motorVelocity, + motorTimescale: motorTimescale, + }); + } + break; + } + case DriveModes.THRUST: { + var thrustVector = currentVelocities.translation, + maxThrust = settings.translation.maxVelocity, + thrust; + if (targetSpeed > EPSILON) { + thrust = movementUtils.calculateThrust(maxThrust * 5, thrustVector, previousValues.thrust); + } else if (currentSpeed > 1 && Vec3.length(previousValues.thrust) > 1) { + thrust = Vec3.multiply(-currentSpeed / 10.0, thrustVector); + } else { + thrust = Vec3.ZERO; + } + if (thrust) { + thrust = Vec3.multiplyQbyV(MyAvatar[orientationProperty], thrust); + if (verticalHold) { + thrust.y = 0; + } + } + previousValues.thrust = pendingChanges.MyAvatar.setThrust = thrust; + break; + } + case DriveModes.JITTER_TEST: + case DriveModes.POSITION: { + pendingChanges.MyAvatar.position = Vec3.sum(currentPosition, deltaPosition); + break; + } + default: { + throw new Error('unknown driveMode: ' + settings.driveMode); + break; + } + } + } + + var finalOrientation; + switch (Camera.mode) { + case 'mirror': // fall through + case 'independent': + targetOrientation = settings.preventRoll ? Quat.cancelOutRoll(targetOrientation) : targetOrientation; + var boomVector = Vec3.multiply(-currentVelocities.zoom.z, Quat.getFront(targetOrientation)), + deltaCameraPosition = Vec3.sum(boomVector, deltaPosition); + Object.assign(pendingChanges.Camera, { + position: Vec3.sum(Camera.position, deltaCameraPosition), + orientation: targetOrientation, + }); + break; + case 'entity': + finalOrientation = targetOrientation; + break; + default: // 'first person', 'third person' + finalOrientation = targetOrientation; + break; + } + + if (settings.driveMode === movementUtils.DriveModes.JITTER_TEST) { + finalOrientation = Quat.multiply(MyAvatar[orientationProperty], Quat.fromPitchYawRollDegrees(0, 60 * deltaTime, 0)); + // Quat.fromPitchYawRollDegrees(0, _utils.getRuntimeSeconds() * 60, 0) + } + + if (finalOrientation) { + if (settings.preventRoll) { + finalOrientation = Quat.cancelOutRoll(finalOrientation); + } + previousValues.finalOrientation = pendingChanges.MyAvatar[orientationProperty] = Quat.normalize(finalOrientation); + } + + if (!movementState.mouseSmooth && movementState.isRightMouseButton) { + // directly apply mouse pitch and yaw when mouse smoothing is disabled + _applyDirectPitchYaw(deltaTime, movementState, settings); + } + + var endTime = _utils.getRuntimeSeconds(); + var cycleTime = endTime - update.endTime; + update.endTime = endTime; + + var submitted = pendingChanges.submit(); + + update.momentaryFPS = 1 / actualDeltaTime; + + if (settings.debug && update.frameCount % 120 === 0) { + Messages.sendLocalMessage(_debugChannel, JSON.stringify({ + threadMode: cameraControls.threadMode, + driveMode: settings.driveMode, + orientationProperty: orientationProperty, + isGrounded: movementState.isGrounded, + targetAnimationFPS: cameraControls.threadMode === movementUtils.CameraControls.ANIMATION_FRAME ? cameraControls.fps : undefined, + actualFPS: 1 / actualDeltaTime, + effectiveAnimationFPS: 1 / deltaTime, + seconds: { + startTime: startTime, + endTime: endTime, + }, + milliseconds: { + actualDeltaTime: actualDeltaTime * 1000, + deltaTime: deltaTime * 1000, + cycleTime: cycleTime * 1000, + calculationTime: (endTime - startTime) * 1000, + }, + finalOrientation: finalOrientation, + thrust: thrust, + maxVelocity: settings.translation, + targetVelocity: targetVelocity, + currentSpeed: currentSpeed, + targetSpeed: targetSpeed, + }, 0, 2)); + } +} + +if (0) { + Script.update.connect(gc); + Script.scriptEnding.connect(function() { + Script.update.disconnect(gc); + }); +} + +function _applyDirectPitchYaw(deltaTime, movementState, settings) { + var orientationProperty = settings.useHead ? 'headOrientation' : 'orientation', + rotation = movementState.rotation, + speed = Vec3.multiply(-DEG_TO_RAD / 2.0, settings.rotation.speed); + + var previousValues = globalState.previousValues, + pendingChanges = globalState.pendingChanges, + currentVelocities = globalState.currentVelocities; + + var previous = previousValues.pitchYawRoll, + target = Vec3.multiply(deltaTime, Vec3.multiplyVbyV(rotation, speed)), + pitchYawRoll = Vec3.mix(previous, target, 0.5), + orientation = Quat.fromVec3Degrees(pitchYawRoll); + + previousValues.pitchYawRoll = pitchYawRoll; + + if (pendingChanges.MyAvatar.headOrientation || pendingChanges.MyAvatar.orientation) { + var newOrientation = Quat.multiply(MyAvatar[orientationProperty], orientation); + delete pendingChanges.MyAvatar.headOrientation; + delete pendingChanges.MyAvatar.orientation; + if (settings.preventRoll) { + newOrientation = Quat.cancelOutRoll(newOrientation); + } + MyAvatar[orientationProperty] = newOrientation; + } else if (pendingChanges.Camera.orientation) { + var cameraOrientation = Quat.multiply(Camera.orientation, orientation); + if (settings.preventRoll) { + cameraOrientation = Quat.cancelOutRoll(cameraOrientation); + } + Camera.orientation = cameraOrientation; + } + currentVelocities.rotation = Vec3.ZERO; +} + +// ---------------------------------------------------------------------------- +function _startConfigationMonitor(applicationConfig, cameraConfig, interval) { + // monitor and sync Application state -> Settings values + return Script.setInterval(function monitor() { + var settingNames = Object.keys(applicationConfig.config); + settingNames.forEach(function(key) { + applicationConfig.resyncValue(key); // align Menus <=> APIs + var value = cameraConfig.getValue(key), + appValue = applicationConfig.getValue(key); + if (appValue !== undefined && String(appValue) !== String(value)) { + log('applicationConfig -> cameraConfig', + key, [typeof appValue, appValue], '(was:'+[typeof value, value]+')'); + cameraConfig.setValue(key, appValue); // align Application <=> Settings + } + }); + }, interval); +} + +// ---------------------------------------------------------------------------- + +function _resetMyAvatarMotor(targetObject) { + if (MyAvatar.motorTimescale !== DEFAULT_MOTOR_TIMESCALE) { + targetObject.MyAvatar.motorTimescale = DEFAULT_MOTOR_TIMESCALE; + } + if (MyAvatar.motorReferenceFrame !== 'avatar') { + targetObject.MyAvatar.motorReferenceFrame = 'avatar'; + } + if (Vec3.length(MyAvatar.motorVelocity)) { + targetObject.MyAvatar.motorVelocity = Vec3.ZERO; + } +} + +// ---------------------------------------------------------------------------- +function _fixedPrecisionStringifiyFilter(key, value, object) { + if (typeof value === 'object' && value && 'w' in value) { + return Quat.safeEulerAngles(value); + } else if (typeof value === 'number') { + return value.toFixed(4)*1; + } + return value; +} +function _createOverlayDebugOutput(options) { + options = require('./modules/_utils.js').assign({ + x: 0, y: 0, width: 500, height: 800, visible: false + }, options || {}); + options.lineHeight = options.lineHeight || Math.round(options.height / 36); + options.font = options.font || { size: Math.floor(options.height / 36) }; + overlayDebugOutput.overlayID = Overlays.addOverlay('text', options); + + Messages.subscribe(_debugChannel); + Messages.messageReceived.connect(onMessageReceived); + + Script.scriptEnding.connect(function() { + Overlays.deleteOverlay(overlayDebugOutput.overlayID); + Messages.unsubscribe(debugChannel); + Messages.messageReceived.disconnect(onMessageReceived); + }); + function overlayDebugOutput(output) { + var text = JSON.stringify(output, _fixedPrecisionStringifiyFilter, 2); + if (text !== overlayDebugOutput.lastText) { + overlayDebugOutput.lastText = text; + Overlays.editOverlay(overlayDebugOutput.overlayID, { text: text }); + } + } + function onMessageReceived(channel, message, ssend, local) { + if (!local || channel !== _debugChannel) { + return; + } + overlayDebugOutput(JSON.parse(message)); + } + + return overlayDebugOutput; +} + + +// ---------------------------------------------------------------------------- +_patchCameraModeSetting(); +function _patchCameraModeSetting() { + // FIXME: looks like the Camera API suffered a regression where setting Camera.mode = 'first person' or 'third person' + // no longer works; the only reliable way to set it now seems to be jury-rigging the Menu items... + Camera.$setModeString = Camera.$setModeString || function(mode) { + // 'independent' => "Independent Mode", 'first person' => 'First Person', etc. + var cameraMenuItem = (mode+'') + .replace(/^(independent|entity)$/, '$1 mode') + .replace(/\b[a-z]/g, function(ch) { + return ch.toUpperCase(); + }); + + log('working around Camera.mode bug by enabling the menuItem:', cameraMenuItem); + Menu.setIsOptionChecked(cameraMenuItem, true); + }; +} +// ---------------------------------------------------------------------------- + +function _instrumentDebugValues() { + debugPrint = log; + var cacheBuster = '?' + new Date().getTime().toString(36); + require = Script.require(Script.resolvePath('./modules/_utils.js') + cacheBuster).makeDebugRequire(Script.resolvePath('.')); + APP_HTML_URL += cacheBuster; + overlayDebugOutput = _createOverlayDebugOutput({ + lineHeight: 10, + font: { size: 10 }, + width: 250, height: 800 }); + // auto-disable camera move mode when debugging + Script.scriptEnding.connect(function() { + cameraConfig && cameraConfig.setValue('camera-move-enabled', false); + }); +} + +// Show fatal (unhandled) exceptions in a BSOD popup +Script.unhandledException.connect(function onUnhandledException(error) { + log('UNHANDLED EXCEPTION!!', error, error && error.stack); + try { cameraControls.disable(); } catch(e) {} + Script.unhandledException.disconnect(onUnhandledException); + if (WANT_DEBUG) { + // show blue screen of death with the error details + _utils.BSOD({ + error: error, + buttons: [ 'Abort', 'Retry', 'Fail' ], + debugInfo: DEBUG_INFO, + }, function(error, button) { + log('BSOD.result', error, button); + if (button === 'Abort') { + Script.stop(); + } else if (button === 'Retry') { + _utils.reloadClientScript(FILENAME); + } + }); + } else { + // use a simple alert to display just the error message + Window.alert('app-camera-move error: ' + error.message); + Script.stop(); + } +}); +// ---------------------------------------------------------------------------- + +main(); diff --git a/unpublishedScripts/marketplace/camera-move/app.html b/unpublishedScripts/marketplace/camera-move/app.html new file mode 100644 index 0000000000..c27e179df5 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/app.html @@ -0,0 +1,1076 @@ +<!doctype html> +<html class="tablet-ui"> + <head> + <title>Camera Move</title> + <script> VERSION = '0.0.1'; </script> + + <!-- hide the page content until fully loaded --> + <style> + body { background-color: #393939 } + .content { display: none } + </style> + + <!-- bring in jQuery and jQuery UI --> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.js"></script> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/themes/base/jquery-ui.css" /> + + <!--script src="https://d3js.org/d3-ease.v1.min.js"></script--> + + <script src="https://cdn.jsdelivr.net/jquery.tooltipster/4.2.5/js/tooltipster.bundle.min.js"></script> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/g/jquery.tooltipster@4.2.5(css/tooltipster.bundle.min.css+css/plugins/tooltipster/sideTip/themes/tooltipster-sideTip-noir.min.css)"> + + <script src='./_debug.js'></script> + + <!-- load our local scripts --> + <script type='require' src='./modules/_utils.js'></script> + <script type='require' src='./modules/custom-settings-app/browser/BridgedSettings.js'></script> + <script type='require' src='./modules/custom-settings-app/browser/JQuerySettings.js'></script> + <script type='require'>{ + signal = _utils.signal; + assert = _utils.assert; + Object.assign = Object.assign || _utils.assign; + browserUtils = new _utils.BrowserUtils(window); + }</script> + <script type='require' src='./app.js'></script> + + <script> + _debug.loadScriptNodes('script[type=require]'); + </script> + + <script>; +{ + // Qt web views only show the first parameter passed to console; patch if needed so all parameters show up + console = browserUtils.makeConsoleWorkRight(console); + + // display unhandled exceptions in an error div + if (/debug/.test(location) || window.qt) { + window.onerror = _debug.handleUncaughtException; + } + + // process querystring parameters from main client script + var PARAMS = browserUtils.extendWithQueryParams({ + namespace: location.pathname.split('/').pop(), + uuid: undefined, + debug: false, + tooltiptest: false, + }, location.href); + + log.prefix = 'html.' + PARAMS.namespace + ' | '; + function log(msg) { + console.info.apply(console, [log.prefix + msg].concat([].slice.call(arguments,1))); + } + function _debugPrint(msg) { + console.debug.apply(console, [log.prefix + msg].concat([].slice.call(arguments,1))); + } + var debugPrint = PARAMS.debug ? _debugPrint : function() {}; + + $(document).ready(function() { + defineCustomWidgets(); + initializeDOM(); + viewportUpdated.connect(preconfigureLESS.onViewportUpdated); + + // event bridge intialization + log('document.ready...'); + if (typeof QWebChannel === 'function') { + // Qt/Interface within an embedded web view + browserUtils.openEventBridge(onEventBridgeOpened); + } else { + // Testing in Desktop Chrome + _debug.openEventBridgeMock(onEventBridgeOpened); + } + }); + + function onEventBridgeOpened(eventBridge) { + EventBridge = eventBridge; + log('openEventBridge.opened', EventBridge); + + bridgedSettings = new BridgedSettings({ + eventBridge: EventBridge, + namespace: PARAMS.namespace, + uuid: PARAMS.uuid, + debug: PARAMS.debug, + }); + window.addEventListener('unload', bridgedSettings.cleanup.bind(bridgedSettings)); + bridgedSettings.callbackError.connect(function onCallbackError(err, msg) { + console.error(err.stack); + throw err; + }); + + jquerySettings = new JQuerySettings({ + namespace: PARAMS.namespace, + uuid: PARAMS.uuid, + debug: PARAMS.debug, + }); + + debugPrint('>>> SENDING ACK'); + // let Client script know we are ready + EventBridge.emitWebEvent(location.href); + + // keep Interface in sync when DOM changes + jquerySettings.valueUpdated.connect(function(key, value, oldValue, origin) { + logValueUpdate('jquerySettings.valueUpdated', key, value, oldValue, origin); + bridgedSettings.syncValue(key, value, origin); + }); + + // keep DOM in sync when Interface changes + bridgedSettings.valueUpdated.connect(function(key, value, oldValue, origin) { + logValueUpdate('bridgedSettings.valueUpdated', key, value, oldValue, origin); + jquerySettings.setValue(key, value); + }); + + setupUI(); + + setTimeout(function() { + // wait a tic before showing so initial async settings have time to queue/arrive + $('section').show(); + }, 150); + } +}</script> + </head> + + <body class="settings-app"> + <div id="errors" style="display:none"><pre class='output'></pre><button onclick=location.reload()>adsfsadf</button></div> + <div class="content"> + <header class="title"> + <div class="inner-title"> + <h1>Camera Move</h1> + <div class="bool row"> + <input class="setting" type="checkbox" id="camera-move-enabled" /> + <label for="camera-move-enabled">Enabled</label> + </div> + <div id="appVersion"><span class='output'>...</span></div> + </div> + </header> + + <div class='scrollable'> + <!-- generate debug line rows --> + <pre style='display:none;font-size:8px'> + <script id='delme'>for (var i=0; !(i >= 100); i++) document.write('<span class=content></span>'); </script> + </pre> + <script>with({ parent: '', node: document.getElementById('delme') }) node.parentNode.removeChild(node);</script> + + <section> + <h2>Translation</h2> + <div class="number row"> + <label>Max Linear Velocity<span class="unit">m/s</span></label> + <input class="setting" data-type="number" step=".1" id="translation-max-velocity" /> + </div> + <div class="slider row"> + <label>Ease In Coefficient</label> + <div class="control"></div> + <input class="setting" data-type="number" id="translation-ease-in" /> + </div> + <div class="slider row"> + <label>Ease Out Coefficient</label> + <div class="control"></div> + <input class="setting" data-type="number" id="translation-ease-out" /> + </div> + </section> + + <hr /> + + <section> + <h2>Rotation</h2> + <div class="number row"> + <label>Max Angular Velocity<span class="unit">deg/s</span></label> + <input class="setting" data-type="number" step=".1" id="rotation-max-velocity" /> + </div> + <div class="slider row"> + <label>Ease In Coefficient</label> + <div class="control"></div> + <input class="setting" data-type="number" id="rotation-ease-in" /> + </div> + <div class="slider row"> + <label>Ease Out Coefficient</label> + <div class="control"></div> + <input class="setting" data-type="number" id="rotation-ease-out" /> + </div> + </section> + + <hr /> + + <section> + <h2>Options</h2> + <div class="bool row"> + <input class="setting" id="enable-lookat-snapping" name="Avatar/lookAtSnappingEnabled" type="checkbox" /> + <label for="enable-lookat-snapping">Avatars snap look at camera</label> + </div> + <div class="bool row"> + <input class="setting" id="use-snap-turn" name="Avatar/useSnapTurn" type="checkbox" /> + <label for="use-snap-turn">Enable snap turn in HMD</label> + </div> + <div class="bool row"> + <input class="setting" id="enable-mouse-smooth" type="checkbox" /> + <label for="enable-mouse-smooth">Enable smooth mouselook</label> + </div> + <div class="bool row"> + <input class="setting" id="minimal-cursor" type="checkbox" /> + <label for="minimal-cursor">Enable minimal cursor</label> + </div> + </section> + + <!-- advanced-options --> + <div style='display: none' id='advanced-options'> + <br /> + <div class='content'> + <hr /> + <section> + <h2>Rotation Speeds</h2> + <div class="column"> + <div class="number row"> + <label for='rotation-x-speed'>Pitch speed<span class="unit">deg/s</span></label> + <input class="setting" data-type="number" step="1" id="rotation-x-speed" /> + </div> + <div class="number row"> + <label for='rotation-y-speed'>Yaw speed<span class="unit">deg/s</span></label> + <input class="setting" data-type="number" step="1" id="rotation-y-speed" /> + </div> + </div> + </section> + + <hr /> + + <section> + <h2>input scaling</h2> + <div class="column"> + <div class="number row"> + <label for='rotation-keyboard-multiplier'>Keyboard multiplier</label> + <input class="setting" data-type="number" step=".1" id="rotation-keyboard-multiplier" /> + </div> + <div class="number row"> + <label for='rotation-mouse-multiplier'>Mouse multiplier</label> + <input class="setting" data-type="number" step=".1" id="rotation-mouse-multiplier" /> + </div> + </div> + </section> + + <hr /> + + <section> + <h2>Advanced Options</h2> + <div class="bool row"> + <input class="setting" id="stay-grounded" type="checkbox" /> + <label for="stay-grounded">Stay on ground</label> + </div> + <div class="bool row"> + <input class="setting" id="use-head" type="checkbox" /> + <label for="use-head">Apply rotations to Avatar Head</label> + </div> + <div class="bool row"> + <input class="setting" id="prevent-roll" type="checkbox" /> + <label for="prevent-roll">Prevent Roll</label> + </div> + <div class="bool row"> + <input class="setting" id="constant-delta-time" type="checkbox" /> + <label for="constant-delta-time">Use constant Δ time for frame updates</label> + </div> + <div class="bool row"> + <input class="setting" id="normalize-inputs" type="checkbox" /> + <label for="normalize-inputs">Normalize mouse movement</label> + </div> + <div class="bool row"> + <input class="setting" id="collisions-enabled" name="Avatar/Enable Avatar Collisions" type="checkbox" /> + <label for="collisions-enabled">Enable Avatar Collisions</label> + </div> + <div class="bool row"> + <input class="setting" id="draw-mesh" name="Avatar/Draw Mesh" type="checkbox" /> + <label for="draw-mesh">Draw My Avatar</label> + </div> + <div class="bool row"> + <input class="setting" id="ui-enable-tooltips" type="checkbox" /> + <label for="ui-enable-tooltips">Enable UI tooltips</label> + </div> + <br /> + </section> + + <hr /> + + <section> + <h2>Update Mode</h2> + <div class='column'> + <div class="radio row" id="drive-mode" > + <div> + <input class="setting" name="drive-mode" id="motor" type="radio" /> + <label for="motor">Scripted Motor Control</label> + </div> + <div> + <input class="setting" name="drive-mode" id="position" type="radio" /> + <label for="position">Absolute Avatar position</label> + </div> + <div> + <input class="setting" name="drive-mode" id="thrust" type="radio" /> + <label for="thrust">Thrust/force vectors</label> + </div> + <div> + <input class="setting" name="drive-mode" id="jitter-test" type="radio" /> + <label for="jitter-test">Debug Jitter Testing</label> + </div> + </div> + <!--/section> + <hr /> + <section> + <h2>Script update mode:</h2--> + <div class="radio row" id="thread-update-mode" > + <div> + <input class="setting" name="thread-update-mode" id="update" type="radio" /> + <label for="update"><code>Script.update</code></label> + </div> + <div class='row tooltip-target' for="requestAnimationFrame" data-tooltip-side='left'> + <label for="requestAnimationFrame"><code>requestAnimationFrame</code></label> + <input class="setting" name="thread-update-mode" id="requestAnimationFrame" type="radio" /> + <br /> <small>fps:</small> <input class="setting" data-type="number" step="1" id="fps" /> + </div> + <div> + <input class="setting" name="thread-update-mode" id="setImmediate" type="radio" /> + <label for="setImmediate"><code>setImmediate</code></label> + </div> + <div> + <input class="setting" name="thread-update-mode" id="nextTick" type="radio" /> + <label for="nextTick"><code>nextTick</code></label> + </div> + </div> + </div> + </section> + + <hr /> + + <section id='debug-menu'> + <h2>debug menu</h2> + <div> + <div style='float:left'> + <button id='reset-sensors'>Reset Avatar</button> + <button class='localhost-only' id='page-reload'>window.reload</button> + <button class='localhost-only' id='script-reload'>script.reload</button> + </div> + <div style='float:right'> + <button id='copy-json'>Export JSON</button> + <button id='paste-json'>Import JSON</button> + </div> + </div> + </section> + + <hr /> + <div style='height: 32px'> </div> + </div> + </div><!-- /advanced-options --> + </div><!-- /scrollable --> + + <footer> + <div style='float:left'> + <button id='reset-to-defaults' title='Replace applicable settings with System Defaults'>Reset</button> + </div> + <center> + <div id='toggleKey'>keybinding: <span class='binding'>…</span></div> + </center> + <div style='float:right'> + <button id='toggle-advanced-options'>Advanced<span class='chevron'></span></button> + </div> + <br /> + </footer> + </div> + + <!-- tooltips --> + <div id="tooltips" style="display:none"> + <section> + <div for="translation-max-velocity"> + avatar walking/flying speed limit (ie: the value eased-into) + </div> + <div for="translation-ease-in"> + <ul> + <li>lower values gently ramp-into moving</li> + <li>higher values rapidly accelerate to top speed</li> + </ul> + </div> + <div for="translation-ease-out"> + <ul> + <li>lower values bring movement to a rolling stop</li> + <li>higher values stop movements more immediately</li> + </ul> + </div> + <div for="rotation-max-velocity"> + look up/down (pitch) and turn left/right (yaw) speed limit + </div> + </section> + + <section> + <div for="rotation-ease-in"> + <ul> + <li>lower values gently start turning or looking up/down</li> + <li>higher values quickly turn and look around</li> + </ul> + </div> + <div for="rotation-ease-out"> + <ul> + <li>lower values bring turning/looking to a rolling stop</li> + <li>higher values stop turning/looking more immediately</li> + </ul> + </div> + </section> + + <section> + <div for="enable-lookat-snapping"> + uncheck this to prevent avatars from automatically making eye contact with the camera + </div> + <div for="enable-mouse-smooth"> + uncheck this to bypass smoothing for right-mouse-button drag controls + </div> + <div for="use-snap-turn"> + toggles <b>Settings > Avatar > Snap turn while in HMD</b> + </div> + <div for="minimal-cursor"> + use half-sized mouse cursor + </div> + </section> + + <section> + <div for="stay-grounded"> + prevents your Avatar from gaining altitude when looking up/down and moving + </div> + <div for="use-head"> + update <b>MyAvatar.headOrientation</b> (instead of body orientation) + </div> + <div for="prevent-roll"> + keep upright by applying <b>Quat.cancelOutRoll</b> to resulting rotations + </div> + <div for="constant-delta-time"> + ignore actual time between frames and instead calculate using <b>(1s / requestAnimationFrame.fps)</b> or <b>(1s / 90fps)</b> + </div> + <div for="normalize-inputs"> + convert variable mouse movements into unit values <b>+1, 0, −1</b> + </div> + <div for="collisions-enabled"> + toggles <b>Avatar > Enable Avatar Collisions</b> + </div> + <div for="draw-mesh"> + toggles <b>Developer > Avatar > Draw Mesh</b> + </div> + <div for="ui-enable-tooltips"> + toggle display of tooltips like this one + </div> + </section> + + <section> + <div for="rotation-x-speed">degrees per up/down controller inputs</div> + <div for="rotation-y-speed">degrees per left/right controller inputs</div> + <div for="rotation-keyboard-multiplier">prescale raw controller inputs by this amount</div> + <div for="rotation-mouse-multiplier">prescale raw controller inputs by this amount</div> + </section> + + <section> + <div for="motor"> + ~ <b>MyAvatar.motorVelocity</b> + </div> + <div for="position"> + ~ <b>MyAvatar.position</b> + </div> + <div for="thrust"> + ~ <b>MyAvatar.setThrust</b> + </div> + <div for="jitter-test"> + <b>Diagnostic test:</b> Applies a constant rotation to reveal system-level jitter and update interference + </div> + </section> + + <section> + <div for="update"> + Script.update events fire at rightly ~60 times per second + </div> + <div for="requestAnimationFrame"> + <b>requestAnimationFrame</b> emulation attempts to schedule frame updates in a way that maintains the given frame rate + </div> + <div for="setImmediate"> + <b>setImmediate</b> emulation schedules frame updates "next in line" (ie: after pending tasks) + </div> + <div for="nextTick"> + <b>nextTick</b> emulation queues frame updates "first in line" (ie: before other tasks) + </div> + </section> + + <section> + <div for="copy-json"> + exports current settings as JSON + </div> + <div for="paste-json"> + replace current settings with previously-exported JSON + </div> + <div for="reset-sensors"> + trigger <b>MyAvatar > Reset Sensors</b> and then reset <b>bodyPitch</b> and <b>bodyYaw</b> + </div> + <div for="toggle-advanced-options"> + show / hide advanced settings + </div> + </section> + </div><!-- /tooltips --> + + <style id='tablet-ui-less' type="text/less"> +// Embedded LESS stylesheets are used as a structured way to manage page styling. +// The template rules below get compiled at page load time by less.js. +// see: app.js:preconfigureLESS() and http://lesscss.org/usage/ for more info +// ---------------------------------------------------------------------------- +// tablet-ui.less +.tablet-ui { + .mixins > .load-hifi-font(@custom-font-family); + .mixins > .load-hifi-font(@input-font-family); + + input, textarea { + font-family: '@{input-font-family}', sans-serif; + } + + .mixin-tablet-mode() { + width: 100%; + margin-left: 0; + margin-right: 0; + overflow-x: hidden; + } + .tablet-mode { .mixin-tablet-mode() !important; } + + body { + color: @color-text; + background-color: @color-bg; + + font-family: '@{custom-font-family}'; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + overflow: hidden; + overflow-x: hidden !important; + padding: 0; + margin: 0; + + height: @client-height * 1px; + //margin-top: 6px; + margin-left: -5px; + overflow: hidden; + } + + p { margin: 2px 0; } + + header { z-index: 2; } + + section { padding: 0 24px; } + + hr { + border: none; + background: #404040 url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAAjSURBVBhXY1RVVf3PgARYjIyMoEwIYHRwcEBRwQSloYCBAQCwjgPMiI7W2QAAAABJRU5ErkJggg==) repeat-x top left; + padding: 1px; + margin: 0px; + width: 100%; + //position: absolute; + } + + .title { + position: relative; + padding: 6px 0 6px 10px; + text-align: left; + clear: both; + + h1, label { + font-weight: normal; + //margin: 16px 0; + display: inline-block; + } + } + + #errors .content { padding: 1px 3px; background-color: black; color:blue; display:block; } + #errors span:nth-child(1).content:before { content: 'hash:@{hash}\000a'; } + + h1 { font-size: 18px; } + + h2 { + margin-left: -12px; + font-size: 14px; + color: #ddd; + } + + input[type=text], input[data-type=number], textarea { + margin: 0; + padding: 0 0 0 12px; + color: @color-text; + background-color: @color-bg-darker; + border: none; + font-size: 15px; + &:disabled { + background-color: lighten(@color-bg-darker, 25%); + color: darken(@color-text, 25%); + } + } +} + +.mixins() { + .load-hifi-font(@font-family) { + @font-face { + font-family: '@{font-family}'; + src: url('../../../../resources/fonts/@{font-family}.ttf'), /* Windows production */ + url('../../../../fonts/@{font-family}.ttf'), /* OSX production */ + url('../../../../interface/resources/fonts/@{font-family}.ttf'), /* Development, running script in /HiFi/examples */ + url('https://cdn.rawgit.com/highfidelity/hifi/9fa900ba/interface/resources/fonts/@{font-family}.ttf'); /* fallback to rawgit/github */ + } + //@import (css) url("https://fonts.googleapis.com/css?family=@{custom-font-family}"); + } +} + +.scrollable { + top: @header-height * 1px + 1; + padding-top: 0; + padding-right: 5px; + left: 0; + position: relative; + //width: @client-width * 1px; + overflow-x: hidden; + height: @inner-height * 1px - @footer-height - @header-height; + overflow-y: scroll; + &::-webkit-scrollbar { width: 12px; height: 0px; } + &::-webkit-scrollbar-button { width: 0px; height: 0px;} + &::-webkit-scrollbar-thumb { background: darken(@color-highlight, 5%); border-radius: 4px; } + &::-webkit-scrollbar-thumb:hover { background: lighten(@color-highlight, 5%); border-radius: 4px;} + //&::-webkit-scrollbar-thumb:active { background: #fff; border: 1px dotted black;} + &::-webkit-scrollbar-track:disabled { background: @color-bg; border: 0px none #ffffff; border-radius: 0px;} + &::-webkit-scrollbar-track { background: #666666; border: 0px none #ffffff; border-radius: 0px;} + &::-webkit-scrollbar-track:hover { background: #666666;} + &::-webkit-scrollbar-track:active { xborder: 2px dotted #666; background: #555;} + &::-webkit-scrollbar-corner { background: transparent;} +} +</style> +<style id='jquery-ui-overrides-less' type='text/less'> +// ---------------------------------------------------------------------------- +// jquery-ui-overrides.less +.tablet-ui { + .tooltipster-base { + @tooltip-bg-color: darken(#624888, 5%); + @tooltip-text-color: contrast(@tooltip-bg-color); + .tooltipster-box { + background-color: @tooltip-bg-color; + .tooltipster-content { + ul { + margin: 0; padding: 0; + li { + list-style-position: outside; + text-indent: -4px; + margin-left: 14px; + } + } + color: @tooltip-text-color; + font-weight: normal; + font-size: 12.5px; + overflow-y: hidden; + padding: 3px 6px 2px 6px; + } + } + .arrow-border(@side) { + &.tooltipster-@{side} { + .tooltipster-arrow { + .tooltipster-arrow-background { border-@{side}-color: @tooltip-bg-color; } + .tooltipster-arrow-border { border-@{side}-color: black; } + } + } + } + .arrow-border(top); + .arrow-border(right); + .arrow-border(left); + .arrow-border(bottom); + } + .radio { + label.ui-state-active { color: white !important; } + .ui-checkboxradio-label .ui-icon { border-radius: .5em; } + } + + .ui-checkboxradio-label { + padding: 3px 0; + &:hover { + .ui-icon-background { background-color: white; } + &.ui-state-active .ui-icon-background { + border-color: white; + background-color: @color-highlight; + } + } + .ui-checkboxradio-icon-space { + width: 8px; + display: inline-block; + } + &.ui-visual-focus { box-shadow: inherit; } + .ui-icon { + background-color: @color-bg-icon; + box-shadow: none; + border-radius: .25em; + } + &.ui-state-active { + .ui-icon-background { + background-color: @color-highlight; + padding: 2px; + border: solid 2px @color-bg-icon; + width: 8px; + height: 8px; + } + } + } + .ui-slider-range { background-color: @color-highlight; } + + .ui-spinner { + border: none; + background-color: transparent; + margin: 4px 2px; + padding: 0; + .ui-button { + border-left: solid 1px rgba(158, 158, 158, 0.151); + background-color: inherit; + } + input.ui-spinner-input { + margin: 0 !important; + xwidth: inherit !important; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + } + } + } + + .ui-slider-horizontal { + background-color: @color-bg-darker; + border-color: @color-bg-darker !important; + .ui-slider-handle { + top: -.5em; + width: 1.4em; + height: 1.4em; + border-radius: 100%; + outline: none; + background-color: #757575; + border-color: @color-bg-darker; + &:focus { border-color: white; } + // custom inner slider handle circle + .inner-ui-slider-handle { + border-radius: 100%; + padding: 0px; + right: 2px; + bottom: 2px; + position: absolute; + left: 2px; + top: 2px; + border: solid black 2px; + } + } + } +} +</style> +<style id='CustomSettingsApp-less' type='text/less'> +// ---------------------------------------------------------------------------- +// CustomSettingsApp.less +.tablet-ui .settings-app { + @subtle-border: solid 1px rgba(158, 158, 158, 0.151); + + br { clear: both; } + + .content { display: block; } + + #overlay { + opacity: .15; + background-color: white; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + z-index: 9999999; + } + &:not(.active) #overlay { display: block; } + &.active #overlay { display: none; } + + section { + clear: both; + h2 { + text-transform: uppercase; + margin-bottom: 10px; + } + } + + .content footer { + bottom: 0; + top: auto; + text-align: center; + height: @footer-height * 1px; + border-bottom: none; + box-shadow: none; + center { + //line-height: @footer-height * 1px; + vertical-align: middle; + font-size: .8em; + width: @client-width * .5px; + position: absolute; + display: block; + margin-left: 24%; + left: 0; + div { display: inline-block; } + } + button { + margin-top: 3px; + } + } + header, footer { + border: solid 1px black; + height: @header-height * 1px; + margin: 0; + position: fixed; + left: 0; + padding: 0; + top: 0; + width: 100%; + background-color: lighten(@color-bg,10%); + box-shadow: 2px 2px 10px rgba(0,0,0,.5); + z-index: 10; + white-space: nowrap; + > div { + position: relative; + padding: 0 6px; + } + .ui-icon { background-color: lighten(@color-bg-icon, 10%); } + .ui-checkboxradio-label { + &.ui-state-active { + .ui-icon { border-color: lighten(@color-bg-icon, 10%); } + &:hover .ui-icon { border-color: inherit; } + } + } + .row { + margin-top: 2px; + margin-left: 10px; + font-size: .9em; + display: inline-block !important; + &.bool { + margin-top: inherit !important; + margin-bottom: inherit !important; + } + } + } + + // row styles in two column mode + .column { + margin-left: -16px; + margin-right: -16px; + .row { + width: 40%; + &:nth-child(1) { margin-right: 5%; } + display: inline-block; + &.radio { + padding-top: .5em; + padding-bottom: .5em; + } + } + } + + .focused() { outline: dotted 1px @color-highlight; } + + // general row styles + .row { + white-space: nowrap; + margin-top: .3em; + margin-bottom: .3em; + + &:focus, div:focus { .focused } + &.disabled input.setting { color: #000; } + &.invalid input.setting { border: solid 1px mix(red, @color-highlight, 55%); } + input { + outline: none !important; + font-size: 1.1em; + margin: 8px; + padding: 8px; + border: solid 1px transparent; + &:focus { border: solid 1px @color-highlight !important; } + } + &.slider { + label { display: block; clear: both } + .ui-spinner { + position: relative; + width: 100px; + input { width: 80% } + } + .control { + float: left; + clear: left; + width: 75%; + margin: 13px; + top: 6px; + height: 6px; + } + } + &.bool { + input[type=checkbox] { display: none; } + label.ui-widget { padding-left: 0px; } + display: table-row; + margin-top: .6em; + margin-bottom: .6em; + } + + &.radio { + vertical-align: top; + input[type=radio] { display: inline-block; } + label.ui-visual-focus { .focused; } + border: @subtle-border; + border-right: @subtle-border; + border-radius: .5em; + padding-left: 12px; + padding-right: 12px; + } + + &.radio, &.bool { + label.ui-button { + text-align: left; + background: inherit; + border: inherit; + color: inherit; + .ui-icon { background-image: none; } + } + //.ui-checkboxradio-checked { color: white } + } + &.number { + label { + display: block; + width: 100%; + margin-right: 26px; + } + input { width: 10em; } + } + .unit { + margin-left: 6px; + font-weight: lighter; + font-size: .8em; + font-style: italic; + } + } + code { + font-family: monospace; + font-size: .8em; + font-weight: bold; + //color: mix(@color-text, magenta, 90%); + } + kbd { + display: inline-block; + margin: 0 .1em; + padding: .1em .6em; + font-family: Arial,"Helvetica Neue",Helvetica,sans-serif; + font-size: .85em; + font-weight: bold; + line-height: 1.1em; + color: #242729; + text-shadow: 0 1px 0 darken(#FFF, 25%); + background-color: darken(#e1e3e5, 25%); + border: 1px solid darken(#adb3b9, 25%); + border-radius: 3px; + box-shadow: 0 1px 0 rgba(12,13,14,0.2),0 0 0 2px darken(#FFF,25%) inset; + white-space: nowrap; + } +} +</style> +<style id='app-less' type='text/less'> +// ---------------------------------------------------------------------------- +// app.less +.tablet-ui .settings-app { + &.camera-move-enabled header { + border-bottom: solid 1px @color-highlight; + label { color: white !important; font-weight: bold; } + } + + button { + font-weight: bold; + font-family: arial; + font-size: 12px; + //padding: 6px 8px; + cursor: pointer; + background-color: @color-primary-button; + color: white; + border-color: transparent; + margin: 2px; + text-transform: uppercase; + &:hover { background-color: lighten(@color-primary-button,10%); } + border: solid 1px transparent; + &:focus { border: dotted 1px tint(@color-highlight) !important; } + } + .localhost-only { + //display: none; + &when (@debug), (@localhost) { + display: inline-block; + } + } + #advanced-options { + .content { background-color: #333; } + display: none; + } + #toggle-advanced-options { + background-color: darken(@color-alt-button, 5%); + .chevron { + width: 2em; + display: inline-block; + &:after { content: ' \25B7'; } + } + &:hover { background-color: lighten(@color-alt-button, 5%); } + } + #reset-to-defaults { + background-color: darken(@color-caution-button, 5%); + &:hover { background-color: lighten(@color-caution-button, 5%); } + } +} +.ui-show-advanced-options { + #advanced-options { display: block !important; } + #toggle-advanced-options .chevron:after { content: ' \25BC' !important; } +} + +#appVersion { + z-index: 1; + float: right; +} +#toggleKey { line-height: @footer-height * 1px; } + +// #overlay when(not(@interface-mode)) { +// height: 16px !important; +// } + +#errors { + position: fixed; + left: 0; + top: 0; + height: auto; + width: 100%; + font-size: .8em; + background-color: rgb(95,0,0); + color: white; + overflow: auto; + margin: 0; + padding: 8px; + z-index: 9999999; +} + +footer center > div { display: none; } +#tooltips { display: none } + +.tablet-ui .settings-app .row[for=requestAnimationFrame] { + display: inherit !important; + margin-top: 0; + margin-bottom: 0; + .ui-spinner { margin: 0; } + .ui-checkboxradio-label { margin: 0; padding: 0;} + #fps { + padding: 3px; + height: .7em; + font-size: .9em; + width: 50px; + } +} +</style> +<script> preconfigureLESS(); </script> +<script data-env-'development' xx-data-env='production' src="https://cdnjs.cloudflare.com/ajax/libs/less.js/2.7.2/less.js"></script> + +<!-- JSON DUMP TEMPLATE --> +<script type='text/html' id='POPUP'> + <html> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/ir-black.min.css" /> + <xx-script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></xx-script> + <body style='margin:0;overflow:hidden;'> + <pre><code class="json" style='height:100%'>JSON</code></pre> + <xx-script>hljs.initHighlighting();</xx-script> + </body> + </html> +</script> + +<div id='overlay'></div> +</body> +</html> diff --git a/unpublishedScripts/marketplace/camera-move/app.js b/unpublishedScripts/marketplace/camera-move/app.js new file mode 100644 index 0000000000..366b6b7117 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/app.js @@ -0,0 +1,895 @@ +// app.js -- jquery support functions + +/* eslint-env commonjs, browser */ + +// ---------------------------------------------------------------------------- +function defineCustomWidgets() { + $.widget('ui.hifiCheckboxRadio', $.ui.checkboxradio, { + _create: function() { + this._super(); + this.element[0].value = this.element[0].id; + debugPrint('ui.hifiCheckboxRadio._create', this.element[0].type, this.element[0].id, this.element[0].value); + }, + });//$.fn.hifiCheckboxRadio = $.fn.checkboxradio; + $.widget('ui.hifiControlGroup', $.ui.controlgroup, { + _create: function(x) { + debugPrint('ui.hifiControlGroup._create', this.element[0]) + var tmp = this.options.items.checkboxradio; + delete this.options.items.checkboxradio; + this.options.items.hifiCheckboxRadio = tmp; + this._super(); + + Object.defineProperty(this.element[0], 'value', { + enumerable: true, + get: function() { assert(false, 'attempt to access hifiControlGroup.element[0].value...' +[this.id,this.name]); }, + set: function(nv) { assert(false, 'attempt to set hifiControlGroup.element[0].value...' +[this.id,this.name]); }, + }); + }, + }); + $.widget('ui.hifiSpinner', $.ui.spinner, { + _create: function() { + debugPrint('ui.hifiSpinner._create', this.element[0]) + this.previous = null; + this._super(); + }, + _spin: function( step, event ) { + if (event.type === 'mousewheel') { + if (!event.shiftKey) { + step *= ('1e'+Math.max(1,this._precision()))/10; + } + if (event.ctrlKey) { + step *= 10; + } + } + return this._super( step, event ); + }, + _stop: function( event, ui ) { + try { + return this._super(event, ui); + } finally { + if (/mouse/.test(event && event.type)) { + var value = this.element.val(); + if (value != "" && !isNaN(value) && this.previous !== null && this.previous !== value) { + debugPrint(this.element[0].id, 'spinner.changed', event.type, JSON.stringify({ + previous: isNaN(this.previous) ? this.previous+'' : this.previous, + val: isNaN(value) ? value+'' : value, + })); + this.element.change(); + } + this.previous = value; + } + } + }, + _format: function(n) { + var precision = this._precision() + return parseFloat(n).toFixed(precision); + }, + _events: { + mousewheel: function(event, delta) { + if (document.activeElement !== this.element[0]) + return; + // fix broken mousewheel on Chrome / webkit + delta = delta === undefined ? event.originalEvent.deltaY : delta; + $.ui.spinner.prototype._events.mousewheel.call(this, event, delta); + } + } + }); + $.widget('ui.hifiSlider', $.ui.slider, { + _create: function() { + this._super(); + // add the inner circle and border (per design specs) to jquery-ui's existing slider handle + this.element.find('.ui-slider-handle') + .html('<div class="inner-ui-slider-handle"></div>'); + }, + }); +} + +// JSON export / import helpers proto module +JQuerySettings.$json = (function() { + return { + setPath: setPath, + rollupPaths: rollupPaths, + encodeNodes: encodeNodes, + exportAll: exportAll, + showSettings: showSettings, + applyJSON: applyJSON, + promptJSON: promptJSON, + popupJSON, popupJSON, + }; + + function encodeNodes(resolver, elements) { + return elements.toArray().reduce((function(out, input, i) { + log('input['+i+']', input.id); + var id = input.type === 'radio' ? $(input).closest(':ui-hifiControlGroup').prop('id') : input.id; + var key = resolver.getKey(id); + log('toJSON', id, key, input.id); + setPath(out, key.split('/'), resolver.getValue(key)); + return out; + }).bind(this), {}); + } + + function setPath(obj, path, value) { + var key = path.pop(); + obj = path.reduce(function(obj, subkey) { + return obj[subkey] = obj[subkey] || {}; + }, obj); + debugPrint('setPath', key, Object.keys(obj)); + obj[key] = value; + } + + function rollupPaths(obj, output, path) { + path = path || []; + output = output || {}; + log('rollupPaths', Object.keys(obj||{}), Object.keys(output), path); + for (var p in obj) { + path.push(p); + var value = obj[p]; + if (value && typeof value === 'object') { + rollupPaths(obj[p], output, path); + } else { + output[path.join('/')] = value; + } + path.pop(); + } + return output; + } + + function exportAll(resolver, name) { + return { + version: VERSION, + name: name || undefined, + settings: encodeNodes(resolver, $('input')), + _metadata: { timestamp: new Date(), PARAMS: PARAMS, url: location.href, } + }; + }; + + function showSettings(resolver, saveName) { + JQuerySettings.$json.popupJSON(saveName || '(current settings)', Object.assign(JQuerySettings.$json.exportAll(resolver, saveName), { + extraParams: bridgedSettings.extraParams, + })); + }; + + function popupJSON(title, tmp) { + var HTML = POPUP.innerHTML + .replace(/\bxx-script\b/g, 'script') + .replace('JSON', JSON.stringify(tmp, 0, 2).replace(/\n/g, '<br />')); + if (0) { + bridgedSettings.sendEvent({ + method: 'overlayWebWindow', + options: { + title: 'app-camera-move-export' + (title ? '::'+title : ''), + content: HTML, + }, + }); + } else { + // make the browser address bar less ugly by putting spaces and friedly name as a "URL footer" + var footer = '<\!-- #' + HTML.substr(0,256).replace(/./g,' ') + (title || 'Camera Move Settings'); + window.open("data:text/html;escape," + encodeURIComponent(HTML) + footer,"app-camera-move-export"); + } + } + + function applyJSON(resolver, name, tmp) { + assert('version' in tmp && 'settings' in tmp, 'invalid settings record: ' + JSON.stringify(tmp)) + var settings = rollupPaths(tmp.settings); + for(var p in settings) { + var key = resolver.getId(p, true); + if (!key) { + log('$applySettings -- skipping unregistered Settings key: ', p); + } else { + resolver.setValue(p, settings[p], name+'.settings.'+p); + } + } + }; + + function promptJSON() { + var json = window.prompt('(paste JSON here)', ''); + if (!json) return; + try { + json = JSON.parse(json); + } catch(e) { + throw new Error('Could not parse pasted JSON: ' + e + '\n\n' + (json||'').replace(/</g,'<')); + } + return json; + } +})(this); + +// ---------------------------------------------------------------------------- +// manage jquery-tooltipster hover tooltips +TooltipManager = (function(global) { + function TooltipManager(options) { + this.options = options; + assert(options.elements, 'TooltipManager constructor expects options.elements'); + assert(options.tooltips, 'TooltipManager constructor expects options.tooltips'); + var TOOLTIPS = this.TOOLTIPS = {}; + $(options.tooltips).find('[for]').each(function() { + var id = $(this).attr('for'); + TOOLTIPS[id] = $(this).html(); + }); + + var _self = this; + options.elements.each(function() { + var element = $($(this).closest('.tooltip-target').get(0) || this), + input = element.is('input, button') ? element : element.find('input, button'), + parent = element.is('.row, button') ? element : element.parent(), + id = element.attr('for') || input.prop('id'), + tip = TOOLTIPS[id] || element.prop('title'); + + var tooltipSide = element.data('tooltipSide'); + + log('binding tooltip', tooltipSide, element[0].nodeName, id || element[0], tip); + if(!tip) + return log('missing tooltip: ' + (id || this.id || this.name || this.nodeName)); + if(element.is('.tooltipstered')) { + console.info('already tooltipstered!?', this.id, this.name, id); + return; + } + var instance = element.tooltipster({ + theme: ['tooltipster-noir'], + side: tooltipSide || ( + input.is('button') ? 'top' : + input.closest('.slider').get(0) || input.closest('.column').get(0) ? ['top','bottom'] : + ['right','top','bottom', 'left'] + ), + content: tip, + updateAnimation: 'scale', + trigger: !options.testmode ? 'hover' : 'click', + distance: element.is('.slider.row') ? -20 : undefined,//element + delay: [500, 1000], + contentAsHTML: true, + interactive: options.testmode, + minWidth: options.viewport && options.viewport.min.x, + maxWidth: options.viewport && options.viewport.max.w, + }).tooltipster('instance'); + + instance.on('close', function(event) { + if (options.keepopen === element) { + debugPrint(event.type, 'canceling close keepopen === element', id); + event.stop(); + options.keepopen = null; + } + }); + instance.on('before', function(event) { + debugPrint(event.type, 'before', event); + !options.testmode && _self.closeAll(); + !options.enabled && event.stop(); + return; + }); + parent.find(':focusable, input, [tabindex], button, .control') + .add(parent) + .add(input.closest(':focusable, input, [tabindex]')) + .on({ + click: function(evt) { + if (input.is('button')) return setTimeout(instance.close.bind(instance,null),50); + options.keepopen = element; 0&&instance.open(); + }, + focus: instance.open.bind(instance, null), + blur: function(evt) { instance.close(); _self.openFocusedTooltip(); }, + }); + }); + Object.assign(this, { + openFocusedTooltip: function() { + if (!this.options.enabled) + return; + setTimeout(function() { + if (!document.activeElement || document.activeElement === document.body || + !$(document.activeElement).closest('section')) { + return; + } + var tip = $([]) + .add($(document.activeElement)) + .add($(document.activeElement).find('.tooltipstered')) + .add($(document.activeElement).closest('.tooltipstered')) + .filter('.tooltipstered'); + if (tip.is('.tooltipstered')) { + log('opening focused tooltip', tip.length, tip[0].id); + tip.tooltipster('open'); + } + },1); + }, + rapidClose: function(instance, reopen) { + if (!instance.status().open) { + return; + } + instance.elementTooltip() && $(instance.elementTooltip()).hide(); + instance.close(function() { reopen && instance.open(); }); + }, + openAll: function() { + $('.tooltipstered').tooltipster('open'); + }, + closeAll: function() { + $.tooltipster.instances().forEach(function(instance) { + this.rapidClose(instance); + }.bind(this)); + }, + updateViewport: function(viewport) { + var options = { + minWidth: viewport.min.x, + maxWidth: viewport.max.x, + }; + $.tooltipster.setDefaults(options); + log('updating tooltipster options', JSON.stringify(options, 0, 2)); + $.tooltipster.instances().forEach(function(instance) { + instance.option('minWidth', options.minWidth); + instance.option('maxWidth', options.maxWidth); + this.rapidClose(instance, instance.status().open); + }.bind(this)); + }, + enable: function() { + this.options.enabled = true; + if (this.options.testmode) + this.openAll(); + }, + disable: function() { + this.options.enabled = false; + this.closeAll(); + }, + }); + } + return TooltipManager; +})(this); + +function setupUI() { + $(window).trigger('resize'); // populate viewport + + $('#debug-menu button, footer button').button(); + + var $json = JQuerySettings.$json; + + var buttonHandlers = { + 'page-reload': function() { + log('triggering location.reload'); + location.reload(); + }, + 'script-reload': function() { + log('triggering script.reload'); + bridgedSettings.sendEvent({ method: 'reloadClientScript' }); + }, + 'reset-sensors': function() { + log('resetting avatar orientation'); + bridgedSettings.sendEvent({ method: 'resetSensors' }); + }, + 'reset-to-defaults': function() { + tooltipManager.closeAll(); + document.activeElement && document.activeElement.blur(); + document.body.focus(); + setTimeout(function() { + bridgedSettings.sendEvent({ method: 'reset' }); + },1); + }, + 'copy-json': $json.showSettings.bind($json, jquerySettings, null), + 'paste-json': function() { + $json.applyJSON( + jquerySettings, + 'pasted', + $json.promptJSON() + ); + }, + 'toggle-advanced-options': function(evt) { + var on = $('body').hasClass('ui-show-advanced-options'); + bridgedSettings.setValue('ui-show-advanced-options', !on); + evt.stopPropagation(); + evt.preventDefault(); + $(this).tooltipster('instance').content((on ? 'show' : 'hide') + ' advanced options'); + if (!on) { + $('.scrollable').delay(100).animate({ + scrollTop: innerHeight - $('header').innerHeight() - 24 + }, 1500); + } + }, + 'appVersion': function(evt) { evt.shiftKey && $json.showSettings(jquerySettings); }, + 'errors': function() { + $(this).find('.output').text('').end().hide(); + }, + }; + Object.keys(buttonHandlers).forEach(function(p) { + $('#' + p).css('cursor', 'pointer').on('click', buttonHandlers[p]); + }); + + // ---------------------------------------------------------------- + tooltipManager = new TooltipManager({ + enabled: false, + testmode: PARAMS.tooltiptest, + viewport: PARAMS.viewport, + tooltips: $('#tooltips'), + elements: $($.unique($('input.setting, button').map(function() { + var input = $(this), + button = input.is('button') && input, + row = input.closest('.row').is('.slider') && input.closest('.row'), + label = input.is('[type=checkbox],[type=radio]') && input.parent().find('label'); + + return (button || label || row || input).get(0); + }))), + }); + + $( "input[data-type=number]" ).each(function() { + // set up default numeric precisions + var step = $(this).prop('step') || .01; + var precision = ((1 / step).toString().length - 1); + $(this).prop('step', step || Math.pow(10, -precision)); + }); + + $('input.setting').each(function() { + // set up the bidirectional mapping between DOM and Settings + jquerySettings.registerNode(this); + }).on('change', function(evt) { + // set up change detection + var input = $(this), + key = assert(jquerySettings.getKey(this.id)), + type = this.dataset.type || input.prop('type'), + valid = (this.value !== '' && (type !== 'number' || isFinite(this.value))); + + debugPrint('input.setting.change', key, evt, this); + $(this).closest('.row').toggleClass('invalid', !valid); + + if (!valid) { + debugPrint('input.setting.change: ignoring invalid value ' + this.value + ' for ' + key); + return; + } + + jquerySettings.resyncValue(key, 'input.setting.change'); + + // radio buttons are codependent (ie: TCOBO selected at a time) + // so if under a control group, trigger a display refresh + $(evt.target).closest(':ui-hifiControlGroup').find(':ui-hifiCheckboxRadio').hifiCheckboxRadio('refresh'); + }); + + if (PARAMS.debug || true) { + // support hitting 'r' key to refresh during development + $(window).on('keypress.debug', function(evt) { + if (!$(evt.target).is('input')) { + var ch = String.fromCharCode(evt.keyCode); + if (evt.originalEvent.keyIdentifier === 'U+0057') + ch = 'w'; + switch(ch) { + case 'r': { + log('... "r" reloading', evt.keyCode); + location.reload() + } break; + case 'w': { + if (evt.ctrlKey) { + log('ctrl-w detected; closing via client script'); + bridgedSettings.sendEvent({ method: 'window.close' }); + } + } break; + } + } + }); + } + + // numeric fields + $( ".number.row input.setting" ) + .hifiSpinner({ + disabled: true, + create: function() { + var input = $(this), + id = assert(this.id, '.number input.setting without id attribute: ' + this), + key = assert(jquerySettings.getKey(this.id)); + + var options = input.hifiSpinner('instance').options; + options.min = options.min || 0.0; + + bridgedSettings.getValueAsync(key, function(err, result) { + input.hifiSpinner('enable'); + jquerySettings.setValue(key, result); + }); + }, + }); + + // radio button groups + $( ".radio.row" ) + .hifiControlGroup({ + direction: 'horizontal', + disabled: true, + create: function() { + var group = $(this), id = this.id; + this.setAttribute('type', this.type = this.dataset.type = 'radio-group'); + var key = assert(jquerySettings.getKey(id)); + bridgedSettings.getValueAsync(key, function(err, result) { + debugPrint('> GOT RADIO', key, id, result); + group.hifiControlGroup('enable'); + jquerySettings.setValue(key, result); + group.change(); + }); + }, + }); + + // checkbox fields + $( ".bool.row input.setting" ) + .hifiCheckboxRadio({ disabled: true }) + .each(function() { + var key = assert(jquerySettings.getKey(this.id)), + input = $(this); + + bridgedSettings.getValueAsync(key, function(err, result) { + input.hifiCheckboxRadio('enable'); + jquerySettings.setValue(key, result); + }); + }); + + // slider + numeric field sets + $( ".slider.row .control" ).each(function(ent) { + var element = $(this), + input = element.parent().find('input'), + id = input.prop('id'), + key = assert(jquerySettings.getKey(id)); + + var commonOptions = { + disabled: true, + min: parseFloat(input.prop('min') || 0), + max: parseFloat(input.prop('max') || 10), + step: parseFloat(input.prop('step') || 0.01), + }; + debugPrint('commonOptions', commonOptions); + + // see: https://api.jqueryui.com/slider/ for more options + var slider = element.hifiSlider(Object.assign({ + orientation: "horizontal", + range: "min", + animate: 'fast', + value: 0.0, + start: function(event, ui) { this.$startValue = ui.value; }, + slide: function(event, ui) { input.hifiSpinner('value', ui.value); }, + stop: function(event, ui) { + if (ui.value != this.$startValue) { + jquerySettings.setValue(key, ui.value, 'slider.stop'); + } + }, + }, commonOptions)); + + // setup chrome up/down arrow steps and change event for propagating input field -> slider + input.on('change', function() { + element.hifiSlider("value", this.value); + }).hifiSpinner(Object.assign({}, commonOptions, { max: Infinity })); + + bridgedSettings.getValueAsync(key, function(err, result) { + input.hifiSpinner('enable'); + element.hifiSlider('enable'); + jquerySettings.setValue(key, result); + }); + }); + + // make all other numeric fields into custom jquery spinners + $('input[data-type=number]:not(:ui-hifiSpinner)').hifiSpinner({ + disabled: true, + create: function() { + var input = $(this), + id = assert(this.id, '.number input.setting without id attribute: ' + this), + key = assert(jquerySettings.getKey(this.id)); + + log('======================================', id, key); + + var options = input.hifiSpinner('instance').options; + options.min = options.min || 0.0; + + bridgedSettings.getValueAsync(key, function(err, result) { + jquerySettings.setValue(key, result); + }); + }, + }); + + + // ---------------------------------------------------------------------------- + // allow spacebar to toggle checkbox / radiobutton fields + $('[type=checkbox]:ui-hifiCheckboxRadio').parent().prop('tabindex',0) + .on('keydown.toggle', function spaceToggle(evt) { + if (evt.keyCode === 32) { + var input = $(this).find(':ui-hifiCheckboxRadio'), + id = input.prop('id'), + key = assert(jquerySettings.getKey(id)); + log('spaceToggle', evt.target+'', id, key); + if (!input.is('[type=radio]') || !input.prop('checked')) { + input.prop('checked', !input.prop('checked')); + input.change(); + } + evt.preventDefault(); + evt.stopPropagation(); + } + }); + + // when user presses ENTER on a number field, blur it to provide visual feedback + $('input[data-type=number]').on('keydown.enter', function(evt) { + if (evt.keyCode === 13 && $(document.activeElement).is('input')) { + tooltipManager.closeAll(); + document.activeElement.blur(); + var nexts = $('[tabindex],input').not('[tabindex=-1],.ui-slider-handle').toArray(); + if (~nexts.indexOf(this)) { + var nextActive = nexts[nexts.indexOf(this)+1]; + $(nextActive).focus(); + } + } + }); + + // by default webkit spacebar behaves like PageDown; this disables that to avoid confusion + window.onkeydown = function(evt) { + if (evt.keyCode === 32 && document.activeElement !== document.body) { + log('snarfing spacebar event', document.activeElement); + return evt.preventDefault(), evt.stopPropagation(), false; + } + }; + + // select-all text when an input field is first focused + $('input').not('input[type=radio],input[type=checkbox]').on('focus', function (e) { + var dt = (new Date - this.blurredAt); + if (!(dt < 5)) { // debounce + this.blurredAt = +new Date; + //log('FOCUS', dt, e.target === document.activeElement, this === document.activeElement); + $(this).one('mouseup.selectit', function () { + $(this).select(); + return false; + }).select(); + } + }).on('blur', function(e) { + this.blurredAt = new Date; + //log('BLUR', e.target === document.activeElement, this === document.activeElement); + }); + + // monitor specific settings for live changes + jquerySettings.registerSetting({ type: 'placeholder' }, '.extraParams'); + jquerySettings.registerSetting({ type: 'placeholder' }, 'ui-show-advanced-options'); + jquerySettings.registerSetting({ type: 'placeholder' }, 'Keyboard.RightMouseButton'); + monitorSettings({ + // advanced options toggle + 'ui-show-advanced-options': function(value) { + function handle(err, result) { + log('***************************** ui-show-advanced-options updated', result+''); + $('body').toggleClass('ui-show-advanced-options', !!result); + } + if (value !== undefined) { + handle(null, value); + } else { + bridgedSettings.getValueAsync('ui-show-advanced-options', handle); + } + }, + // UI tooltips checkbox + 'ui-enable-tooltips': function(value) { + if (value) { + tooltipManager.enable(); + tooltipManager.openFocusedTooltip(); + } else { + tooltipManager.disable(); + } + }, + + // enable/disable fps field based on thread update mode + 'thread-update-mode': function(value) { + var enabled = value === 'requestAnimationFrame', + fps = $('#fps'); + + log('onThreadModeChanged', value, enabled); + fps.hifiSpinner(enabled ? 'enable' : 'disable'); + fps.closest('.tooltip-target').toggleClass('disabled', !enabled); + }, + + // apply CSS to body based on whether camera move mode is currently enabled + 'camera-move-enabled': function(value) { + $('body').toggleClass('camera-move-enabled', value); + }, + + // update keybinding and appVersion whenever extraParams arrives + '.extraParams': function(value, other) { + value = bridgedSettings.extraParams; + //log('.extraParams', value, other, arguments); + if (value.mode) { + $('body').toggleClass('tablet-mode', value.mode.tablet); + $('body').toggleClass('hmd-mode', value.mode.hmd); + $('body').toggleClass('desktop-mode', value.mode.desktop); + $('body').toggleClass('toolbar-mode', value.mode.toolbar); + //setupTabletModeScrolling(value.mode.tablet); + } + var versionDisplay = [ + value.appVersion || '(unknown appVersion)', + PARAMS.debug && '(debug)', + value.mode && value.mode.tablet ? '(tablet)' : '', + ].filter(Boolean).join(' | '); + $('#appVersion').find('.output').text(versionDisplay).end().fadeIn(); + + if (value.toggleKey) { + function getKeysHTML(binding) { + return [ 'Control', 'Meta', 'Alt', 'Super', 'Menu', 'Shifted' ] + .map(function(flag) { return binding['is' + flag] && flag; }) + .concat(binding.text || ('(#' + binding.key + ')')) + .filter(Boolean) + .map(function(key) { return '<kbd>' + key.replace('Shifted','Shift') + '</kbd>'; }) + .join('-'); + } + $('#toggleKey').find('.binding').empty() + .append(getKeysHTML(value.toggleKey)).end().fadeIn(); + } + }, + + // gray out / ungray out page content if user is mouse looking around in Interface + // (otherwise the cursor still interacts with web content...) + 'Keyboard.RightMouseButton': function(localValue, key, value) { + log('... Keyboard.RightMouseButton:' + value); + window.active = !value; + }, + }); + $('input').css('-webkit-user-select', 'none'); +} // setupUI + +// helper for instrumenting local jquery onchange handlers +function monitorSettings(options) { + return Object.keys(options).reduce(function(out, id) { + var key = bridgedSettings.resolve(id); + assert(function assertion(){ return typeof key === 'string' }, 'monitorSettings -- received invalid key type') + function onChange(varargs) { + debugPrint('onChange', id, typeof varargs === 'string' ? varargs : typeof varargs); + var args = [].slice.call(arguments); + options[id].apply(this, [ jquerySettings.getValue(id) ].concat(args)); + } + if (bridgedSettings.pendingRequestCount()) { + bridgedSettings.pendingRequestsFinished.connect(function once() { + bridgedSettings.pendingRequestsFinished.disconnect(once); + onChange('pendingRequestsFinished'); + }); + } else { + onChange('initialization'); + } + function _onValueUpdated(_key) { _key === key && onChange.apply(this, arguments); } + bridgedSettings.valueUpdated.connect(bridgedSettings, _onValueUpdated); + jquerySettings.valueUpdated.connect(jquerySettings, _onValueUpdated); + return out; + }, {}); +} + +function logValueUpdate(hint, key, value, oldValue, origin) { + if (0 === key.indexOf('.')) { + return; + } + oldValue = JSON.stringify(oldValue), value = JSON.stringify(value); + _debugPrint('[ ' + hint +' @ ' + origin + '] ' + key + ' = ' + value + ' (was: ' + oldValue + ')'); +} + +function initializeDOM() { + // DOM initialization + window.viewportUpdated = signal(function viewportUpdated(viewport) {}); + function triggerViewportUpdate() { + var viewport = { + geometry: { x: innerWidth, y: innerHeight }, + min: { x: window.innerWidth / 3, y: 32 }, + max: { x: window.innerWidth * 7/8, y: window.innerHeight * 7/8 }, + }; + viewportUpdated(viewport, triggerViewportUpdate.lastViewport); + triggerViewportUpdate.lastViewport = viewport; + } + viewportUpdated.connect(PARAMS, function(viewport, oldViewport) { + log('viewportUpdated', viewport); + Object.assign(PARAMS, { + viewport: Object.assign(PARAMS.viewport||{}, viewport), + }); + tooltipManager.updateViewport(viewport); + }); + document.onselectstart = document.ondragstart = + document.body.ondragstart = document.body.onselectstart = function(){ return false; }; + + document.body.oncontextmenu = document.oncontextmenu = document.body.ontouchstart = function(evt) { + evt.stopPropagation(); + evt.preventDefault(); + return false; + }; + + $('.scrollable').on('mousemove.unselect', function() { + if (!$(document.activeElement).is('input')) { + if (document.selection) { + log('snarfing mousemove.unselect.selection'); + document.selection.empty() + } else { + window.getSelection().removeAllRanges() + } + } + }).on('selectstart.unselect', function(evt) { + if (!$(document.activeElement).is('input')) { + log('snarfing selectstart'); + evt.stopPropagation(); + evt.preventDefault(); + return false; + } + }); + + Object.defineProperty(window, 'active', { + get: function() { return window._active; }, + set: function(nv) { + nv = !!nv; + window._active = nv; + log('window.active == ' + nv); + if (!nv) { + document.activeElement && document.activeElement.blur(); + document.body.focus(); + $('body').toggleClass('active', nv); + } else { + $('body').toggleClass('active', nv); + } + }, + }); + window.active = true; + + function checkAnim(evt) { + if (!checkAnim.disabled) { + if ($('.scrollable').is(':animated')) { + $('.scrollable').stop(); + log(evt.type, 'stop animation'); + } + } + } + $(window).on({ + resize: function resize() { + window.clearTimeout(resize.to); + resize.to = window.setTimeout(triggerViewportUpdate, 100); + }, + mousedown: checkAnim, + mouseup: checkAnim, + scroll: checkAnim, + wheel: checkAnim, + blur: function() { + log('** BLUR ** '); + document.body.focus(); + document.activeElement && document.activeElement.blur(); + tooltipManager.disable(); + //tooltipManager.closeAll(); + }, + focus: function() { + log('** FOCUS **'); + bridgedSettings.getValue('ui-enable-tooltips') && tooltipManager.enable(); + }, + }); +} + +function preconfigureLESS() { + window.lessOriginalNodes = $('style[type="text/less"]').remove(); + window.lessGlobalVars = Object.assign({ + debug: !!PARAMS.debug, + localhost: 1||/^\b(?:localhost|127[.])/.test(location), + hash: '', + }, { + 'header-height': 48, + 'footer-height': 32, + 'custom-font-family': 'Raleway-Regular', + 'input-font-family': 'FiraSans-Regular', + 'color-highlight': '#009bd5', + 'color-text': '#afafaf', + 'color-bg': '#393939', + 'color-bg-darker': '#252525', + 'color-bg-icon': '#545454', + 'color-primary-button': 'darkblue', + 'color-alt-button': 'green', + 'color-caution-button': 'darkred', + }); + + window.less = { + //poll: 1000, + //watch: true, + globalVars: lessGlobalVars, + }; + + preconfigureLESS.onViewportUpdated = function onViewportUpdated(viewport) { + if (onViewportUpdated.to) { + clearTimeout(onViewportUpdated.to); + } else if (lessGlobalVars.hash) { + onViewportUpdated.to = setTimeout(onViewportUpdated, 500); // debounce + return; + } + delete lessGlobalVars.hash; + Object.assign(lessGlobalVars, { + 'interface-mode': /highfidelity/i.test(navigator.userAgent), + 'inner-width': window.innerWidth, + 'inner-height': window.innerHeight, + 'client-width': document.body.clientWidth || window.innerWidth, + 'client-height': document.body.clientHeight || window.innerHeight, + 'hash': '', + }); + lessGlobalVars.hash = JSON.stringify(JSON.stringify(lessGlobalVars,0,2)).replace(/\\n/g , '\\000a'); + var hash = JSON.stringify(lessGlobalVars, 0, 2); + log('onViewportUpdated', JSON.parse(onViewportUpdated.lastHash||'{}')['inner-width'], JSON.parse(hash)['inner-width']); + if (onViewportUpdated.lastHash !== hash) { + //log('updating lessVars', 'less.modifyVars:' + typeof less.modifyVars, JSON.stringify(lessGlobalVars, 0, 2)); + PARAMS.debug && $('#errors').show().html("<pre>").children(0).text(hash); + // LESS needs some help to recompile inline styles, so a fresh copy of the source nodes is swapped-in + var newNodes = lessOriginalNodes.clone().appendTo(document.body); + less.modifyVars && less.modifyVars(true, lessGlobalVars); + var oldNodes = onViewportUpdated.lastNodes; + oldNodes && oldNodes.remove(); + onViewportUpdated.lastNodes = newNodes; + } + onViewportUpdated.lastHash = hash; + }; +} diff --git a/unpublishedScripts/marketplace/camera-move/modules/EnumMeta.js b/unpublishedScripts/marketplace/camera-move/modules/EnumMeta.js new file mode 100644 index 0000000000..f59f134344 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/EnumMeta.js @@ -0,0 +1,239 @@ +// EnumMeta.js -- helper module that maps related enum values to names and ids +// + +/* eslint-env commonjs */ +/* global DriveKeys */ + +var VERSION = '0.0.1'; +var WANT_DEBUG = false; + +function _debugPrint() { + print('EnumMeta | ' + [].slice.call(arguments).join(' ')); +} + +var debugPrint = WANT_DEBUG ? _debugPrint : function(){}; + +try { + /* global process */ + if (process.title === 'node') { + _defineNodeJSMocks(); + } +} catch (e) { /* eslint-disable-line empty-block */ } + +_debugPrint(VERSION); + +// FIXME: C++ emits this action event, but doesn't expose it yet to scripting +// (ie: as Actions.ZOOM or Actions.TranslateCameraZ) +var ACTION_TRANSLATE_CAMERA_Z = { + actionName: 'TranslateCameraZ', + actionID: 12, + driveKeyName: 'ZOOM' +}; + +module.exports = { + version: VERSION, + DriveKeyNames: invertKeys(DriveKeys), + Controller: { + Hardware: Object.keys(Controller.Hardware).reduce(function(names, prop) { + names[prop+'Names'] = invertKeys(Controller.Hardware[prop]); + return names; + }, {}), + ActionNames: _getActionNames(), + StandardNames: invertKeys(Controller.Standard) + }, + getDriveKeyNameFromActionName: getDriveKeyNameFromActionName, + getActionNameFromDriveKeyName: getActionNameFromDriveKeyName, + eventKeyText2KeyboardName: eventKeyText2KeyboardName, + keyboardName2eventKeyText: keyboardName2eventKeyText, + + ACTION_TRANSLATE_CAMERA_Z: ACTION_TRANSLATE_CAMERA_Z, + INVALID_ACTION_ID: Controller.findAction('INVALID_ACTION_ID_FOO'), +}; + +_debugPrint('///'+VERSION, Object.keys(module.exports)); + +var actionsMapping = {}, driveKeyMapping = {}; + +initializeMappings(actionsMapping, driveKeyMapping); + +function invertKeys(object) { + if (!object) { + return object; + } + return Object.keys(object).reduce(function(out, key) { + out[object[key]] = key; + return out; + }, {}); +} + +function _getActionNames() { + var ActionNames = invertKeys(Controller.Hardware.Actions); + ActionNames[ACTION_TRANSLATE_CAMERA_Z.actionID] = ACTION_TRANSLATE_CAMERA_Z.actionName; + function mapActionName(actionName) { + var actionKey = Controller.Hardware.Actions[actionName], + actionID = Controller.findAction(actionName), + similarName = eventKeyText2KeyboardName(actionName), + existingName = ActionNames[actionID]; + + var keyName = actionName; + + if (actionID === module.exports.INVALID_ACTION_ID) { + _debugPrint('actionID === INVALID_ACTION_ID', actionName); + } + switch (actionName) { + case 'StepTranslateX': actionName = 'StepTranslate'; break; + case 'StepTranslateY': actionName = 'StepTranslate'; break; + case 'StepTranslateZ': actionName = 'StepTranslate'; break; + case 'ACTION1': actionName = 'PrimaryAction'; break; + case 'ACTION2': actionName = 'SecondaryAction'; break; + } + debugPrint(keyName, actionName, actionKey, actionID); + + similarName = similarName.replace('Lateral','Strafe').replace(/^(?:Longitudinal|Vertical)/, ''); + if (actionID in ActionNames) { + // check if overlap is just BoomIn <=> BOOM_IN + if (similarName !== existingName && actionName !== existingName) { + throw new Error('assumption failed: overlapping actionID:'+JSON.stringify({ + actionID: actionID, + actionKey: actionKey, + actionName: actionName, + similarName: similarName, + keyName: keyName, + existingName: existingName + },0,2)); + } + } else { + ActionNames[actionID] = actionName; + ActionNames[actionKey] = keyName; + } + } + // first map non-legacy (eg: Up and not VERTICAL_UP) actions + Object.keys(Controller.Hardware.Actions).filter(function(name) { + return /[a-z]/.test(name); + }).sort().reverse().forEach(mapActionName); + // now legacy actions + Object.keys(Controller.Hardware.Actions).filter(function(name) { + return !/[a-z]/.test(name); + }).sort().reverse().forEach(mapActionName); + + return ActionNames; +} + +// attempts to brute-force translate an Action name into a DriveKey name +// eg: _translateActionName('TranslateX') === 'TRANSLATE_X' +// eg: _translateActionName('Yaw') === 'YAW' +function _translateActionName(name, _index) { + name = name || ''; + var key = name; + var re = new RegExp('[A-Z][a-z0-9]+', 'g'); + key = key.replace(re, function(Word) { + return Word.toUpperCase()+'_'; + }) + .replace(/_$/, ''); + + if (key in DriveKeys) { + debugPrint('getDriveKeyFromEventName', _index, name, key, DriveKeys[key]); + return key; + } +} + +function getActionNameFromDriveKeyName(driveKeyName) { + return driveKeyMapping[driveKeyName]; +} +// maps an action lookup value to a DriveKey name +// eg: actionName: 'Yaw' === 'YAW' +// actionKey: Controller.Actions.Yaw => 'YAW' +// actionID: Controller.findAction('Yaw') => 'YAW' +function getDriveKeyNameFromActionName(lookupValue) { + if (lookupValue === ACTION_TRANSLATE_CAMERA_Z.actionName || + lookupValue === ACTION_TRANSLATE_CAMERA_Z.actionID) { + return ACTION_TRANSLATE_CAMERA_Z.driveKeyName; + } + if (typeof lookupValue === 'string') { + lookupValue = Controller.findAction(lookupValue); + } + return actionsMapping[lookupValue]; +} + +// maps a Controller.key*Event event.text -> Controller.Hardware.Keyboard[name] +// eg: ('Page Up') === 'PgUp' +// eg: ('LEFT') === 'Left' +function eventKeyText2KeyboardName(text) { + if (eventKeyText2KeyboardName[text]) { + // use memoized value + return eventKeyText2KeyboardName[text]; + } + var keyboardName = (text||'').toUpperCase().split(/[ _]/).map(function(WORD) { + return WORD.replace(/([A-Z])(\w*)/g, function(_, A, b) { + return (A.toUpperCase() + b.toLowerCase()); + }); + }).join('').replace('Page','Pg'); + return eventKeyText2KeyboardName[text] = eventKeyText2KeyboardName[keyboardName] = keyboardName; +} + +// maps a Controller.Hardware.Keyboard[name] -> Controller.key*Event event.text +// eg: ('PgUp') === 'PAGE UP' +// eg: ('Shift') === 'SHIFT' +function keyboardName2eventKeyText(keyName) { + if (keyboardName2eventKeyText[keyName]) { + // use memoized value + return keyboardName2eventKeyText[keyName]; + } + var text = keyName.replace('Pg', 'Page'); + var caseWords = text.match(/[A-Z][a-z0-9]+/g) || [ text ]; + var eventText = caseWords.map(function(str) { + return str.toUpperCase(); + }).join('_'); + return keyboardName2eventKeyText[keyName] = eventText; +} + +function initializeMappings(actionMap, driveKeyMap) { + _debugPrint('creating mapping'); + var ref = ACTION_TRANSLATE_CAMERA_Z; + actionMap[ref.actionName] = actionMap[ref.actionID] = ref.driveKeyName; + actionMap.BoomIn = 'ZOOM'; + actionMap.BoomOut = 'ZOOM'; + + Controller.getActionNames().sort().reduce( + function(out, name, index, arr) { + var actionKey = arr[index]; + var actionID = Controller.findAction(name); + var value = actionID in out ? out[actionID] : _translateActionName(name, index); + if (value !== undefined) { + var prefix = (actionID in out ? '+++' : '---'); + debugPrint(prefix + ' Action2DriveKeyName['+name+'('+actionID+')] = ' + value); + driveKeyMap[value] = driveKeyMap[value] || name; + } + out[name] = out[actionID] = out[actionKey] = value; + return out; + }, actionMap); +} + +// ---------------------------------------------------------------------------- +// mocks for litmus testing using Node.js command line tools +function _defineNodeJSMocks() { + /* eslint-disable no-global-assign */ + DriveKeys = { + TRANSLATE_X: 12345 + }; + Controller = { + getActionNames: function() { + return Object.keys(this.Hardware.Actions); + }, + findAction: function(name) { + return this.Hardware.Actions[name] || 4095; + }, + Hardware: { + Actions: { + TranslateX: 54321 + }, + Application: { + Grounded: 1 + }, + Keyboard: { + A: 65 + } + } + }; + /* eslint-enable no-global-assign */ +} diff --git a/unpublishedScripts/marketplace/camera-move/modules/_utils.js b/unpublishedScripts/marketplace/camera-move/modules/_utils.js new file mode 100644 index 0000000000..5d04912e69 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/_utils.js @@ -0,0 +1,520 @@ +// _utils.js -- misc. helper classes/functions + +"use strict"; +/* eslint-env commonjs, hifi */ + +//var HIRES_CLOCK = (typeof Window === 'object' && Window && Window.performance) && Window.performance.now; +var USE_HIRES_CLOCK = typeof HIRES_CLOCK === 'function'; + +var exports = { + version: '0.0.1b' + (USE_HIRES_CLOCK ? '-hires' : ''), + bind: bind, + signal: signal, + assign: assign, + sign: sign, + assert: assert, + getSystemMetadata: getSystemMetadata, + makeDebugRequire: makeDebugRequire, + DeferredUpdater: DeferredUpdater, + KeyListener: KeyListener, + getRuntimeSeconds: getRuntimeSeconds, + createAnimationStepper: createAnimationStepper, + reloadClientScript: reloadClientScript, + + normalizeStackTrace: normalizeStackTrace, + BrowserUtils: BrowserUtils, + BSOD: BSOD, // exception reporter +}; +try { + module.exports = exports; // Interface / Node.js +} catch(e) { + this._utils = assign(this._utils || {}, exports); // browser +} + +// ---------------------------------------------------------------------------- +function makeDebugRequire(relativeTo) { + return function boundDebugRequire(id) { + return debugRequire(id, relativeTo); + }; +} +function debugRequire(id, relativeTo) { + // hack-around for use during local development / testing that forces every require to re-fetch the script from the server + var modulePath = Script._requireResolve(id, relativeTo) + '?' + new Date().getTime().toString(36); + print('========== DEBUGREQUIRE:' + modulePath); + Script.require.cache[modulePath] = Script.require.cache[id] = undefined; + Script.require.__qt_data__[modulePath] = Script.require.__qt_data__[id] = true; + return Script.require(modulePath); +} + +function assert(truthy, message) { + message = message || 'Assertion Failed:'; + + if (typeof truthy === 'function' && truthy.name === 'assertion') { + message += ' ' + JSON.stringify((truthy+'').replace(/^[^{]+\{|\}$|^\s*|\s*$/g, '')); + try { + truthy = truthy(); + } catch (e) { + message += '(exception: ' + e +')'; + } + } + if (!truthy) { + message += ' (! '+truthy+')'; + throw new Error(message); + } + return truthy; +} + + +// ---------------------------------------------------------------------------- +function sign(x) { + x = +x; + if (x === 0 || isNaN(x)) { + return Number(x); + } + return x > 0 ? 1 : -1; +} +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill +/* eslint-disable */ +function assign(target, varArgs) { // .length of function is 2 + 'use strict'; + if (target == null) { // TypeError if undefined or null + throw new TypeError('Cannot convert undefined or null to object'); + } + + var to = Object(target); + + for (var index = 1; index < arguments.length; index++) { + var nextSource = arguments[index]; + + if (nextSource != null) { // Skip over if undefined or null + for (var nextKey in nextSource) { + // Avoid bugs when hasOwnProperty is shadowed + if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { + to[nextKey] = nextSource[nextKey]; + } + } + } + } + return to; +} +/* eslint-enable */ +// //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill + +// ---------------------------------------------------------------------------- +// @function - bind a function to a `this` context +// @param {Object} - the `this` context +// @param {Function|String} - function or method name +bind.debug = true; +function bind(thiz, method) { + var methodName = typeof method === 'string' ? method : method.name; + method = thiz[method] || method; + if (bind.debug && methodName) { + methodName = methodName.replace(/[^A-Za-z0-9_$]/g, '_'); + var debug = {}; + debug[methodName] = method; + return eval('1,function bound'+methodName+'() { return debug.'+methodName+'.apply(thiz, arguments); }'); + } + return function() { + return method.apply(thiz, arguments); + }; +} + +// @function - Qt signal polyfill +function signal(template) { + var callbacks = []; + return Object.defineProperties(function() { + var args = [].slice.call(arguments); + callbacks.forEach(function(obj) { + obj.handler.apply(obj.scope, args); + }); + }, { + connect: { value: function(scope, handler) { + callbacks.push({scope: scope, handler: scope[handler] || handler || scope}); + }}, + disconnect: { value: function(scope, handler) { + var match = {scope: scope, handler: scope[handler] || handler || scope}; + callbacks = callbacks.filter(function(obj) { + return !(obj.scope === match.scope && obj.handler === match.handler); + }); + }} + }); +} +// ---------------------------------------------------------------------------- +function normalizeStackTrace(err, options) { + options = options || {}; + // * Chromium: " at <anonymous> (file://.../filename.js:45:65)" + // * Interface: " at <anonymous() file://.../filename.js:45" + // * normalized: " at <anonymous> filename.js:45" + var output = err.stack ? err.stack.replace( + /((?:https?|file):[/][/].*?):(\d+)(?::\d+)?([)]|\s|$)/g, + function(_, url, lineNumber, suffix) { + var fileref = url.split(/[?#]/)[0].split('/').pop(); + if (Array.isArray(options.wrapFilesWith)) { + fileref = options.wrapFilesWith[0] + fileref + options.wrapFilesWith[1]; + } + if (Array.isArray(options.wrapLineNumbersWith)) { + lineNumber = options.wrapLineNumbersWith[0] + lineNumber + options.wrapLineNumbersWith[1]; + } + return fileref + ':' + lineNumber + suffix; + } + ).replace(/[(]([-\w.%:]+[.](?:html|js))[)]/g, '$1') : err.message; + return ' '+output; +} + +// utilities specific to use from web browsers / embedded Interface web windows +function BrowserUtils(global) { + global = global || (1,eval)('this'); + return { + global: global, + console: global.console, + log: function(msg) { + this.console.log.apply(this.console, ['browserUtils | ' + msg].concat([].slice.call(arguments, 1))); + }, + makeConsoleWorkRight: function(console, forcePatching) { + if (console.$patched || !(forcePatching || global.qt)) { + return console; + } + var patched = ['log','debug','info','warn','error'].reduce(function(output, method) { + output[method] = function() { + return console[method]([].slice.call(arguments).join(' ')); + }; + return output; + }, { $patched: console }); + for (var p in console) { + if (typeof console[p] === 'function' && !(p in patched)) { + patched[p] = console[p].bind(console); + } + } + patched.__proto__ = console; // let scope chain find constants and other non-function values + return patched; + }, + parseQueryParams: function(querystring) { + return this.extendWithQueryParams({}, querystring); + }, + extendWithQueryParams: function(obj, querystring) { + querystring = querystring || global.location.href; + querystring.replace(/\b(\w+)=([^&?#]+)/g, function(_, key, value) { + value = unescape(value); + obj[key] = value; + }); + return obj; + }, + // openEventBridge handles the cluster of scenarios Interface has imposed on webviews for making EventBridge connections + openEventBridge: function openEventBridge(callback) { + this.log('openEventBridge |', 'typeof global.EventBridge == ' + typeof global.EventBridge); + try { + global.EventBridge.scriptEventReceived.connect.exists; + //this.log('openEventBridge| EventBridge already exists... -- invoking callback', 'typeof EventBridge == ' + typeof global.EventBridge); + return callback(global.EventBridge); + } catch(e) { + this.log('EventBridge does not yet exist -- attempting to instrument via qt.webChannelTransport'); + var QWebChannel = assert(global.QWebChannel, 'expected global.QWebChannel to exist'), + qt = assert(global.qt, 'expected global.qt to exist'), + webChannelTransport = assert(qt.webChannelTransport, 'expected global.qt.webChannelTransport to exist'); + new QWebChannel(qt.webChannelTransport, bind(this, function (channel) { + global.EventBridge = channel.objects.eventBridgeWrapper.eventBridge; + global.EventBridge.$WebChannel = channel; + this.log('openEventBridge opened -- invoking callback', 'typeof EventBridge === ' + typeof global.EventBridge); + callback(global.EventBridge); + })); + } + }, + }; +} +// ---------------------------------------------------------------------------- +// queue pending updates so the exact order of application can be varied +// (currently Interface exists sporadic jitter that seems to depend on whether +// Camera or MyAvatar gets updated first) +function DeferredUpdater(target, options) { + options = options || {}; + // define _meta as a non-enumerable instance property (so it doesn't show up in for(var p in ...) loops) + Object.defineProperty(this, '_meta', { value: { + target: target, + lastValue: {}, + dedupe: options.dedupe, + }}); +} +DeferredUpdater.prototype = { + reset: function() { + var self = this; + Object.keys(this).forEach(function(property) { + delete self[property]; + }); + this._meta.lastValue = {}; + }, + submit: function() { + var meta = this._meta, + target = meta.target, + lastValue = meta.lastValue, + dedupe = meta.dedupe, + self = this, + submitted = {}; + + self.submit = getRuntimeSeconds(); + Object.keys(self).forEach(function(property) { + var newValue = self[property]; + if (0 && dedupe) { + var stringified = JSON.stringify(newValue); + var last = lastValue[property]; + if (stringified === last) { + return; + } + lastValue[property] = stringified; + } + if (0) { + var tmp = lastValue['_'+property]; + if (typeof tmp === 'object') { + if ('w' in tmp) { + newValue = Quat.normalize(Quat.slerp(tmp, newValue, 0.95)); + } else if ('z' in tmp) { + newValue = Vec3.mix(tmp, newValue, 0.95); + } + } else if (typeof tmp === 'number') { + newValue = (newValue + tmp)/2.0; + } + lastValue['_'+property] = newValue; + } + submitted[property] = newValue; + if (typeof target[property] === 'function') { + target[property](newValue); + } else { + target[property] = newValue; + } + delete self[property]; + }); + return submitted; + } +}; +// create a group of deferred updaters eg: DeferredUpdater.createGroup({ MyAvatar: MyAvatar, Camera: Camera }) +DeferredUpdater.createGroup = function(items, options) { + var result = { + __proto__: { + reset: function() { + Object.keys(this).forEach(bind(this, function(item) { + this[item].reset(); + })); + }, + submit: function() { + var submitted = {}; + Object.keys(this).forEach(bind(this, function(item) { + submitted[item] = this[item].submit(); + })); + return submitted; + } + } + }; + Object.keys(items).forEach(function(item) { + result[item] = new DeferredUpdater(items[item], options); + }); + return result; +}; + +// ---------------------------------------------------------------------------- + +// monotonic session runtime (in seconds) +getRuntimeSeconds.EPOCH = getRuntimeSeconds(0); +function getRuntimeSeconds(since) { + since = since === undefined ? getRuntimeSeconds.EPOCH : since; + var now = USE_HIRES_CLOCK ? HIRES_CLOCK() : +new Date; + return ((now / 1000.0) - since); +} + + +function createAnimationStepper(options) { + options = options || {}; + var fps = options.fps || 30, + waitMs = 1000 / fps, + getTime = options.getRuntimeSeconds || getRuntimeSeconds, + lastUpdateTime = -1e-6, + timeout = 0; + + requestAnimationFrame.fps = fps; + requestAnimationFrame.reset = function() { + if (timeout) { + Script.clearTimeout(timeout); + timeout = 0; + } + }; + + function requestAnimationFrame(update) { + requestAnimationFrame.reset(); + timeout = Script.setTimeout(function() { + timeout = 0; + update(getTime(lastUpdateTime)); + lastUpdateTime = getTime(); + }, waitMs ); + } + + return requestAnimationFrame; +} + +// ---------------------------------------------------------------------------- +// KeyListener provides a scoped wrapper where options.onKeyPressEvent only gets +// called when the specified event.text matches the input options +// example: var listener = new KeyListener({ text: 'SPACE', isShifted: false, onKeyPressEvent: function(event) { ... } }); +// Script.scriptEnding.connect(listener, 'disconnect'); +function KeyListener(options) { + assert(typeof options === 'object' && 'text' in options && 'onKeyPressEvent' in options); + + this._options = options; + assign(this, { + modifiers: this._getEventModifiers(options, true) + }, options); + log('created KeyListener', JSON.stringify(this, 0, 2)); + this.connect(); +} +KeyListener.prototype = { + _getEventModifiers: function(event, trueOnly) { + return [ 'Control', 'Meta', 'Alt', 'Super', 'Menu', 'Shifted' ].map(function(mod) { + var isMod = 'is' + mod, + value = event[isMod], + found = (trueOnly ? value : typeof value === 'boolean'); + return found && isMod + ' = ' + value; + }).filter(Boolean).sort().join('|'); + }, + handleEvent: function(event, target) { + if (event.text === this.text) { + var modifiers = this._getEventModifiers(event, true); + if (modifiers !== this.modifiers) { + return log('KeyListener -- different modifiers, disregarding keystroke', JSON.stringify({ + expected: this.modifiers, + received: modifiers, + },0,2)); + } + return this[target](event); + } + }, + connect: function() { + return this.$bindEvents(true); + }, + disconnect: function() { + return this.$bindEvents(false); + }, + $onKeyPressEvent: function(event) { + return this.handleEvent(event, 'onKeyPressEvent'); + }, + $onKeyReleaseEvent: function(event) { + return this.handleEvent(event, 'onKeyReleaseEvent'); + }, + $bindEvents: function(connect) { + if (this.onKeyPressEvent) { + Controller.keyPressEvent[connect ? 'connect' : 'disconnect'](this, '$onKeyPressEvent'); + } + if (this.onKeyReleaseEvent) { + Controller.keyReleaseEvent[connect ? 'connect' : 'disconnect'](this, '$onKeyReleaseEvent'); + } + Controller[(connect ? 'capture' : 'release') + 'KeyEvents'](this._options); + } +}; + + +// helper to show a verbose exception report in a BSOD-like poup window, in some cases enabling users to +// report specfic feedback without having to manually scan through their local debug logs + +// creates an OverlayWebWindow using inline HTML content +function _overlayWebWindow(options) { + options = Object.assign({ + title: '_overlayWebWindow', + width: Overlays.width() * 2 / 3, + height: Overlays.height() * 2 / 3, + content: '(empty)', + }, options||{}, { + source: 'about:blank', + }); + var window = new OverlayWebWindow(options); + window.options = options; + options.content && window.setURL('data:text/html;text,' + encodeURIComponent(options.content)); + return window; +} + +function BSOD(options, callback) { + var buttonHTML = Array.isArray(options.buttons) && [ + '<div onclick="EventBridge.emitWebEvent(arguments[0].target.innerText)">', + options.buttons.map(function(innerText) { + return '<button>' + innerText + '</button>'; + }).join(', '), + '</div>'].join('\n'); + + var HTML = [ + '<style>body { background:#0000aa; color:#ffffff; font-family:courier; font-size:8pt; margin:10px; }</style>', + buttonHTML, + '<pre style=whitespace:pre-wrap>', + '<strong>' + options.error + '</strong>', + _utils.normalizeStackTrace(options.error, { + wrapLineNumbersWith: ['<b style=color:lime>','</b>'], + wrapFilesWith: ['<b style=color:#f99>','</b>'], + }), + '</pre>', + '<hr />DEBUG INFO:<br />', + '<pre>' + JSON.stringify(Object.assign({ date: new Date }, options.debugInfo), 0, 2) + '</pre>', + ].filter(Boolean).join('\n'); + + var popup = _overlayWebWindow({ + title: options.title || 'PC LOAD LETTER', + content: HTML, + }); + popup.webEventReceived.connect(function(message) { + log('popup.webEventReceived', message); + try { + callback(null, message); + } finally { + popup.close(); + } + }); + return popup; +} + +function getSystemMetadata() { + var tablet = Tablet.getTablet('com.highfidelity.interface.tablet.system'); + return { + mode: { + hmd: HMD.active, + desktop: !HMD.active, + toolbar: Uuid.isNull(HMD.tabletID), + tablet: !Uuid.isNull(HMD.tabletID), + }, + tablet: { + toolbarMode: tablet.toolbarMode, + desktopScale: Settings.getValue('desktopTabletScale'), + hmdScale: Settings.getValue('hmdTabletScale'), + }, + avatar: { + pitchSpeed: MyAvatar.pitchSpeed, + yawSpeed: MyAvatar.yawSpeed, + density: MyAvatar.density, + scale: MyAvatar.scale, + }, + overlays: { + width: Overlays.width(), + height: Overlays.height(), + }, + window: { + width: Window.innerWidth, + height: Window.innerHeight, + }, + desktop: { + width: Desktop.width, + height: Desktop.height, + }, + }; +} + +function reloadClientScript(filename) { + log('reloading', filename); + var result = ScriptDiscoveryService.stopScript(filename, true); + log('...stopScript', filename, result); + if (!result) { + var matches = ScriptDiscoveryService.getRunning().filter(function(script) { + //log(script.path, script.url); + return 0 === script.path.indexOf(filename); + }); + log('...matches', JSON.stringify(matches,0,2)); + var path = matches[0] && matches[0].path; + if (path) { + log('...stopScript', path); + result = ScriptDiscoveryService.stopScript(path, true); + log('///stopScript', result); + } + } + return result; +} diff --git a/unpublishedScripts/marketplace/camera-move/modules/config-utils.js b/unpublishedScripts/marketplace/camera-move/modules/config-utils.js new file mode 100644 index 0000000000..0f19884388 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/config-utils.js @@ -0,0 +1,265 @@ +// config-utils.js -- helpers for coordinating Application runtime vs. Settings configuration +// +// * ApplicationConfig -- provides a way to configure and monitor Menu items and API values. +// * SettingsConfig -- provides a similar way to configure and monitor scoped Settings values. +// * ... together they provide a way to manage overlapping values and keep them in sync. + +"use strict"; +/* eslint-env commonjs */ + +module.exports = { + version: '0.0.1a', + ApplicationConfig: ApplicationConfig, + SettingsConfig: SettingsConfig +}; + +var _utils = require('./_utils.js'), + assert = _utils.assert; +Object.assign = Object.assign || _utils.assign; + +function _debugPrint() { + print('config-utils | ' + [].slice.call(arguments).join(' ')); +} + +var debugPrint = function() {}; + +// ---------------------------------------------------------------------------- +// grouped Application-specific configuration values using runtime state / API props +// +// options.config[] supports the following item formats: +// 'settingsName': { menu: 'Menu > MenuItem'}, // assumes MenuItem is a checkbox / checkable value +// 'settingsName': { object: [ MyAvatar, 'property' ] }, +// 'settingsName': { object: [ MyAvatar, 'getterMethod', 'setterMethod' ] }, +// 'settingsName': { menu: 'Menu > MenuItem', object: [ MyAvatar, 'property' ] }, + +function ApplicationConfig(options) { + options = options || {}; + assert('namespace' in options && 'config' in options); + if (options.debug) { + debugPrint = _debugPrint; + debugPrint('debugPrinting enabled'); + } + this.namespace = options.namespace; + this.valueUpdated = _utils.signal(function valueUpdated(key, newValue, oldValue, origin){}); + + // process shorthand notations into fully-qualfied ApplicationConfigItem instances + this.config = {}; + this.register(options.config); +} +ApplicationConfig.prototype = { + resolve: function(key) { + assert(typeof key === 'string', 'ApplicationConfig.resolve error: key is not a string: ' + key); + if (0 !== key.indexOf('.') && !~key.indexOf('/')) { + key = [ this.namespace, key ].join('/'); + } + if (key in this.config) { + return key; + } + log('ApplicationConfig -- could not resolve key: ' + key); + return undefined; + }, + registerItem: function(settingName, item) { + item._settingName = settingName; + item.settingName = ~settingName.indexOf('/') ? settingName : [ this.namespace, settingName ].join('/'); + return this.config[item.settingName] = this.config[settingName] = new ApplicationConfigItem(item); + }, + register: function(items) { + for (var p in items) { + var item = items[p]; + if (item) { + this.registerItem(p, item) + } + } + }, + _getItem: function(key) { + return this.config[this.resolve(key)]; + }, + getValue: function(key, defaultValue) { + var item = this._getItem(key); + if (!item) { + return defaultValue; + } + return item.get(); + }, + setValue: function setValue(key, value) { + key = this.resolve(key); + var lastValue = this.getValue(key, value); + var ret = this._getItem(key).set(value); + if (lastValue !== value) { + this.valueUpdated(key, value, lastValue, 'ApplicationConfig.setValue'); + } + return ret; + }, + // sync dual-source (ie: Menu + API) items + resyncValue: function(key) { + var item = this._getItem(key); + return item && item.resync(); + }, + // sync Settings values -> Application state + applyValue: function applyValue(key, value, origin) { + if (this.resolve(key)) { + var appValue = this.getValue(key, value); + log('applyValue', key, value, origin ? '['+origin+']' : '', appValue); + if (appValue !== value) { + this.setValue(key, value); + log('applied new setting', key, value, '(was:'+appValue+')'); + } + return true; + } + } +}; + +// ApplicationConfigItem represents a single API/Menu item accessor +function ApplicationConfigItem(item) { + Object.assign(this, item, { _item: item }); + Object.assign(this, { + _menu: this._parseMenuConfig(this.menu), + _object: this._parseObjectConfig(this.object) + }); + if(0)debugPrint('>>>>>' + this); +} +ApplicationConfigItem.prototype = { + authority: 'object', // when values conflict, this determines which source is considered the truth + resync: function resync() { + var authoritativeValue = this.get(); + + if (this._menu && this._menu.get() !== authoritativeValue) { + _debugPrint(this.settingName, this._menu.menuItem, + '... menu value ('+this._menu.get()+') out of sync;', + 'setting to authoritativeValue ('+authoritativeValue+')'); + this._menu.set(authoritativeValue); + } + if (this._object && this._object.get() !== authoritativeValue) { + _debugPrint(this.settingName, this._object.getter || this._object.property, + '... object value ('+this._object.get()+') out of sync;', + 'setting to authoritativeValue ('+authoritativeValue+')'); + this._object.set(authoritativeValue); + } + }, + toString: function() { + return '[ApplicationConfigItem ' + [ + 'setting:' + JSON.stringify(this.settingName), + this.authority !== ApplicationConfigItem.prototype.authority && 'authority:' + JSON.stringify(this.authority), + this._object && 'object:' + JSON.stringify(this._object.property || this._object.getter), + this._menu && 'menu:' + JSON.stringify(this._menu.menu) + // 'value:' + this.get(), + ].filter(Boolean).join(' ') + ']'; + }, + get: function get() { + return this.authority === 'menu' ? this._menu.get() : this._object.get(); + }, + set: function set(nv) { + this._object && this._object.set(nv); + this._menu && this._menu.set(nv); + return nv; + }, + _raiseError: function(errorMessage) { + if (this.debug) { + throw new Error(errorMessage); + } else { + _debugPrint('ERROR: ' + errorMessage); + } + }, + _parseObjectConfig: function(parts) { + if (!Array.isArray(parts) || parts.length < 2) { + return null; + } + var object = parts[0], getter = parts[1], setter = parts[2]; + if (typeof object[getter] === 'function' && typeof object[setter] === 'function') { + // [ API, 'getter', 'setter' ] + return { + object: object, getter: getter, setter: setter, + get: function getObjectValue() { + return this.object[this.getter](); + }, + set: function setObjectValue(nv) { + return this.object[this.setter](nv), nv; + } + }; + } else if (getter in object) { + // [ API, 'property' ] + return { + object: object, property: getter, + get: function() { + //log('======> get API, property', object, getter, this.object[this.property]); + return this.object[this.property]; + }, + set: function(nv) { + //log('======> set API, property', object, getter, this.object[this.property], nv); + return this.object[this.property] = nv; + } + }; + } + + this._raiseError('{ object: [ Object, getterOrPropertyName, setterName ] } -- invalid params or does not exist: ' + + [ this.settingName, this.object, getter, setter ].join(' | ')); + }, + _parseMenuConfig: function(menu) { + if (!menu || typeof menu !== 'string') { + return null; + } + var parts = menu.split(/\s*>\s*/), menuItemName = parts.pop(), menuName = parts.join(' > '); + if (menuItemName && Menu.menuItemExists(menuName, menuItemName)) { + return { + menu: menu, menuName: menuName, menuItemName: menuItemName, + get: function() { + return Menu.isOptionChecked(this.menuItemName); + }, + set: function(nv) { + return Menu.setIsOptionChecked(this.menuItemName, nv), nv; + } + }; + } + this._raiseError('{ menu: "Menu > Item" } structure -- invalid params or does not exist: ' + + [ this.settingName, this.menu, menuName, menuItemName ].join(' | ')); + } +}; // ApplicationConfigItem.prototype + +// ---------------------------------------------------------------------------- +// grouped configuration using the Settings.* API abstraction +function SettingsConfig(options) { + options = options || {}; + assert('namespace' in options); + this.namespace = options.namespace; + this.defaultValues = {}; + this.valueUpdated = _utils.signal(function valueUpdated(key, newValue, oldValue, origin){}); + if (options.defaultValues) { + Object.keys(options.defaultValues) + .forEach(_utils.bind(this, function(key) { + var fullSettingsKey = this.resolve(key); + this.defaultValues[fullSettingsKey] = options.defaultValues[key]; + })); + } +} +SettingsConfig.prototype = { + resolve: function(key) { + assert(typeof key === 'string', 'SettingsConfig.resolve error: key is not a string: ' + key); + if (0 !== key.indexOf('.') && !~key.indexOf('/')) { + return [ this.namespace, key ].join('/'); + } else { + return key; + } + }, + getValue: function(key, defaultValue) { + key = this.resolve(key); + defaultValue = defaultValue === undefined ? this.defaultValues[key] : defaultValue; + return Settings.getValue(key, defaultValue); + }, + setValue: function setValue(key, value) { + key = this.resolve(key); + var lastValue = this.getValue(key); + var ret = Settings.setValue(key, value); + if (lastValue !== value) { + this.valueUpdated(key, value, lastValue, 'SettingsConfig.setValue'); + } + return ret; + }, + getFloat: function getFloat(key, defaultValue) { + key = this.resolve(key); + defaultValue = defaultValue === undefined ? this.defaultValues[key] : defaultValue; + var value = parseFloat(this.getValue(key, defaultValue)); + return isFinite(value) ? value : isFinite(defaultValue) ? defaultValue : 0.0; + } +}; + +// ---------------------------------------------------------------------------- diff --git a/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/CustomSettingsApp.js b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/CustomSettingsApp.js new file mode 100644 index 0000000000..549d60b887 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/CustomSettingsApp.js @@ -0,0 +1,339 @@ +// CustomSettingsApp.js -- manages Settings between a Client script and connected "settings" tablet WebView page +// see html.bridgedSettings.js for the webView side + +// example: +// var button = tablet.addButton({ text: 'My Settings' }); +// var mySettingsApp = new CustomSettingsApp({ +// namespace: 'mySettingsGroup', +// url: Script.resolvePath('myapp.html'), +// uuid: button.uuid, +// tablet: tablet +// }); +// +// // settings are automatically sync'd from the web page back to Interface; to be notified when that happens, use: +// myAppSettings.settingUpdated.connect(function(name, value, origin) { +// print('setting updated from web page', name, value, origin); +// }); +// +// // settings are also automatically sync'd from Interface back to the web page; to manually sync a value, use: +// myAppSettings.syncValue(fullSettingsKey, value); + +/* eslint-env commonjs */ +"use strict"; + +CustomSettingsApp.version = '0.0.0'; +module.exports = CustomSettingsApp; + +var _utils = require('../_utils.js'); +Object.assign = Object.assign || _utils.assign; + +function assert(truthy, message) { + return _utils.assert.call(this, truthy, 'CustomSettingsApp | ' + message); +} + +function _debugPrint() { + print('CustomSettingsApp | ' + [].slice.call(arguments).join(' ')); +} +var debugPrint = function() {}; + +function CustomSettingsApp(options) { + assert('url' in options, 'expected options.url'); + if (options.debug) { + debugPrint = _debugPrint; + } + + this.url = options.url; + this.namespace = options.namespace || 'BridgedSettings'; + this.uuid = options.uuid || Uuid.generate(); + this.recheckInterval = options.recheckInterval || 1000; + + this.settingsScreenVisible = false; + this.isActive = false; + + this.extraParams = Object.assign(options.extraParams || {}, { + customSettingsVersion: CustomSettingsApp.version+'', + protocolVersion: location.protocolVersion && location.protocolVersion() + }); + + var params = { + namespace: this.namespace, + uuid: this.uuid, + debug: options.debug || undefined + }; + + // encode PARAMS into '?key=value&...' + var query = Object.keys(params).map(function encodeValue(key) { + var value = encodeURIComponent(params[key] === undefined ? '' : params[key]); + return [ key, value ].join('='); + }).join('&'); + this.url += '?&' + query; + + this.isActiveChanged = _utils.signal(function(isActive) {}); + this.valueUpdated = _utils.signal(function(key, value, oldValue, origin) {}); + + this.settingsAPI = options.settingsAPI || Settings; + + // keep track of accessed settings so they can be kept in sync when changed on this side + this._activeSettings = { + sent: {}, + received: {}, + remote: {}, + get: function(key) { + return { + sent: this.sent[key], + received: this.received[key], + remote: this.remote[key] + }; + } + }; + + if (options.tablet) { + this._initialize(options.tablet); + } +} + +CustomSettingsApp.prototype = { + tablet: null, + resolve: function(key) { + if (0 === key.indexOf('.') || ~key.indexOf('/')) { + // key is already qualified under a group; return as-is + return key; + } + // nest under the current namespace + return [ this.namespace, key ].join('/'); + }, + sendEvent: function(msg) { + assert(this.tablet, '!this.tablet'); + msg.ns = msg.ns || this.namespace; + msg.uuid = msg.uuid || this.uuid; + this.tablet.emitScriptEvent(JSON.stringify(msg)); + }, + getValue: function(key, defaultValue) { + key = this.resolve(key); + return key in this._activeSettings.remote ? this._activeSettings.remote[key] : defaultValue; + }, + setValue: function(key, value) { + key = this.resolve(key); + var current = this.getValue(key); + if (current !== value) { + return this.syncValue(key, value, 'CustomSettingsApp.setValue'); + } + return false; + }, + syncValue: function(key, value, origin) { + key = this.resolve(key); + var oldValue = this._activeSettings.remote[key]; + assert(value !== null, 'CustomSettingsApp.syncValue value is null'); + this.sendEvent({ id: 'valueUpdated', params: [key, value, oldValue, origin] }); + this._activeSettings.sent[key] = value; + this._activeSettings.remote[key] = value; + this.valueUpdated(key, value, oldValue, (origin ? origin+':' : '') + 'CustomSettingsApp.syncValue'); + }, + onScreenChanged: function onScreenChanged(type, url) { + this.settingsScreenVisible = (url === this.url); + debugPrint('===> onScreenChanged', type, url, 'settingsScreenVisible: ' + this.settingsScreenVisible); + if (this.isActive && !this.settingsScreenVisible) { + this.isActiveChanged(this.isActive = false); + } + }, + + _setValue: function(key, value, oldValue, origin) { + var current = this.settingsAPI.getValue(key), + lastRemoteValue = this._activeSettings.remote[key]; + debugPrint('.setValue(' + JSON.stringify({key: key, value: value, current: current, lastRemoteValue: lastRemoteValue })+')'); + this._activeSettings.received[key] = value; + this._activeSettings.remote[key] = value; + var result; + if (lastRemoteValue !== value) { + this.valueUpdated(key, value, lastRemoteValue, 'CustomSettingsApp.tablet'); + } + if (current !== value) { + result = this.settingsAPI.setValue(key, value); + } + return result; + }, + + _handleValidatedMessage: function(obj, msg) { + var tablet = this.tablet; + if (!tablet) { + throw new Error('_handleValidatedMessage called when not connected to tablet...'); + } + var params = Array.isArray(obj.params) ? obj.params : [obj.params]; + var parts = (obj.method||'').split('.'), api = parts[0], method = parts[1]; + if (api === 'valueUpdated') { + obj.result = this._setValue.apply(this, params); + } else if (api === 'Settings' && method && params[0]) { + var key = this.resolve(params[0]), value = params[1]; + debugPrint('>>>>', method, key, value); + switch (method) { + case 'getValue': { + obj.result = this.settingsAPI.getValue(key, value); + this._activeSettings.sent[key] = obj.result; + this._activeSettings.remote[key] = obj.result; + break; + } + case 'setValue': { + obj.result = this._setValue(key, value, params[2], params[3]); + break; + } + default: { + obj.error = 'unmapped Settings method: ' + method; + throw new Error(obj.error); + } + } + } else { + if (this.onUnhandledMessage) { + this.onUnhandledMessage(obj, msg); + } else { + obj.error = 'unmapped method call: ' + msg; + } + } + if (obj.id) { + // if message has an id, reply with the same message obj which now has a .result or .error field + // note: a small delay is needed because of an apparent race condition between ScriptEngine and Tablet WebViews + Script.setTimeout(_utils.bind(this, function() { + debugPrint(obj.id, '########', JSON.stringify(obj)); + this.sendEvent(obj); + }), 100); + } else if (obj.error) { + throw new Error(obj.error); + } + }, + onWebEventReceived: function onWebEventReceived(msg) { + var tablet = this.tablet; + if (!tablet) { + throw new Error('onWebEventReceived called when not connected to tablet...'); + } + if (msg === this.url) { + this.isActiveChanged(this.isActive = true); + // reply to initial HTML page ACK with any extraParams that were specified + this.sendEvent({ id: 'extraParams', extraParams: this.extraParams }); + return; + } + try { + var obj = JSON.parse(msg); + var validSender = obj.ns === this.namespace && obj.uuid === this.uuid; + if (validSender) { + this._handleValidatedMessage(obj, msg); + } else { + debugPrint('skipping', JSON.stringify([obj.ns, obj.uuid]), JSON.stringify(this), msg); + } + } catch (e) { + _debugPrint('rpc error:', e, msg); + } + }, + + _initialize: function(tablet) { + if (this.tablet) { + throw new Error('CustomSettingsApp._initialize called but this.tablet already has a value'); + } + this.tablet = tablet; + tablet.webEventReceived.connect(this, 'onWebEventReceived'); + tablet.screenChanged.connect(this, 'onScreenChanged'); + + this.onAPIValueUpdated = function(key, newValue, oldValue, origin) { + if (this._activeSettings.remote[key] !== newValue) { + _debugPrint( + '[onAPIValueUpdated @ ' + origin + ']', + key + ' = ' + JSON.stringify(newValue), '(was: ' + JSON.stringify(oldValue) +')', + JSON.stringify(this._activeSettings.get(key),0,2)); + this.syncValue(key, newValue, (origin ? origin+':' : '') + 'CustomSettingsApp.onAPIValueUpdated'); + } + }; + this.isActiveChanged.connect(this, function(isActive) { + debugPrint('============= CustomSettingsApp... isActiveChanged', JSON.stringify({ + isActive: isActive, + interval: this.interval || 0, + recheckInterval: this.recheckInterval + },0,2)); + this._activeSettings.remote = {}; // reset assumptions about remote values + isActive ? this.$startMonitor() : this.$stopMonitor(); + }); + + debugPrint('CustomSettingsApp...initialized', this.namespace); + }, + + $syncSettings: function() { + for (var p in this._activeSettings.sent) { + var value = this.settingsAPI.getValue(p), + lastValue = this._activeSettings.remote[p]; + if (value !== undefined && value !== null && value !== '' && value !== lastValue) { + _debugPrint('CustomSettingsApp... detected external settings change', JSON.stringify({ + key: p, + lastValueSent: (this._activeSettings.sent[p]+''), + lastValueAssumed: (this._activeSettings.remote[p]+''), + lastValueReceived: (this._activeSettings.received[p]+''), + newValue: (value+'') + })); + this.syncValue(p, value, 'Settings'); + this.valueUpdated(p, value, lastValue, 'CustomSettingsApp.$syncSettings'); + } + } + }, + $startMonitor: function() { + if (!(this.recheckInterval > 0)) { // expressed this way handles -1, NaN, null, undefined etc. + _debugPrint('$startMonitor -- recheckInterval <= 0; not starting settings monitor thread'); + return false; + } + if (this.interval) { + this.$stopMonitor(); + } + if (this.settingsAPI.valueUpdated) { + _debugPrint('settingsAPI supports valueUpdated -- binding to detect settings changes', this.settingsAPI); + this.settingsAPI.valueUpdated.connect(this, 'onAPIValueUpdated'); + } + this.interval = Script.setInterval(_utils.bind(this, '$syncSettings'), this.recheckInterval); + _debugPrint('STARTED MONITORING THREAD'); + }, + $stopMonitor: function() { + if (this.interval) { + Script.clearInterval(this.interval); + this.interval = 0; + if (this.settingsAPI.valueUpdated) { + this.settingsAPI.valueUpdated.disconnect(this, 'onAPIValueUpdated'); + } + _debugPrint('stopped monitoring thread'); + return true; + } + }, + + cleanup: function() { + if (!this.tablet) { + return _debugPrint('CustomSettingsApp...cleanup called when not initialized'); + } + var tablet = this.tablet; + tablet.webEventReceived.disconnect(this, 'onWebEventReceived'); + tablet.screenChanged.disconnect(this, 'onScreenChanged'); + this.$stopMonitor(); + if (this.isActive) { + try { + this.isActiveChanged(this.isActive = false); + } catch (e) { + _debugPrint('ERROR: cleanup error during isActiveChanged(false)', e); + } + } + this.toggle(false); + this.settingsScreenVisible = false; + this.tablet = null; + debugPrint('cleanup completed', this.namespace); + }, + + toggle: function(show) { + if (!this.tablet) { + return _debugPrint('CustomSettingsApp...cleanup called when not initialized'); + } + if (typeof show !== 'boolean') { + show = !this.settingsScreenVisible; + } + + if (this.settingsScreenVisible && !show) { + this.tablet.gotoHomeScreen(); + } else if (!this.settingsScreenVisible && show) { + Script.setTimeout(_utils.bind(this, function() { + // Interface sometimes crashes if not for adding a small timeout here :( + this.tablet.gotoWebScreen(this.url); + }), 1); + } + } +}; + diff --git a/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/BridgedSettings.js b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/BridgedSettings.js new file mode 100644 index 0000000000..169b722228 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/BridgedSettings.js @@ -0,0 +1,207 @@ +// BridgedSettings.js -- HTML-side implementation of bridged/async Settings +// see ../CustomSettingsApp.js for the corresponding Interface script + +/* eslint-env commonjs, browser */ +(function(global) { + "use strict"; + + BridgedSettings.version = '0.0.2'; + + try { + module.exports = BridgedSettings; + } catch (e) { + global.BridgedSettings = BridgedSettings; + } + + var _utils = global._utils || (global.require && global.require('../_utils.js')); + + if (!_utils || !_utils.signal) { + throw new Error('html.BridgedSettings.js -- expected _utils to be available on the global object (ie: window._utils)'); + } + var signal = _utils.signal, + assert = _utils.assert; + + function log() { + console.info('bridgedSettings | ' + [].slice.call(arguments).join(' ')); // eslint-disable-line no-console + } + log('version', BridgedSettings.version); + + var debugPrint = function() {}; // = log + + function BridgedSettings(options) { + options = options || {}; + this.eventBridge = options.eventBridge || global.EventBridge; + this.namespace = options.namespace || 'BridgedSettings'; + this.uuid = options.uuid || undefined; + this.valueUpdated = signal(function valueUpdated(key, newValue, oldValue, origin){}); + this.callbackError = signal(function callbackError(error, message){}); + this.pendingRequestsFinished = signal(function pendingRequestsFinished(){}); + this.extraParams = {}; + + // keep track of accessed settings so they can be kept in sync when changed on this side + this._activeSettings = { + sent: {}, + received: {}, + remote: {}, + }; + + this.debug = options.debug; + this.log = log.bind({}, this.namespace + ' |'); + this.debugPrint = function() { return this.debug && this.log.apply(this, arguments); }; + + this.log('connecting to EventBridge.scriptEventReceived'); + this._boundScriptEventReceived = this.onScriptEventReceived.bind(this); + this.eventBridge.scriptEventReceived.connect(this._boundScriptEventReceived); + + this.callbacks = Object.defineProperties(options.callbacks || {}, { + extraParams: { value: this.handleExtraParams }, + valueUpdated: { value: this.handleValueUpdated }, + }); + } + + BridgedSettings.prototype = { + _callbackId: 1, + resolve: function(key) { + if (0 !== key.indexOf('.') && !~key.indexOf('/')) { + return [ this.namespace, key ].join('/'); + } else { + return key; + } + }, + handleValueUpdated: function(msg) { + // client script notified us that a value was updated on that side + var key = this.resolve(msg.params[0]), + value = msg.params[1], + oldValue = msg.params[2], + origin = msg.params[3]; + log('callbacks.valueUpdated', key, value, oldValue, origin); + this._activeSettings.received[key] = this._activeSettings.remote[key] = value; + this.valueUpdated(key, value, oldValue, (origin?origin+':':'') + 'callbacks.valueUpdated'); + }, + handleExtraParams: function(msg) { + // client script sent us extraParams + var extraParams = msg.extraParams; + Object.assign(this.extraParams, extraParams); + this.debugPrint('received .extraParams', JSON.stringify(extraParams,0,2)); + var key = '.extraParams'; + this._activeSettings.received[key] = this._activeSettings.remote[key] = extraParams; + this.valueUpdated(key, this.extraParams, this.extraParams, 'html.bridgedSettings.handleExtraParams'); + }, + cleanup: function() { + try { + this.eventBridge.scriptEventReceived.disconnect(this._boundScriptEventReceived); + } catch(e) { + this.log('error disconnecting from scriptEventReceived:', e); + } + }, + pendingRequestCount: function() { + return Object.keys(this.callbacks).length; + }, + onScriptEventReceived: function(_msg) { + var error; + this.debugPrint(this.namespace, '_onScriptEventReceived......' + _msg); + try { + var msg = JSON.parse(_msg); + if (msg.ns === this.namespace && msg.uuid === this.uuid) { + this.debugPrint('_onScriptEventReceived', msg); + var callback = this.callbacks[msg.id], + handled = false, + debug = this.debug; + if (callback) { + try { + callback.call(this, msg); + handled = true; + } catch (e) { + error = e; + this.log('CALLBACK ERROR', this.namespace, msg.id, '_onScriptEventReceived', e); + this.callbackError(error, msg); + if (debug) { + throw error; + } + } + } + if (!handled) { + if (this.onUnhandledMessage) { + return this.onUnhandledMessage(msg, _msg); + } else { + error = new Error('unhandled message: ' + _msg); + } + } + } + } catch (e) { error = e; } + if (this.debug && error) { + throw error; + } + return error; + }, + sendEvent: function(msg) { + msg.ns = msg.ns || this.namespace; + msg.uuid = msg.uuid || this.uuid; + this.eventBridge.emitWebEvent(JSON.stringify(msg)); + }, + getValue: function(key, defaultValue) { + key = this.resolve(key); + return key in this._activeSettings.remote ? this._activeSettings.remote[key] : defaultValue; + }, + setValue: function(key, value) { + key = this.resolve(key); + var current = this.getValue(key); + if (current !== value) { + return this.syncValue(key, value, 'setValue'); + } + return false; + }, + syncValue: function(key, value, origin) { + key = this.resolve(key); + var oldValue = this._activeSettings.remote[key]; + this.sendEvent({ method: 'valueUpdated', params: [key, value, oldValue, origin] }); + this._activeSettings.sent[key] = this._activeSettings.remote[key] = value; + this.valueUpdated(key, value, oldValue, (origin ? origin+':' : '') + 'html.bridgedSettings.syncValue'); + }, + getValueAsync: function(key, defaultValue, callback) { + key = this.resolve(key); + if (typeof defaultValue === 'function') { + callback = defaultValue; + defaultValue = undefined; + } + var params = defaultValue !== undefined ? [ key, defaultValue ] : [ key ], + event = { method: 'Settings.getValue', params: params }; + + this.debugPrint('< getValueAsync...', key, params); + if (callback) { + event.id = this._callbackId++; + this.callbacks[event.id] = function(obj) { + try { + callback(obj.error, obj.result); + if (!obj.error) { + this._activeSettings.received[key] = this._activeSettings.remote[key] = obj.result; + } + } finally { + delete this.callbacks[event.id]; + } + this.pendingRequestCount() === 0 && this.pendingRequestsFinished(); + }; + } + this.sendEvent(event); + }, + setValueAsync: function(key, value, callback) { + key = this.resolve(key); + this.log('< setValueAsync', key, value); + var params = [ key, value ], + event = { method: 'Settings.setValue', params: params }; + if (callback) { + event.id = this._callbackId++; + this.callbacks[event.id] = function(obj) { + try { + callback(obj.error, obj.result); + } finally { + delete this.callbacks[event.id]; + } + }; + } + this._activeSettings.sent[key] = this._activeSettings.remote[key] = value; + this.sendEvent(event); + } + }; + +})(this); diff --git a/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/JQuerySettings.js b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/JQuerySettings.js new file mode 100644 index 0000000000..bd31f0abd7 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/JQuerySettings.js @@ -0,0 +1,231 @@ +// JQuerySettings.js -- HTML-side helper class for managing settings-linked jQuery UI elements + +/* eslint-env commonjs, browser */ +(function(global) { + "use strict"; + + JQuerySettings.version = '0.0.0'; + + try { + module.exports = JQuerySettings; + } catch (e) { + global.JQuerySettings= JQuerySettings + } + + var _utils = global._utils || (global.require && global.require('../_utils.js')); + + if (!_utils || !_utils.signal) { + throw new Error('html.BridgedSettings.js -- expected _utils to be available on the global object (ie: window._utils)'); + } + var signal = _utils.signal, + assert = _utils.assert; + + function log() { + console.info('jquerySettings | ' + [].slice.call(arguments).join(' ')); // eslint-disable-line no-console + } + log('version', JQuerySettings.version); + + var debugPrint = function() {}; // = log + + function JQuerySettings(options) { + assert('namespace' in options); + Object.assign(this, { + id2Setting: {}, // DOM id -> qualified Settings key + Setting2id: {}, // qualified Settings key -> DOM id + _activeSettings: { + received: {}, // from DOM elements + sent: {}, // to DOM elements + remote: {}, // MRU values + }, + + }, options); + this.valueUpdated = signal(function valueUpdated(key, value, oldValue, origin){}); + } + JQuerySettings.prototype = { + resolve: function(key) { + if (0 !== key.indexOf('.') && !~key.indexOf('/')) { + return [ this.namespace, key ].join('/'); + } else { + return key; + } + }, + registerSetting: function(id, key) { + this.id2Setting[id] = key; + this.Setting2id[key] = id; + }, + registerNode: function(node) { + var name = node.name || node.id, + key = this.resolve(name); + this.registerSetting(node.id, key); + if (node.type === 'radio') { + // for radio buttons also map the overall radio-group to the key + this.registerSetting(name, key); + } + }, + // lookup the DOM id for a given Settings key + getId: function(key, missingOk) { + key = this.resolve(key); + return assert(this.Setting2id[key] || missingOk || true, 'jquerySettings.getId: !Setting2id['+key+']'); + }, + // lookup the Settings key for a given DOM id + getKey: function(id, missingOk) { + return assert(this.id2Setting[id] || missingOk || true, 'jquerySettings.getKey: !id2Setting['+id+']'); + }, + // lookup the DOM node for a given Settings key + findNodeByKey: function(key, missingOk) { + key = this.resolve(key); + var id = this.getId(key, missingOk); + var node = document.getElementById(id); + if (typeof node !== 'object') { + log('jquerySettings.getNodeByKey -- node not found:', 'key=='+key, 'id=='+id); + } + return node; + }, + _notifyValueUpdated: function(key, value, oldValue, origin) { + this._activeSettings.sent[key] = value; + this._activeSettings.remote[key] = value; + this.valueUpdated(this.resolve(key), value, oldValue, origin); + }, + getValue: function(key, defaultValue) { + key = this.resolve(key); + var node = this.findNodeByKey(key); + if (node) { + var value = this.__getNodeValue(node); + this._activeSettings.remote[key] = value; + this._activeSettings.received[key] = value; + return value; + } + return defaultValue; + }, + setValue: function(key, value, origin) { + key = this.resolve(key); + var lastValue = this.getValue(key, value); + if (lastValue !== value || origin) { + var node = assert(this.findNodeByKey(key), 'jquerySettings.setValue -- node not found: ' + key); + var ret = this.__setNodeValue(node, value); + if (origin) { + this._notifyValueUpdated(key, value, lastValue, origin || 'jquerySettings.setValue'); + } + return ret; + } + }, + resyncValue: function(key, origin) { + var sentValue = key in this._activeSettings.sent ? this._activeSettings.sent[key] : null, + receivedValue = key in this._activeSettings.received ? this._activeSettings.received[key] : null, + currentValue = this.getValue(key, null); + + log('resyncValue', JSON.stringify({ + key: key, current: currentValue, sent: sentValue, received: receivedValue, + 'current !== received': [typeof currentValue, currentValue+'', typeof receivedValue, receivedValue+'', currentValue !== receivedValue], + 'current !== sent': [currentValue+'', sentValue+'', currentValue !== sentValue], + })); + + if (currentValue !== receivedValue || currentValue !== sentValue) { + this._notifyValueUpdated(key, currentValue, sentValue, (origin?origin+':':'')+'jquerySettings.resyncValue'); + } + }, + + domAccessors: { + 'default': { + get: function() { return this.value; }, + set: function(nv) { $(this).val(nv); }, + }, + 'checkbox': { + get: function() { return this.checked; }, + set: function(nv) { $(this).prop('checked', nv); }, + }, + 'number': { + get: function() { return parseFloat(this.value); }, + set: function(nv) { + var step = this.step || 1, precision = (1/step).toString(this).length - 1; + var value = parseFloat(newValue).toFixed(precision); + if (isNaN(value)) { + log('domAccessors.number.set', id, 'ignoring NaN value:', value+''); + return; + } + $(this).val(value); + }, + }, + 'radio': { + get: function() { assert(false, 'use radio-group to get current selected radio value for ' + this.id); }, + set: function(nv) {}, + }, + 'radio-group': { + get: function() { + var checked = $(this).closest(':ui-hifiControlGroup').find('input:checked'); + debugPrint('_getthisValue.radio-group checked item: ' + checked[0], checked.val()); + return checked.val(); + }, + set: function(nv) { + + }, + }, + }, + + __getNodeValue: function(node) { + assert(node && typeof node === 'object', '__getNodeValue expects a DOM node'); + node = node.jquery ? node.get(0) : node; + var type = node ? (node.dataset.type || node.type) : undefined; + var value; + assert(type in this.domAccessors, 'DOM value accessor not defined for node type: ' + type); + debugPrint('__getNodeValue', type); + return this.domAccessors[type].get.call(node); + + // switch(node.type) { + // case 'checkbox': value = node.checked; break; + // case 'number': value = parseFloat(node.value); break; + // case 'radio': + // case 'radio-group': + // var checked = $(node).closest(':ui-hifiControlGroup').find('input:checked'); + // debugPrint('_getNodeValue.radio-group checked item: ' + checked[0], checked.val()); + // value = checked.val(); + // break; + // default: value = node.value; + // } + // debugPrint('_getNodeValue', node, node.id, node.type, node.type !== 'radio-group' && node.value, value); + // return value; + }, + + __setNodeValue: function(node, newValue) { + if (node && node.jquery) { + node = node[0]; + } + var id = node.id, + key = this.getKey(node.id), + type = node.dataset.type || node.type, + value = newValue, + element = $(node); + + debugPrint('__setNodeValue', '('+type+') #' + key + '=' + newValue); + + switch(type) { + case 'radio': + assert(false, 'radio buttons should be set through their radio-group parent'); + break; + case 'checkbox': + element.prop('checked', newValue); + element.is(':ui-hifiCheckboxRadio') && element.hifiCheckboxRadio('refresh'); + break; + case 'radio-group': + var input = element.find('input[type=radio]#' + newValue); + assert(input[0], 'ERROR: ' + key + ': could not find "input[type=radio]#' + newValue + '" to set new radio-group value of: ' + newValue); + input.prop('checked', true); + input.closest(':ui-hifiControlGroup').find(':ui-hifiCheckboxRadio').hifiCheckboxRadio('refresh'); + break; + case 'number': + var step = node.step || 1, precision = (1/step).toString().length - 1; + value = parseFloat(newValue).toFixed(precision); + if (isNaN(value)) { + log(id, 'ignoring NaN value'); + break; + } + element.val(value); + element.closest('.row').find(':ui-hifiSlider').hifiSlider('value', parseFloat(value)); + break; + default: + element.val(newValue); + } + return value; + } + }; +})(this); diff --git a/unpublishedScripts/marketplace/camera-move/modules/movement-utils.js b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.js new file mode 100644 index 0000000000..b887f4a268 --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.js @@ -0,0 +1,841 @@ +// movement-utils.js -- helper classes that help manage related Controller.*Event and input API bindings for movement controls + +/* eslint-disable comma-dangle */ +/* global require: true */ +/* eslint-env commonjs */ +"use strict"; + +module.exports = { + version: '0.0.2c-0104', + + CameraControls: CameraControls, + MovementEventMapper: MovementEventMapper, + MovementMapping: MovementMapping, + VelocityTracker: VelocityTracker, + VirtualDriveKeys: VirtualDriveKeys, + + applyEasing: applyEasing, + calculateThrust: calculateThrust, + vec3damp: vec3damp, + vec3eclamp: vec3eclamp, + + DriveModes: { + JITTER_TEST: 'jitter-test', + POSITION: 'position', // ~ MyAvatar.position + MOTOR: 'motor', // ~ MyAvatar.motorVelocity + THRUST: 'thrust', // ~ MyAvatar.setThrust + }, +}; + +var MAPPING_TEMPLATE = require('./movement-utils.mapping.json' + (Script.resolvePath('').match(/[?#].*$/)||[''])[0]); + +var WANT_DEBUG = false; + +function log() { + print('movement-utils | ' + [].slice.call(arguments).join(' ')); +} + +var debugPrint = function() {}; + +log(module.exports.version); + +var _utils = require('./_utils.js'), + assert = _utils.assert; + +if (1||WANT_DEBUG) { + require = _utils.makeDebugRequire(Script.resolvePath('.')); + _utils = require('./_utils.js'); // re-require in debug mode + if (WANT_DEBUG) 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); + +MovementEventMapper.CAPTURE_DRIVE_KEYS = 'drive-keys'; +MovementEventMapper.CAPTURE_ACTION_EVENTS = 'action-events'; +//MovementEventMapper.CAPTURE_KEYBOARD_EVENTS = 'key-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 = { + 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) { + var changed = 0; + for (var p in this.options) { + if (p in options) { + log('MovementEventMapper updating options.'+p, this.options[p] + ' -> ' + options[p]); + this.options[p] = options[p]; + changed++; + } + } + for (var p in options) { + if (!(p in this.options)) { + var value = options[p]; + log('MovementEventMapper warning: ignoring option:', p, (value +'').substr(0, 40)+'...'); + } + } + return changed; + }, + applyOptions: function(options, applyNow) { + var changed = this.updateOptions(options || {}); + if (changed && applyNow) { + this.reset(); + } + }, + reset: function() { + log(this.constructor.name, 'reset', JSON.stringify(Object.assign({}, this.options, { controllerMapping: undefined }),0,2)); + var enabled = this.enabled; + enabled && this.disable(); + enabled && 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) { + 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; + + if (!capture || this.options.captureMode === MovementEventMapper.CAPTURE_ACTION_EVENTS) { + var tmp = capture ? 'captureActionEvents' : 'releaseActionEvents'; + log('bindEvents -- ', tmp.toUpperCase()); + Controller[tmp](); + } + if (!capture || this.options.captureMode === MovementEventMapper.CAPTURE_DRIVE_KEYS) { + var 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) { /* eslint-disable-line empty-block */ } + + 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 + }; + if(0)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 = { + update: function update(dt) { + Object.keys(this.$pendingReset).forEach(_utils.bind(this, function(i) { + var reset = this.$pendingReset[i]; + if (reset.event.driveKey in this) { + this.setValue(reset.event, 0); + } + })); + }, + getValue: function(driveKey, defaultValue) { + return driveKey in this ? this[driveKey] : defaultValue; + }, + handleActionEvent: function(from, event) { + var value = event.actionValue; + if (this.$eventFilter) { + value = this.$eventFilter(from, event, function(from, event) { + return event.actionValue; + }); + } + if (event.driveKeyName) { + return this.setValue(event, value); + } + return false; + }, + setValue: function(event, value) { + var driveKeyName = event.driveKeyName, + driveKey = DriveKeys[driveKeyName], + id = event.id, + previous = this[driveKey], + autoReset = (driveKeyName === 'ZOOM'); + + if(0)debugPrint('setValue', 'id:'+id, 'driveKey:' + driveKey, 'driveKeyName:'+driveKeyName, 'actionName:'+event.actionName, 'previous:'+previous, 'value:'+value); + + 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; + if(0)debugPrint('actionStates.$pendingReset reset', enumMeta.DriveKeyNames[p]); + })); + 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 + }, + step_translation: { + x: 'STEP_TRANSLATE_X' in DriveKeys && this.getValue(DriveKeys.STEP_TRANSLATE_X) || 0, + y: 'STEP_TRANSLATE_Y' in DriveKeys && this.getValue(DriveKeys.STEP_TRANSLATE_Y) || 0, + z: 'STEP_TRANSLATE_Z' in DriveKeys && this.getValue(DriveKeys.STEP_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 + }, + step_rotation: { + x: 'STEP_PITCH' in DriveKeys && this.getValue(DriveKeys.STEP_PITCH) || 0, + y: 'STEP_YAW' in DriveKeys && this.getValue(DriveKeys.STEP_YAW) || 0, + z: 'STEP_ROLL' in DriveKeys && this.getValue(DriveKeys.STEP_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 = { + 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) { + var changed = 0; + for (var p in this.options) { + if (p in options) { + log('MovementMapping updating options.'+p, this.options[p] + ' -> ' + options[p]); + this.options[p] = options[p]; + changed++; + } + } + for (var p in options) { + if (!(p in this.options)) { + var value = options[p]; + log('MovementMapping warning: ignoring option:', p, (value +'').substr(0, 40)+'...'); + } + } + return changed; + }, + applyOptions: function(options) { + var changed = this.updateOptions(options || {}); + if (changed && 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); + log(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]; + when = when.filter(shouldInclude); + function shouldIncludeWhen(p, i) { + if (~exclude.indexOf(when)) { + log('EXCLUDING item.when === ' + when); + return false; + } + return true; + } + item.when = when.length > 1 ? when : when[0]; + + if (item.from && Array.isArray(item.from.makeAxis)) { + var makeAxis = item.from.makeAxis; + function shouldInclude(p, i) { + if (~exclude.indexOf(p)) { + log('EXCLUDING from.makeAxis[][' + i + '] === ' + p); + return false; + } + return true; + } + 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 + +// ---------------------------------------------------------------------------- +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(targetVelocity, velocity, drag) { + // If force isn't being applied in a direction, incorporate drag; + var dragEffect = { + x: targetVelocity.x ? 0 : drag.x, + y: targetVelocity.y ? 0 : drag.y, + z: targetVelocity.z ? 0 : drag.z, + }; + return Vec3.subtract(Vec3.sum(velocity, targetVelocity), dragEffect); +} + + +// ---------------------------------------------------------------------------- +function VelocityTracker(defaultValues) { + Object.defineProperty(this, 'defaultValues', { configurable: true, value: defaultValues }); +} +VelocityTracker.prototype = { + 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) { + var result = vec3damp( + targetState[component], + currentVelocities[component], + drag[component] + ); + var maxVelocity = settings[component].maxVelocity, + epsilon = settings[component].epsilon; + return this[component] = vec3eclamp(result, epsilon, maxVelocity); + }, +}; + +// ---------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- +Object.assign(CameraControls, { + SCRIPT_UPDATE: 'update', + ANIMATION_FRAME: 'requestAnimationFrame', // emulated + NEXT_TICK: 'nextTick', // emulated + SET_IMMEDIATE: 'setImmediate', // emulated +}); + +function CameraControls(options) { + options = options || {}; + assert('update' in options && 'threadMode' in options); + this.update = options.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 = { + $animate: null, + $start: function() { + if (this.$animate) { + return; + } + + 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) + var 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) + var 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, 1); + }); + 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(); + + if (this.enabled !== true) { + this.enabled = true; + this.enabledChanged(this.enabled); + } + }, + 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.enabled = false; + this.enabledChanged(this.enabled); + } + }, + _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, + }); + }, +}; // CameraControls + +// ---------------------------------------------------------------------------- +function applyEasing(deltaTime, direction, settings, state, scaling) { + var obj = {}; + for (var p in scaling) { + var group = settings[p], + easeConst = group[direction], + multiplier = group.speed, + scale = scaling[p], + stateVector = state[p]; + var vec = obj[p] = Vec3.multiply(easeConst * scale * deltaTime, stateVector); + // vec.x *= multiplier.x; + // vec.y *= multiplier.y; + // vec.z *= multiplier.z; + } + return obj; +} + +// ---------------------------------------------------------------------------- +// currently unused +/* +function normalizeDegrees(x) { + while (x > 360) { + x -= 360; + } + while (x < 0) { + x += 360; + } + return x; +} +function getEffectiveVelocities(deltaTime, collisionsEnabled, previousValues) { + // use native values if available, otherwise compute a derivative over deltaTime + var effectiveVelocity = collisionsEnabled ? MyAvatar.velocity : Vec3.multiply(Vec3.subtract( + MyAvatar.position, previousValues.position + ), 1/deltaTime); + var effectiveAngularVelocity = // collisionsEnabled ? MyAvatar.angularVelocity : + Vec3.multiply( + Quat.safeEulerAngles(Quat.multiply( + MyAvatar.orientation, + Quat.inverse(previousValues.orientation) + )), 1/deltaTime); + + effectiveVelocity = Vec3.multiplyQbyV(Quat.inverse(previousValues.orientation), effectiveVelocity); + + effectiveAngularVelocity = { + x: normalizeDegrees(effectiveAngularVelocity.x), + y: normalizeDegrees(effectiveAngularVelocity.y), + z: normalizeDegrees(effectiveAngularVelocity.z), + }; + + return { + velocity: effectiveVelocity, + angularVelocity: effectiveAngularVelocity, + }; +} +function _isAvatarNearlyUpright(maxDegrees) { + maxDegrees = maxDegrees || 5; + return Math.abs(MyAvatar.headRoll) < maxDegrees && Math.abs(MyAvatar.bodyPitch) < maxDegrees; +} + + +*/ diff --git a/unpublishedScripts/marketplace/camera-move/modules/movement-utils.mapping.json b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.mapping.json new file mode 100644 index 0000000000..2489cb634d --- /dev/null +++ b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.mapping.json @@ -0,0 +1,98 @@ +{ + "name": "app-camera-move", + "channels": [ + + { "comment": "------------------ Actions.TranslateX -------------------" }, + { + "from": {"makeAxis": [["Keyboard.Left"],["Keyboard.Right"]]}, + "when": "Keyboard.Shift", + "to": "Actions.TranslateX" + }, + { + "from": {"makeAxis": [["Keyboard.A","Keyboard.TouchpadLeft"],["Keyboard.D","Keyboard.TouchpadRight"]]}, + "when": "Keyboard.Shift", + "to": "Actions.TranslateX" + }, + + { "comment": "------------------ Actions.TranslateY -------------------" }, + { + "from": {"makeAxis": [["Keyboard.C","Keyboard.PgDown"],["Keyboard.E","Keyboard.PgUp"]]}, + "when": "!Keyboard.Shift", + "to": "Actions.TranslateY" + }, + + { "comment": "------------------ Actions.TranslateZ -------------------" }, + { + "from": {"makeAxis": [["Keyboard.W"],["Keyboard.S"]]}, + "when": "!Keyboard.Shift", + "to": "Actions.TranslateZ" + }, + { + "from": {"makeAxis": [["Keyboard.Up"],["Keyboard.Down"]]}, + "when": "!Keyboard.Shift", + "to": "Actions.TranslateZ" + }, + + { "comment": "------------------ Actions.Yaw -------------------" }, + { + "from": {"makeAxis": [["Keyboard.A","Keyboard.TouchpadLeft"],["Keyboard.D","Keyboard.TouchpadRight"]]}, + "when": "!Keyboard.Shift", + "to": "Actions.Yaw", + "filters": ["invert"] + }, + { + "from": {"makeAxis": [["Keyboard.MouseMoveLeft"],["Keyboard.MouseMoveRight"]]}, + "when": "Keyboard.RightMouseButton", + "to": "Actions.Yaw", + "filters": ["invert"] + }, + { + "from": {"makeAxis": [["Keyboard.Left"],["Keyboard.Right"]]}, + "when": "!Keyboard.Shift", + "to": "Actions.Yaw", + "filters": ["invert"] + }, + { + "from": {"makeAxis": [["Keyboard.Left"],["Keyboard.Right"]]}, + "when": ["Application.SnapTurn", "!Keyboard.Shift"], + "to": "Actions.StepYaw", + "filters": + [ + { "type": "invert" }, + { "type": "pulse", "interval": 0.5, "resetOnZero": true }, + { "type": "scale", "scale": 22.5 } + ] + }, + + { "comment": "------------------ Actions.Pitch -------------------" }, + { + "from": {"makeAxis": [["Keyboard.W"],["Keyboard.S"]]}, + "when": "Keyboard.Shift", + "to": "Actions.Pitch", + "filters": ["invert"] + }, + { + "from": {"makeAxis": [["Keyboard.MouseMoveUp"],["Keyboard.MouseMoveDown"]]}, + "when": "Keyboard.RightMouseButton", + "to": "Actions.Pitch", + "filters": ["invert"] + }, + { + "from": {"makeAxis": [["Keyboard.Up"],["Keyboard.Down"]]}, + "when": "Keyboard.Shift", + "to": "Actions.Pitch", + "filters": ["invert"] + }, + + { "comment": "------------------ Actions.BoomIn -------------------" }, + { + "from": {"makeAxis": [["Keyboard.C","Keyboard.PgDown"],["Keyboard.E","Keyboard.PgUp"]]}, + "when": "Keyboard.Shift", + "to": "Actions.BoomIn", + "filters": [{"type": "scale","scale": 0.005}] + }, + + { "comment": "------------------ end -------------------" } + + ] +}