Feature/nametags v2 (#1)

* Version 2 initial.

* Enable nametags in the listing.
This commit is contained in:
Armored-Dragon 2025-03-05 01:49:34 -06:00 committed by GitHub
parent 3a5f3bd450
commit 00efd88d45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 244 additions and 125 deletions

View file

@ -0,0 +1,10 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<canvas class="canvas"></canvas>
</body>
<script src="./canvas.js"></script>
</html>

View file

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

View file

@ -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 >:(
}