948 lines · 36.0 KB · JavaScript
RawDownload
"use strict";//// pal.js//// Created by Howard Stearns on December 9th, 2016//  Copyright 2016 High Fidelity, Inc.//  Copyright 2023 Overte e.V.//// Distributed under the Apache License, Version 2.0// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html///* jslint vars:true, plusplus:true, forin:true *//* global Tablet, Settings, Script, AvatarList, Users, Entities,    MyAvatar, Camera, Overlays, Vec3, Quat, HMD, Controller, Account,    UserActivityLogger, Messages, Window, XMLHttpRequest, print, location, getControllerWorldLocation*//* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */// (function () { // BEGIN LOCAL_SCOPEvar controllerStandard = Controller.Standard; var request = Script.require('request').request;var AppUi = Script.require('appUi'); var populateNearbyUserList, color, textures, removeOverlays,    controllerComputePickRay, off,    receiveMessage, avatarDisconnected, clearLocalQMLDataAndClosePAL,    CHANNEL, getConnectionData,    avatarAdded, avatarRemoved, avatarSessionChanged; // forward references; // hardcoding these as it appears we cannot traverse the originalTextures in overlays???  Maybe I've missed// something, will revisit as this is sorta horrible.var UNSELECTED_TEXTURES = {    "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png"),    "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png")};var SELECTED_TEXTURES = {    "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png"),    "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png")};var HOVER_TEXTURES = {    "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png"),    "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png")}; var UNSELECTED_COLOR = { red: 0x1F, green: 0xC6, blue: 0xA6};var SELECTED_COLOR = {red: 0xF3, green: 0x91, blue: 0x29};var HOVER_COLOR = {red: 0xD0, green: 0xD0, blue: 0xD0}; // almost white for nowvar METAVERSE_BASE = Account.metaverseServerURL; Script.include("/~/system/libraries/controllers.js"); function projectVectorOntoPlane(normalizedVector, planeNormal) {    return Vec3.cross(planeNormal, Vec3.cross(normalizedVector, planeNormal));}function angleBetweenVectorsInPlane(from, to, normal) {    var projectedFrom = projectVectorOntoPlane(from, normal);    var projectedTo = projectVectorOntoPlane(to, normal);    return Vec3.orientedAngle(projectedFrom, projectedTo, normal);} //// Overlays.//var overlays = {}; // Keeps track of all our extended overlay data objects, keyed by target identifier. function ExtendedOverlay(key, properties, selected, hasModel) { // A wrapper around overlays to store the key it is associated with.    overlays[key] = this;    if (hasModel) {        var modelKey = key + "-m";        this.model = new ExtendedOverlay(modelKey, {            "type": "Model",            "modelURL": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx"),            "textures": textures(selected),            "ignorePickIntersection": true        }, false, false);    } else {        this.model = undefined;    }    this.key = key;    this.selected = selected || false; // not undefined    this.hovering = false;    this.activeOverlay = Entities.addEntity(properties, "local"); // We could use different overlays for (un)selected...}// Instance methods:ExtendedOverlay.prototype.deleteOverlay = function () { // remove display and data of this overlay    Entities.deleteEntity(this.activeOverlay);    delete overlays[this.key];}; ExtendedOverlay.prototype.editOverlay = function (properties) { // change display of this overlay    Entities.editEntity(this.activeOverlay, properties);}; function color(selected, hovering, level) {    var base = hovering ? HOVER_COLOR : selected ? SELECTED_COLOR : UNSELECTED_COLOR;    function scale(component) {        var delta = 0xFF - component;        return component + (delta * level);    }    return {red: scale(base.red), green: scale(base.green), blue: scale(base.blue)};} function textures(selected, hovering) {    return hovering ? HOVER_TEXTURES : selected ? SELECTED_TEXTURES : UNSELECTED_TEXTURES;}// so we don't have to traverse the overlays to get the last onevar lastHoveringId = 0;ExtendedOverlay.prototype.hover = function (hovering) {    this.hovering = hovering;    if (this.key === lastHoveringId) {        if (hovering) {            return;        }        lastHoveringId = 0;    }    this.editOverlay({color: color(this.selected, hovering, this.audioLevel)});    if (this.model) {        this.model.editOverlay({textures: textures(this.selected, hovering)});    }    if (hovering) {        // un-hover the last hovering overlay        if (lastHoveringId && lastHoveringId !== this.key) {            ExtendedOverlay.get(lastHoveringId).hover(false);        }        lastHoveringId = this.key;    }};ExtendedOverlay.prototype.select = function (selected) {    if (this.selected === selected) {        return;    }     UserActivityLogger.palAction(selected ? "avatar_selected" : "avatar_deselected", this.key);     this.editOverlay({color: color(selected, this.hovering, this.audioLevel)});    if (this.model) {        this.model.editOverlay({textures: textures(selected)});    }    this.selected = selected;};// Class methods:var selectedIds = [];ExtendedOverlay.isSelected = function (id) {    return -1 !== selectedIds.indexOf(id);};ExtendedOverlay.get = function (key) { // answer the extended overlay data object associated with the given avatar identifier    return overlays[key];};ExtendedOverlay.some = function (iterator) { // Bails early as soon as iterator returns truthy.    var key;    for (key in overlays) {        if (iterator(ExtendedOverlay.get(key))) {            return;        }    }};ExtendedOverlay.unHover = function () { // calls hover(false) on lastHoveringId (if any)    if (lastHoveringId) {        ExtendedOverlay.get(lastHoveringId).hover(false);    }}; // hit(overlay) on the one overlay intersected by pickRay, if any.// noHit() if no ExtendedOverlay was intersected (helps with hover)ExtendedOverlay.applyPickRay = function (pickRay, hit, noHit) {    // TODO: this could just include the necessary overlays for better performance    var pickedOverlay = Overlays.findRayIntersection(pickRay, true); // Depends on nearer coverOverlays to extend closer to us than farther ones.    if (!pickedOverlay.intersects) {        if (noHit) {            return noHit();        }        return;    }    ExtendedOverlay.some(function (overlay) { // See if pickedOverlay is one of ours.        if ((overlay.activeOverlay) === pickedOverlay.overlayID) {            hit(overlay);            return true;        }    });};  //// Similar, for entities//function HighlightedEntity(id, entityProperties) {    this.id = id;    this.overlay = Entities.addEntity({        "type": "Shape",        "shape": "Cube",        "position": entityProperties.position,        "rotation": entityProperties.rotation,        "dimensions": entityProperties.dimensions,        "primitiveMode": "solid",        "color": {            "red": 0xF3,            "green": 0x91,            "blue": 0x29        },        "ignorePickIntersection": true,        "renderLayer": "world" // Arguable. For now, let's not distract with mysterious wires around the scene.    }, "local");    HighlightedEntity.overlays.push(this);}HighlightedEntity.overlays = [];HighlightedEntity.clearOverlays = function clearHighlightedEntities() {    HighlightedEntity.overlays.forEach(function (highlighted) {        Entities.deleteEntity(highlighted.overlay);    });    HighlightedEntity.overlays = [];};HighlightedEntity.updateOverlays = function updateHighlightedEntities() {    HighlightedEntity.overlays.forEach(function (highlighted) {        var properties = Entities.getEntityProperties(highlighted.id, ['position', 'rotation', 'dimensions']);        Entities.editEntity(highlighted.overlay, {            "position": properties.position,            "rotation": properties.rotation,            "dimensions": properties.dimensions        });    });}; /* this contains current gain for a given node (by session id).  More efficient than * querying it, plus there isn't a getGain function so why write one */var sessionGains = {};function convertDbToLinear(decibels) {    // +20db = 10x, 0dB = 1x, -10dB = 0.1x, etc...    // but, your perception is that something 2x as loud is +10db    // so we go from -60 to +20 or 1/64x to 4x.  For now, we can    // maybe scale the signal this way??    return Math.pow(2, decibels / 10.0);}function fromQml(message) { // messages are {method, params}, like json-rpc. See also sendToQml.    var data, connectionUserName, friendUserName;    switch (message.method) {    case 'selected':        selectedIds = message.params;        ExtendedOverlay.some(function (overlay) {            var id = overlay.key;            var selected = ExtendedOverlay.isSelected(id);            overlay.select(selected);        });         HighlightedEntity.clearOverlays();        if (selectedIds.length) {            Entities.findEntitiesInFrustum(Camera.frustum).forEach(function (id) {                // Because lastEditedBy is per session, the vast majority of entities won't match,                // so it would probably be worth reducing marshalling costs by asking for just we need.                // However, providing property name(s) is advisory and some additional properties are                // included anyway. As it turns out, asking for 'lastEditedBy' gives 'position', 'rotation',                // and 'dimensions', too, so we might as well make use of them instead of making a second                // getEntityProperties call.                // It would be nice if we could harden this against future changes by specifying all                // and only these four in an array, but see                // https://highfidelity.fogbugz.com/f/cases/2728/Entities-getEntityProperties-id-lastEditedBy-name-lastEditedBy-doesn-t-work                var properties = Entities.getEntityProperties(id, 'lastEditedBy');                if (ExtendedOverlay.isSelected(properties.lastEditedBy)) {                    new HighlightedEntity(id, properties);                }            });        }        break;    case 'refresh': // old name for refreshNearby    case 'refreshNearby':        data = {};        ExtendedOverlay.some(function (overlay) { // capture the audio data            data[overlay.key] = overlay;        });        removeOverlays();        // If filter is specified from .qml instead of through settings, update the settings.        if (message.params.filter !== undefined) {            Settings.setValue('pal/filtered', !!message.params.filter);        }        populateNearbyUserList(message.params.selected, data);        UserActivityLogger.palAction("refresh_nearby", "");        break;    case 'refreshConnections':        print('Refreshing Connections...');        UserActivityLogger.palAction("refresh_connections", "");        break;    case 'removeConnection':        connectionUserName = message.params;        request({            uri: METAVERSE_BASE + '/api/v1/user/connections/' + connectionUserName,            method: 'DELETE'        }, function (error, response) {            if (error || (response.status !== 'success')) {                print("Error: unable to remove connection", connectionUserName, error || response.status);                return;            }            sendToQml({ method: 'connectionRemoved', params: connectionUserName });        });        break;     case 'removeFriend':        friendUserName = message.params;        print("Removing " + friendUserName + " from friends.");        request({            uri: METAVERSE_BASE + '/api/v1/user/friends/' + friendUserName,            method: 'DELETE'        }, function (error, response) {            if (error || (response.status !== 'success')) {                print("Error: unable to unfriend " + friendUserName, error || response.status);                return;            }            getConnectionData(friendUserName);        });        break;    case 'addFriend':        friendUserName = message.params;        print("Adding " + friendUserName + " to friends.");        request({            uri: METAVERSE_BASE + '/api/v1/user/friends',            method: 'POST',            json: true,            body: {                username: friendUserName,            }        }, function (error, response) {            if (error || (response.status !== 'success')) {                print("Error: unable to friend " + friendUserName, error || response.status);                return;            }            getConnectionData(friendUserName);        });        break;    case 'http.request':        break; // Handled by request-service.    case 'hideNotificationDot':        shouldShowDot = false;        ui.messagesWaiting(shouldShowDot);        break;    default:        print('Unrecognized message from Pal.qml');    }} function sendToQml(message) {    ui.sendMessage(message);}function updateUser(data) {    print('PAL update:', JSON.stringify(data));    sendToQml({ method: 'updateUsername', params: data });}//// User management services//// These are prototype versions that will be changed when the back end changes. function requestJSON(url, callback) { // callback(data) if successfull. Logs otherwise.    request({        uri: url    }, function (error, response) {        if (error || (response.status !== 'success')) {            print("Error: unable to get request",  error || response.status);            return;        }        callback(response.data);    });}function getProfilePicture(username, callback) { // callback(url) if successfull. (Logs otherwise)    // FIXME Prototype scrapes profile picture. We should include in user status, and also make available somewhere for myself    request({        uri: METAVERSE_BASE + '/users/' + username    }, function (error, html) {        var matched = !error && html.match(/img class="users-img" src="([^"]*)"/);        if (!matched) {            print('Error: Unable to get profile picture for', username, error);            callback('');            return;        }        callback(matched[1]);    });}var SAFETY_LIMIT = 400;function getAvailableConnections(domain, callback, numResultsPerPage) { // callback([{usename, location}...]) if successfull. (Logs otherwise)    var url = METAVERSE_BASE + '/api/v1/users?per_page=' + (numResultsPerPage || SAFETY_LIMIT) + '&';    if (domain) {        url += 'status=' + domain.slice(1, -1); // without curly braces    } else {        url += 'filter=connections'; // regardless of whether online    }    requestJSON(url, function (connectionsData) {        callback(connectionsData.users);    });}function getInfoAboutUser(specificUsername, callback) {    var url = METAVERSE_BASE + '/api/v1/users?filter=connections&per_page=' + SAFETY_LIMIT + '&search=' + encodeURIComponent(specificUsername);    requestJSON(url, function (connectionsData) {        // You could have (up to SAFETY_LIMIT connections whose usernames contain the specificUsername.        // Search returns all such matches.        for (user in connectionsData.users) {            if (connectionsData.users[user].username === specificUsername) {                callback(connectionsData.users[user]);                return;            }        }        callback(false);    });}function getConnectionData(specificUsername, domain) { // Update all the usernames that I am entitled to see, using my login but not dependent on canKick.    function frob(user) { // get into the right format        var formattedSessionId = user.location.node_id || '';        if (formattedSessionId !== '' && formattedSessionId.indexOf("{") != 0) {            formattedSessionId = "{" + formattedSessionId + "}";        }        return {            sessionId: formattedSessionId,            userName: user.username,            connection: user.connection,            profileUrl: user.images.thumbnail,            placeName: (user.location.root || user.location.domain || {}).name || ''        };    }    if (specificUsername) {        getInfoAboutUser(specificUsername, function (user) {            if (user) {                updateUser(frob(user));            } else {                print('Error: Unable to find information about ' + specificUsername + ' in connectionsData!');            }        });    } else if (domain) {        getAvailableConnections(domain, function (users) {            users.forEach(function (user) {                updateUser(frob(user));            });        });    } else {        print("Error: unrecognized getConnectionData()");    }} //// Main operations.//function addAvatarNode(id) {    var selected = ExtendedOverlay.isSelected(id);    return new ExtendedOverlay(id, {        "type": "Shape",        "shape": "Sphere",        "renderLayer": "front",        "primitiveMode": "solid",        "alpha": 0.8,        "color": color(selected, false, 0.0),        "ignorePickIntersection": false    }, selected, true);}// Each open/refresh will capture a stable set of avatarsOfInterest, within the specified filter.var avatarsOfInterest = {};function populateNearbyUserList(selectData, oldAudioData) {    var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')},        data = [],        avatars = AvatarList.getAvatarIdentifiers(),        myPosition = filter && Camera.position,        frustum = filter && Camera.frustum,        verticalHalfAngle = filter && (frustum.fieldOfView / 2),        horizontalHalfAngle = filter && (verticalHalfAngle * frustum.aspectRatio),        orientation = filter && Camera.orientation,        forward = filter && Quat.getForward(orientation),        verticalAngleNormal = filter && Quat.getRight(orientation),        horizontalAngleNormal = filter && Quat.getUp(orientation);    avatarsOfInterest = {};     var avatarData = AvatarList.getPalData().data;     avatarData.forEach(function (currentAvatarData) {        var id = currentAvatarData.sessionUUID;        var name = currentAvatarData.sessionDisplayName;        if (!name) {            // Either we got a data packet but no identity yet, or something is really messed up. In any case,            // we won't be able to do anything with this user, so don't include them.            // In normal circumstances, a refresh will bring in the new user, but if we're very heavily loaded,            // we could be losing and gaining people randomly.            print('No avatar identity data for', currentAvatarData.sessionUUID);            return;        }        if (id && myPosition && (Vec3.distance(currentAvatarData.position, myPosition) > filter.distance)) {            return;        }        var normal = id && filter && Vec3.normalize(Vec3.subtract(currentAvatarData.position, myPosition));        var horizontal = normal && angleBetweenVectorsInPlane(normal, forward, horizontalAngleNormal);        var vertical = normal && angleBetweenVectorsInPlane(normal, forward, verticalAngleNormal);        if (id && filter && ((Math.abs(horizontal) > horizontalHalfAngle) || (Math.abs(vertical) > verticalHalfAngle))) {            return;        }        var oldAudio = oldAudioData && oldAudioData[id];        var avatarPalDatum = {            profileUrl: '',            displayName: name,            userName: '',            connection: '',            sessionId: id || '',            audioLevel: (oldAudio && oldAudio.audioLevel) || 0.0,            avgAudioLevel: (oldAudio && oldAudio.avgAudioLevel) || 0.0,            admin: false,            personalMute: !!id && Users.getPersonalMuteStatus(id), // expects proper boolean, not null            ignore: !!id && Users.getIgnoreStatus(id), // ditto

Lines 1–500 of 948.