// 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, no-empty */ "use strict"; var VERSION = '0.0.1', 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 = Settings.getValue(NAMESPACE + '/debug', false) EPSILON = 1e-6; function log() { print( NAMESPACE + ' | ' + [].slice.call(arguments).join(' ') ); } var require = Script.require, debugPrint = function(){}, _debugChannel = NAMESPACE + '.stats', overlayDebugOutput = function(){}; if (WANT_DEBUG) { log('WANT_DEBUG is true; instrumenting debug support', WANT_DEBUG); _instrumentDebug(); } 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'), AvatarUpdater = require('./avatar-updater.js'); Object.assign = Object.assign || _utils.assign; var cameraControls, eventMapper, cameraConfig, applicationConfig; var DEFAULTS = { 'namespace': NAMESPACE, 'debug': WANT_DEBUG, 'jitter-test': false, '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, 'mouse-multiplier': 1.0, 'keyboard-multiplier': 1.0, 'ui-enable-tooltips': true, 'ui-show-advanced-options': false, 'Avatar/Draw Mesh': true, 'Scene/shouldRenderEntities': true, 'Scene/shouldRenderAvatars': true, 'Avatar/Show My Eye Vectors': false, 'Avatar/Show Other Eye Vectors': false, }; // map setting names to/from corresponding Menu and API properties var APPLICATION_SETTINGS = { 'Avatar/Enable Avatar Collisions': { menu: 'Avatar > Enable Avatar Collisions', object: [ MyAvatar, 'collisionsEnabled' ], }, 'Avatar/Draw Mesh': { menu: 'Developer > Draw Mesh', object: [ MyAvatar, 'getEnableMeshVisible', 'setEnableMeshVisible' ], }, 'Avatar/Show My Eye Vectors': { menu: 'Developer > Show My Eye Vectors' }, 'Avatar/Show Other Eye Vectors': { menu: 'Developer > Show Other Eye Vectors' }, 'Avatar/useSnapTurn': { object: [ MyAvatar, 'getSnapTurn', 'setSnapTurn' ] }, 'Avatar/lookAtSnappingEnabled': 'lookAtSnappingEnabled' in MyAvatar && { menu: 'Developer > Enable LookAt Snapping', object: [ MyAvatar, 'lookAtSnappingEnabled' ] }, 'Scene/shouldRenderEntities': { object: [ Scene, 'shouldRenderEntities' ] }, 'Scene/shouldRenderAvatars': { object: [ Scene, 'shouldRenderAvatars' ] }, '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, }, Reticle: { supportsScale: 'scale' in Reticle, }, protocolVersion: Window.protocolSignature(), }; 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 jitter) pendingChanges: _utils.DeferredUpdater.createGroup({ Camera: Camera, MyAvatar: MyAvatar, }, { dedupe: false }), // current input controls' effective velocities currentVelocities: new movementUtils.VelocityTracker({ translation: Vec3.ZERO, 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 runtime state (applicationConfig) and Settings state (cameraConfig) applicationConfig = new configUtils.ApplicationConfig({ namespace: DEFAULTS.namespace, config: APPLICATION_SETTINGS, }); cameraConfig = new configUtils.SettingsConfig({ namespace: DEFAULTS.namespace, defaultValues: DEFAULTS, }); var toggleKey = DEFAULT_TOGGLE_KEY; if (cameraConfig.getValue('toggle-key')) { try { toggleKey = JSON.parse(cameraConfig.getValue('toggle-key')); } catch (e) {} } // monitor configuration changes / keep tablet app up-to-date var MONITOR_INTERVAL_MS = 1000; _startConfigationMonitor(applicationConfig, cameraConfig, MONITOR_INTERVAL_MS); // ---------------------------------------------------------------------------- // set up the tablet 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, }, getSystemMetadata(), DEBUG_INFO), debug: WANT_DEBUG > 1, }); Script.scriptEnding.connect(settingsApp, 'cleanup'); settingsApp.valueUpdated.connect(function(key, value, oldValue, origin) { log('settingsApp.valueUpdated: '+ key + ' = ' + JSON.stringify(value) + ' (was: ' + JSON.stringify(oldValue) + ')'); if (/tablet/i.test(origin)) { // apply relevant settings immediately if changed from the tablet UI if (applicationConfig.applyValue(key, value, origin)) { log('settingsApp applied immediate setting', key, value); } } }); // process custom eventbridge messages 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': { var resetValues = {}; // maintain current value of 'show advanced' so user can observe any advanced settings being reset var showAdvancedKey = cameraConfig.resolve('ui-show-advanced-options'); resetValues[showAdvancedKey] = cameraConfig.getValue(showAdvancedKey); 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('restting to system defaults:', JSON.stringify(resetValues, 0, 2)); for (var p in resetValues) { var value = resetValues[p]; applicationConfig.applyValue(p, value, 'reset'); cameraConfig.setValue(p, value); } } break; default: { log('onUnhandledMessage', JSON.stringify(msg,0,2)); } break; } }; // ---------------------------------------------------------------------------- // set up the keyboard/mouse/controller input state manager eventMapper = new movementUtils.MovementEventMapper({ namespace: DEFAULTS.namespace, mouseSmooth: cameraConfig.getValue('enable-mouse-smooth'), mouseMultiplier: cameraConfig.getValue('mouse-multiplier'), keyboardMultiplier: cameraConfig.getValue('keyboard-multiplier'), eventFilter: function eventFilter(from, event, defaultFilter) { var result = defaultFilter(from, event), driveKeyName = event.driveKeyName; if (!result || !driveKeyName) { if (from === 'Keyboard.RightMouseButton') { // let the app know when the user is mouse looking 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'); } else if (from === 'Actions.Yaw') { result *= cameraConfig.getFloat('rotation-y-speed'); } return result; }, }); Script.scriptEnding.connect(eventMapper, 'disable'); // keep track of these changes live so the controller mapping can be kept in sync applicationConfig.register({ 'enable-mouse-smooth': { object: [ eventMapper.options, 'mouseSmooth' ] }, 'keyboard-multiplier': { object: [ eventMapper.options, 'keyboardMultiplier' ] }, 'mouse-multiplier': { object: [ eventMapper.options, 'mouseMultiplier' ] }, }); // ---------------------------------------------------------------------------- // set up the top-level camera controls manager / animator var avatarUpdater = new AvatarUpdater({ debugChannel: _debugChannel, globalState: globalState, getCameraMovementSettings: getCameraMovementSettings, getMovementState: _utils.bind(eventMapper, 'getState'), }); cameraControls = new movementUtils.CameraControls({ namespace: DEFAULTS.namespace, update: avatarUpdater, 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 resetting all drive key states Script.scriptEnding.connect(new _utils.KeyListener({ text: 'ESC', onKeyPressEvent: function(event) { if (cameraControls.enabled) { log('ESC pressed -- resetting drive keys:', JSON.stringify({ virtualDriveKeys: eventMapper.states, movementState: eventMapper.getState(), }, 0, 2)); eventMapper.states.reset(); MyAvatar.velocity = Vec3.ZERO; MyAvatar.angularVelocity = Vec3.ZERO; } }, }), 'disconnect'); // set up the tablet button to toggle the UI display button.clicked.connect(settingsApp, function(enable) { Object.assign(this.extraParams, getSystemMetadata()); button.editProperties({ text: '(opening)' + BUTTON_CONFIG.text, isActive: true }); this.toggle(enable); }); settingsApp.isActiveChanged.connect(function(isActive) { updateButtonText(); if (Overlays.getOverlayType(HMD.tabletScreenID)) { var fromMode = Overlays.getProperty(HMD.tabletScreenID, 'inputMode'), inputMode = isActive ? "Mouse" : "Touch"; log('switching HMD.tabletScreenID from inputMode', fromMode, 'to', inputMode); Overlays.editOverlay(HMD.tabletScreenID, { inputMode: inputMode }); } }); cameraControls.modeChanged.connect(onCameraModeChanged); function updateButtonText() { var lines = [ settingsApp.isActive ? '(app open)' : '', cameraControls.enabled ? (avatarUpdater.update.momentaryFPS||0).toFixed(2) + 'fps' : BUTTON_CONFIG.text.trim() ]; button && button.editProperties({ text: lines.join('\n') }); } var fpsTimeout = 0; 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(); avatarUpdater._resetMyAvatarMotor({ MyAvatar: MyAvatar }); updateButtonText(); if (settingsApp.isActive) { settingsApp.syncValue('Keyboard.RightMouseButton', false, 'cameraControls.disabled'); } } overlayDebugOutput.overlayID && Overlays.editOverlay(overlayDebugOutput.overlayID, { visible: enabled }); }); // when certain settings change we need to reset the drive systems var resetIfChanged = [ 'minimal-cursor', 'drive-mode', 'fps', 'thread-update-mode', 'mouse-multiplier', '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: ' + key + ' = ' + JSON.stringify(value), '(was:' + JSON.stringify(oldValue) + ')', 'triggerReset: ' + triggerReset); if (/tablet/i.test(origin)) { if (applicationConfig.applyValue(key, value, origin)) { log('cameraConfig applied immediate setting', key, value); } } triggerReset && 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(), })); } // reset orientation-related values when the Camera.mode changes 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() { return { epsilon: EPSILON, debug: cameraConfig.getValue('debug'), jitterTest: cameraConfig.getValue('jitter-test'), driveMode: cameraConfig.getValue('drive-mode'), threadMode: cameraConfig.getValue('thread-update-mode'), fps: cameraConfig.getValue('fps'), useHead: cameraConfig.getValue('use-head'), stayGrounded: cameraConfig.getValue('stay-grounded'), preventRoll: cameraConfig.getValue('prevent-roll'), useConstantDeltaTime: cameraConfig.getValue('constant-delta-time'), collisionsEnabled: applicationConfig.getValue('Avatar/Enable Avatar Collisions'), mouseSmooth: cameraConfig.getValue('enable-mouse-smooth'), mouseMultiplier: cameraConfig.getValue('mouse-multiplier'), keyboardMultiplier: cameraConfig.getValue('keyboard-multiplier'), rotation: _getEasingGroup(cameraConfig, 'rotation'), translation: _getEasingGroup(cameraConfig, 'translation'), zoom: _getEasingGroup(cameraConfig, 'zoom'), }; // extract a single 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.001; } 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') }), }; } } // monitor and sync Application state -> Settings values function _startConfigationMonitor(applicationConfig, cameraConfig, interval) { 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); } // ---------------------------------------------------------------------------- // DEBUG overlay support (enable by setting app-camera-move/debug = true in settings // ---------------------------------------------------------------------------- function _instrumentDebug() { 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: 12, font: { size: 12 }, width: 250, height: 800 }); // auto-disable camera move mode when debugging Script.scriptEnding.connect(function() { cameraConfig && cameraConfig.setValue('camera-move-enabled', false); }); } 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) { overlayDebugOutput(JSON.parse(message)); } } return overlayDebugOutput; } // ---------------------------------------------------------------------------- _patchCameraModeSetting(); function _patchCameraModeSetting() { // FIXME: looks like the Camera API suffered a regression where Camera.mode = 'first person' or 'third person' // no longer works from the API; setting via Menu items still seems to work though. 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 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'), }, window: { width: Window.innerWidth, height: Window.innerHeight, }, desktop: { width: Desktop.width, height: Desktop.height, }, }; } // ---------------------------------------------------------------------------- main(); if (typeof module !== 'object') { // if uncaught exceptions occur, show the first in an alert with option to stop the script Script.unhandledException.connect(function onUnhandledException(error) { Script.unhandledException.disconnect(onUnhandledException); log('UNHANDLED EXCEPTION', error, error && error.stack); try { cameraControls.disable(); } catch (e) {} // show the error message and first two stack entries var trace = _utils.normalizeStackTrace(error); var message = [ error ].concat(trace.split('\n').slice(0,2)).concat('stop script?').join('\n'); Window.confirm('app-camera-move error: ' + message.substr(0,256)) && Script.stop(); }); }