diff --git a/applications/nametags/canvas.html b/applications/nametags/canvas.html new file mode 100644 index 0000000..b175cd4 --- /dev/null +++ b/applications/nametags/canvas.html @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/applications/nametags/canvas.js b/applications/nametags/canvas.js new file mode 100644 index 0000000..85b3717 --- /dev/null +++ b/applications/nametags/canvas.js @@ -0,0 +1,126 @@ +const canvas = document.querySelector('canvas'); + +function drawNamePlate(name = "", userUUID = "", hasGroup = false, groupBannerURL = "", tagWidth = 3000, groupBannerHeight = 600, nameTagHeight = 500) { + let ctx = canvas.getContext("2d"); + + // Set the canvas size to match tagWidth and a suitable height + canvas.width = tagWidth; + canvas.height = groupBannerHeight + nameTagHeight; + + if (hasGroup) { + console.log("Has group") + const backgroundImage = new Image(); + backgroundImage.setAttribute('crossorigin', 'anonymous'); + backgroundImage.src = groupBannerURL; + + backgroundImage.onload = function () { + // Calculate the scale factor to maximize one dimension within the available space + const scaleFactorX = groupBannerHeight / backgroundImage.height; + const scaleFactorY = tagWidth / backgroundImage.width; + const scaleFactor = Math.max(scaleFactorX, scaleFactorY); + + // Calculate new dimensions for the image while maintaining aspect ratio + const newWidth = backgroundImage.width * scaleFactor; + const newHeight = backgroundImage.height * scaleFactor; + + // Calculate the vertical offset to center the image vertically within the banner area + const yPosition = (groupBannerHeight - newHeight) / 2; + + // Save the current state of the canvas + ctx.save(); + + // Set up clipping path for the top part of the canvas + ctx.beginPath(); + ctx.rect(0, 0, tagWidth, groupBannerHeight); + ctx.clip(); + + // Draw the image starting from (0, yPosition) + ctx.drawImage(backgroundImage, 0, yPosition, newWidth, newHeight); + + // Restore the previous state of the canvas + ctx.restore(); + + EventBridge.emitWebEvent(JSON.stringify({ + action: "nameplateReady", + data: { + imageBase64: getImageBase64(), + userUUID: userUUID + } + })); + }; + } + + // Nametag background with inset border + const radius = 30; // Define the radius of the rounded corners + + // Main fill color + ctx.fillStyle = "#111111ee"; + ctx.strokeStyle = "black"; + drawRoundedRectangle(ctx, 0, groupBannerHeight, tagWidth, nameTagHeight, radius); + ctx.fill(); + + // Inset border color + ctx.strokeStyle = "#6667ab"; + ctx.lineWidth = 20; // Set the stroke width + drawRoundedRectangle(ctx, 20, groupBannerHeight + 20, tagWidth - 40, nameTagHeight - 40, radius - 10); + ctx.stroke(); + + ctx.fillStyle = "#111111ee"; + ctx.strokeStyle = "black"; + + ctx.font = '256px Arial'; // Set font size and type + ctx.fillStyle = 'white'; // Set text color + + const nameTag = name; + const nameTagTextWidth = ctx.measureText(nameTag).width; // Measure the width of the text + const nameTagXPosition = (tagWidth / 2) - (nameTagTextWidth / 2); // Calculate the center position + + ctx.shadowColor = "black"; + ctx.shadowBlur = 10; + ctx.lineWidth = 8; + + ctx.strokeText(nameTag, nameTagXPosition - 4, groupBannerHeight + 4 + (nameTagHeight + 150) / 2); + ctx.fillText(nameTag, nameTagXPosition, groupBannerHeight + (nameTagHeight + 150) / 2); // Draw the text at the calculated position + + if (!hasGroup) { + EventBridge.emitWebEvent(JSON.stringify({ + action: "nameplateReady", + data: { + imageBase64: getImageBase64(), + userUUID: userUUID + } + })); + } + +} + +function drawRoundedRectangle(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +function getImageBase64() { + return canvas.toDataURL('image/png'); +} + +EventBridge.scriptEventReceived.connect(function (event) { + let eventPacket = {}; + try { + eventPacket = JSON.parse(event); + } catch { + return; + } + + if (eventPacket.action === 'generateNameplate') { + drawNamePlate(eventPacket.data.name, eventPacket.data.userUUID, eventPacket.data.hasGroup, eventPacket.data.groupBannerURL); + } +}) \ No newline at end of file diff --git a/applications/nametags/nametags.js b/applications/nametags/nametags.js index d1b190b..844024f 100644 --- a/applications/nametags/nametags.js +++ b/applications/nametags/nametags.js @@ -1,168 +1,151 @@ // -// Copyright 2024 Overte e.V. +// Copyright 2025 Overte e.V. // // Written by Armored Dragon // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html -(function () { - "use strict"; - let user_nametags = {}; - let visible = Settings.getValue("Nametags_toggle", true); - let maximum_name_length = 50; - let last_camera_mode = Camera.mode; +"use strict"; +let nameTags = {}; +let visible = Settings.getValue("adragon.nametags.enable", true); +let maximumNameLength = 50; +let last_camera_mode = Camera.mode; - _updateList(); +_updateList(); - AvatarManager.avatarAddedEvent.connect(_addUser); // New user connected - AvatarManager.avatarRemovedEvent.connect(_removeUser); // User disconnected - Script.update.connect(_adjustNametags); // Delta time +AvatarManager.avatarAddedEvent.connect(_addUser); // New user connected +AvatarManager.avatarRemovedEvent.connect(_removeUser); // User disconnected +Script.update.connect(_adjustNametags); // Delta time +Script.scriptEnding.connect(_scriptEnding); // Script was uninstalled +Menu.menuItemEvent.connect(_toggleState); // Toggle the nametag - Script.scriptEnding.connect(_scriptEnding); // Script was uninstalled - Menu.menuItemEvent.connect(_toggleState); // Toggle the nametag - - // Toolbar icon - let tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); - let tabletButton = tablet.addButton({ +// Toolbar icon +let tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); +let tabletButton = tablet.addButton({ icon: Script.resolvePath("./assets/nametags-i.svg"), activeIcon: Script.resolvePath("./assets/nametags-a.svg"), text: "NAMETAGS", isActive: visible, - }); - tabletButton.clicked.connect(_toggleState); +}); +tabletButton.clicked.connect(_toggleState); - // Menu item - Menu.addMenuItem({ +// This is a web overlay we will be using to generate a HTML canvas for which we will use to create our nametag base64 image +// See canvas.js +let canvasPuppet = new OverlayWebWindow({ + source: Script.resolvePath("./canvas.html"), + visible: false +}); +canvasPuppet.webEventReceived.connect(onWebEventReceived); + +// Menu item +Menu.addMenuItem({ menuName: "View", menuItemName: "Nametags", shortcutKey: "CTRL+N", isCheckable: true, isChecked: visible, - }); +}); - function _updateList() { - const include_self = !HMD.active && !Camera.mode.includes("first person"); - var user_list = AvatarList.getAvatarIdentifiers(); - if (include_self) user_list.push(MyAvatar.sessionUUID); +function _updateList() { + const includeSelf = !HMD.active && !Camera.mode.includes("first person"); + var userList = AvatarList.getAvatarIdentifiers(); + if (includeSelf) userList.push(MyAvatar.sessionUUID); // Filter undefined values out - user_list = user_list.filter((uuid) => uuid); + userList = userList.filter((uuid) => uuid); - user_list.forEach(_addUser); - } + userList.forEach(_addUser); +} - // Add a user to the user list - function _addUser(user_uuid) { +// Add a user to the user list +async function _addUser(userUUID) { if (!visible) return; - if (user_nametags[user_uuid]) return; + const user = AvatarList.getAvatar(userUUID); + const displayName = user.displayName.substring(0, maximumNameLength) ?? "Anonymous"; - const user = AvatarList.getAvatar(user_uuid); - const display_name = user.displayName ? user.displayName.substring(0, maximum_name_length) : "Anonymous"; - const headJointIndex = user.getJointIndex("Head"); - const jointInObjectFrame = user.getAbsoluteJointTranslationInObjectFrame(headJointIndex); + console.log(`Registering ${displayName} (${userUUID})`); - console.log(`Registering ${display_name} (${user_uuid}) nametag`); - - user_nametags[user_uuid] = { text: {}, background: {} }; - - user_nametags[user_uuid].text = Entities.addEntity( - { - type: "Text", - text: display_name, - backgroundAlpha: 0.0, - billboardMode: "full", - dimensions: { x: 0.8, y: 0.2, z: 0.1 }, - unlit: true, - parentID: user_uuid, - position: Vec3.sum(user.position, { x: 0, y: 0.4 + jointInObjectFrame.y, z: 0 }), - visible: true, - isSolid: false, - topMargin: 0.025, - alignment: "center", - lineHeight: 0.1, - canCastShadow: false, - grab: { - grabbable: false + canvasPuppet.emitScriptEvent(JSON.stringify({ + action: "generateNameplate", + data: { + name: displayName, + userUUID: uuidToString(userUUID), + hasGroup: false // FIXME: Groups are hard-coded false until group features are ready } - }, - "local" - ); - user_nametags[user_uuid].background = Entities.addEntity( - { - type: "Image", - dimensions: { x: 0.8, y: 0.2, z: 0.1 }, - emissive: true, - alpha: 0.8, - keepAspectRatio: false, - position: Vec3.sum(user.position, { x: 0, y: 0.4 + jointInObjectFrame.y, z: 0 }), - parentID: user_nametags[user_uuid].text, - billboardMode: "full", - imageURL: Script.resolvePath("./assets/badge.svg"), - canCastShadow: false, - grab: { - grabbable: false - } - }, - "local" - ); + })); +} - // We need to have this on a timeout because "textSize" can not be determined instantly after the entity was created. - // https://apidocs.overte.org/Entities.html#.textSize - Script.setTimeout(() => { - let textSize = Entities.textSize(user_nametags[user_uuid].text, display_name); - Entities.editEntity(user_nametags[user_uuid].text, { dimensions: { x: textSize.width + 0.25, y: textSize.height + 0.07, z: 0.1 } }); - Entities.editEntity(user_nametags[user_uuid].background, { - dimensions: { x: Math.max(textSize.width + 0.25, 0.6), y: textSize.height + 0.05, z: 0.1 }, - }); - }, 100); - } +// Remove a user from the user list +function _removeUser(userUUID) { + console.log(`Deleting ${userUUID}`); + Entities.deleteEntity(nameTags[userUUID]); + delete nameTags[userUUID]; +} - // Remove a user from the user list - function _removeUser(user_uuid) { - console.log(`Deleting ${user_uuid} nametag`); - Entities.deleteEntity(user_nametags[user_uuid].text); - Entities.deleteEntity(user_nametags[user_uuid].background); - delete user_nametags[user_uuid]; - } - - // Updates positions of existing nametags - function _adjustNametags() { +// Updates positions of existing nametags +function _adjustNametags() { if (last_camera_mode !== Camera.mode) { - if (Camera.mode.includes("first person")) _removeUser(MyAvatar.sessionUUID); - else _addUser(MyAvatar.sessionUUID); - last_camera_mode = Camera.mode; + if (Camera.mode.includes("first person")) _removeUser(MyAvatar.sessionUUID); + else _addUser(MyAvatar.sessionUUID); + last_camera_mode = Camera.mode; } +} - Object.keys(user_nametags).forEach((user_uuid) => { - const user = AvatarList.getAvatar(user_uuid); - const display_name = user.displayName ? user.displayName.substring(0, maximum_name_length) : "Anonymous"; - const headJointIndex = user.getJointIndex("Head"); - const jointInObjectFrame = user.getAbsoluteJointTranslationInObjectFrame(headJointIndex); - Entities.editEntity(user_nametags[user_uuid].text, { - position: Vec3.sum(user.position, { x: 0.01, y: jointInObjectFrame.y + Math.abs(user.scale - 1) + 0.4, z: 0 }), - text: display_name, - }); - }); - } - - // Enable or disable nametags - function _toggleState() { +// Enable or disable nametags +function _toggleState() { visible = !visible; tabletButton.editProperties({ isActive: visible }); Settings.setValue("Nametags_toggle", visible); - if (!visible) Object.keys(user_nametags).forEach(_removeUser); + if (!visible) Object.keys(nameTags).forEach(_removeUser); if (visible) _updateList(); - } +} - function _scriptEnding() { +function _scriptEnding() { tablet.removeButton(tabletButton); Menu.removeMenuItem("View", "Nametags"); - for (let i = 0; Object.keys(user_nametags).length > i; i++) { - Entities.deleteEntity(user_nametags[Object.keys(user_nametags)[i]].text); - Entities.deleteEntity(user_nametags[Object.keys(user_nametags)[i]].background); + Object.keys(nameTags).forEach(_removeUser); + nameTags = {}; +} + +function onWebEventReceived(event) { + let eventPacket = {}; + try { + eventPacket = JSON.parse(event); + } catch { + return; } - user_nametags = {}; - } -})(); + + if (eventPacket.action === 'nameplateReady') { + let userUUID = Uuid.fromString(eventPacket.data.userUUID); + + const user = AvatarList.getAvatar(userUUID); + const headJointIndex = user.getJointIndex("Head"); + const jointPosition = Vec3.sum(user.getJointPosition(headJointIndex), { x: 0, y: 0.5, z: 0 }); + + nameTags[userUUID] = Entities.addEntity( + { + type: "Image", + dimensions: { x: 1, y: 1, z: 0.1 }, + emissive: true, + alpha: 1, + keepAspectRatio: true, + position: jointPosition, + parentID: userUUID, + billboardMode: "full", + imageURL: eventPacket.data.imageBase64, + canCastShadow: false, + grab: { + grabbable: false + } + }, + "local" + ); + } +} + +function uuidToString(existingUuid) { + existingUuid = Uuid.toString(existingUuid); // Scripts way to turn it into a string + return existingUuid.replace(/[{}]/g, ''); // Remove '{' and '}' from UUID string >:( +} \ No newline at end of file