mirror of
https://github.com/overte-org/community-apps.git
synced 2025-04-25 23:55:15 +02:00
Feature/nametags v2 (#1)
* Version 2 initial. * Enable nametags in the listing.
This commit is contained in:
parent
3a5f3bd450
commit
00efd88d45
3 changed files with 244 additions and 125 deletions
10
applications/nametags/canvas.html
Normal file
10
applications/nametags/canvas.html
Normal 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>
|
126
applications/nametags/canvas.js
Normal file
126
applications/nametags/canvas.js
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
|
@ -1,168 +1,151 @@
|
||||||
//
|
//
|
||||||
// Copyright 2024 Overte e.V.
|
// Copyright 2025 Overte e.V.
|
||||||
//
|
//
|
||||||
// Written by Armored Dragon
|
// Written by Armored Dragon
|
||||||
// Distributed under the Apache License, Version 2.0.
|
// Distributed under the Apache License, Version 2.0.
|
||||||
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
|
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
|
||||||
|
|
||||||
(function () {
|
"use strict";
|
||||||
"use strict";
|
let nameTags = {};
|
||||||
let user_nametags = {};
|
let visible = Settings.getValue("adragon.nametags.enable", true);
|
||||||
let visible = Settings.getValue("Nametags_toggle", true);
|
let maximumNameLength = 50;
|
||||||
let maximum_name_length = 50;
|
let last_camera_mode = Camera.mode;
|
||||||
let last_camera_mode = Camera.mode;
|
|
||||||
|
|
||||||
_updateList();
|
_updateList();
|
||||||
|
|
||||||
AvatarManager.avatarAddedEvent.connect(_addUser); // New user connected
|
AvatarManager.avatarAddedEvent.connect(_addUser); // New user connected
|
||||||
AvatarManager.avatarRemovedEvent.connect(_removeUser); // User disconnected
|
AvatarManager.avatarRemovedEvent.connect(_removeUser); // User disconnected
|
||||||
Script.update.connect(_adjustNametags); // Delta time
|
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
|
// Toolbar icon
|
||||||
Menu.menuItemEvent.connect(_toggleState); // Toggle the nametag
|
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"),
|
icon: Script.resolvePath("./assets/nametags-i.svg"),
|
||||||
activeIcon: Script.resolvePath("./assets/nametags-a.svg"),
|
activeIcon: Script.resolvePath("./assets/nametags-a.svg"),
|
||||||
text: "NAMETAGS",
|
text: "NAMETAGS",
|
||||||
isActive: visible,
|
isActive: visible,
|
||||||
});
|
});
|
||||||
tabletButton.clicked.connect(_toggleState);
|
tabletButton.clicked.connect(_toggleState);
|
||||||
|
|
||||||
// Menu item
|
// 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
|
||||||
Menu.addMenuItem({
|
// See canvas.js
|
||||||
|
let canvasPuppet = new OverlayWebWindow({
|
||||||
|
source: Script.resolvePath("./canvas.html"),
|
||||||
|
visible: false
|
||||||
|
});
|
||||||
|
canvasPuppet.webEventReceived.connect(onWebEventReceived);
|
||||||
|
|
||||||
|
// Menu item
|
||||||
|
Menu.addMenuItem({
|
||||||
menuName: "View",
|
menuName: "View",
|
||||||
menuItemName: "Nametags",
|
menuItemName: "Nametags",
|
||||||
shortcutKey: "CTRL+N",
|
shortcutKey: "CTRL+N",
|
||||||
isCheckable: true,
|
isCheckable: true,
|
||||||
isChecked: visible,
|
isChecked: visible,
|
||||||
});
|
});
|
||||||
|
|
||||||
function _updateList() {
|
function _updateList() {
|
||||||
const include_self = !HMD.active && !Camera.mode.includes("first person");
|
const includeSelf = !HMD.active && !Camera.mode.includes("first person");
|
||||||
var user_list = AvatarList.getAvatarIdentifiers();
|
var userList = AvatarList.getAvatarIdentifiers();
|
||||||
if (include_self) user_list.push(MyAvatar.sessionUUID);
|
if (includeSelf) userList.push(MyAvatar.sessionUUID);
|
||||||
|
|
||||||
// Filter undefined values out
|
// 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
|
// Add a user to the user list
|
||||||
function _addUser(user_uuid) {
|
async function _addUser(userUUID) {
|
||||||
if (!visible) return;
|
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);
|
console.log(`Registering ${displayName} (${userUUID})`);
|
||||||
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 ${display_name} (${user_uuid}) nametag`);
|
canvasPuppet.emitScriptEvent(JSON.stringify({
|
||||||
|
action: "generateNameplate",
|
||||||
user_nametags[user_uuid] = { text: {}, background: {} };
|
data: {
|
||||||
|
name: displayName,
|
||||||
user_nametags[user_uuid].text = Entities.addEntity(
|
userUUID: uuidToString(userUUID),
|
||||||
{
|
hasGroup: false // FIXME: Groups are hard-coded false until group features are ready
|
||||||
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
|
|
||||||
}
|
}
|
||||||
},
|
}));
|
||||||
"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.
|
// Remove a user from the user list
|
||||||
// https://apidocs.overte.org/Entities.html#.textSize
|
function _removeUser(userUUID) {
|
||||||
Script.setTimeout(() => {
|
console.log(`Deleting ${userUUID}`);
|
||||||
let textSize = Entities.textSize(user_nametags[user_uuid].text, display_name);
|
Entities.deleteEntity(nameTags[userUUID]);
|
||||||
Entities.editEntity(user_nametags[user_uuid].text, { dimensions: { x: textSize.width + 0.25, y: textSize.height + 0.07, z: 0.1 } });
|
delete nameTags[userUUID];
|
||||||
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
|
// Updates positions of existing nametags
|
||||||
function _removeUser(user_uuid) {
|
function _adjustNametags() {
|
||||||
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() {
|
|
||||||
if (last_camera_mode !== Camera.mode) {
|
if (last_camera_mode !== Camera.mode) {
|
||||||
if (Camera.mode.includes("first person")) _removeUser(MyAvatar.sessionUUID);
|
if (Camera.mode.includes("first person")) _removeUser(MyAvatar.sessionUUID);
|
||||||
else _addUser(MyAvatar.sessionUUID);
|
else _addUser(MyAvatar.sessionUUID);
|
||||||
last_camera_mode = Camera.mode;
|
last_camera_mode = Camera.mode;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object.keys(user_nametags).forEach((user_uuid) => {
|
// Enable or disable nametags
|
||||||
const user = AvatarList.getAvatar(user_uuid);
|
function _toggleState() {
|
||||||
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() {
|
|
||||||
visible = !visible;
|
visible = !visible;
|
||||||
tabletButton.editProperties({ isActive: visible });
|
tabletButton.editProperties({ isActive: visible });
|
||||||
Settings.setValue("Nametags_toggle", visible);
|
Settings.setValue("Nametags_toggle", visible);
|
||||||
|
|
||||||
if (!visible) Object.keys(user_nametags).forEach(_removeUser);
|
if (!visible) Object.keys(nameTags).forEach(_removeUser);
|
||||||
if (visible) _updateList();
|
if (visible) _updateList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _scriptEnding() {
|
function _scriptEnding() {
|
||||||
tablet.removeButton(tabletButton);
|
tablet.removeButton(tabletButton);
|
||||||
Menu.removeMenuItem("View", "Nametags");
|
Menu.removeMenuItem("View", "Nametags");
|
||||||
|
|
||||||
for (let i = 0; Object.keys(user_nametags).length > i; i++) {
|
Object.keys(nameTags).forEach(_removeUser);
|
||||||
Entities.deleteEntity(user_nametags[Object.keys(user_nametags)[i]].text);
|
nameTags = {};
|
||||||
Entities.deleteEntity(user_nametags[Object.keys(user_nametags)[i]].background);
|
}
|
||||||
|
|
||||||
|
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 >:(
|
||||||
|
}
|
Loading…
Reference in a new issue