mirror of
https://github.com/overte-org/community-apps.git
synced 2025-04-25 14:32:56 +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
|
||||
// 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 >:(
|
||||
}
|
Loading…
Reference in a new issue