create clean branch for unpublishedScripts/marketplace/camera-move app

This commit is contained in:
humbletim 2017-06-09 07:09:26 -04:00
parent 759a87c27e
commit 16170cd754
13 changed files with 5725 additions and 0 deletions

View file

@ -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>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -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);
}
},
};

View file

@ -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();

File diff suppressed because it is too large Load diff

View file

@ -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,'&lt;'));
}
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;
};
}

View file

@ -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 */
}

View file

@ -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(',&nbsp;'),
'</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;
}

View file

@ -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;
}
};
// ----------------------------------------------------------------------------

View file

@ -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);
}
}
};

View file

@ -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);

View file

@ -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);

View file

@ -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;
}
*/

View file

@ -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 -------------------" }
]
}