From 43e8301db6ed9cefab13bb3212c80c721d8d3896 Mon Sep 17 00:00:00 2001 From: Sacha Morgese Date: Mon, 17 Jun 2024 17:53:02 +0100 Subject: [PATCH 01/15] Add avatar configuration to change default avatar --- .../client/src/index.ts | 3 ++ .../src/Networked3dWebExperienceClient.ts | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index e305254e..c35e0af6 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -27,6 +27,9 @@ const app = new Networked3dWebExperienceClient(holder, { skyboxHdrJpgUrl: hdrJpgUrl, mmlDocuments: [{ url: `${protocol}//${host}/mml-documents/example-mml.html` }], environmentConfiguration: {}, + avatarConfig: { + availableAvatars: [], + }, }); app.update(); diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 477ce3e3..545ae954 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -63,6 +63,18 @@ type MMLDocumentConfiguration = { }; }; +type AvatarConfig = { + availableAvatars: Array<{ + avatarFileType: "glb" | "html"; + thumbnailUrl?: string; + isDefaultAvatar?: boolean; + avatarFileUrl: string; + avatarName?: string; + }>; + allowCustomAvatars?: boolean; + customAvatarWebhookUrl?: string; +}; + export type Networked3dWebExperienceClientConfig = { sessionToken: string; chatNetworkAddress?: string; @@ -76,6 +88,7 @@ export type Networked3dWebExperienceClientConfig = { skyboxHdrJpgUrl: string; enableTweakPane?: boolean; updateURLLocation?: boolean; + avatarConfig?: AvatarConfig; }; export class Networked3dWebExperienceClient { @@ -423,10 +436,24 @@ export class Networked3dWebExperienceClient { throw new Error("Own identity not found"); } + const defaultAvatar = + this.config.avatarConfig?.availableAvatars.find((avatar) => avatar.isDefaultAvatar) ?? + this.config.avatarConfig?.availableAvatars[0]; + + const characterDescription = defaultAvatar + ? ({ + meshFileUrl: + defaultAvatar.avatarFileType === "glb" ? defaultAvatar.avatarFileUrl : undefined, + mmlCharacterUrl: + defaultAvatar.avatarFileType === "html" ? defaultAvatar.avatarFileUrl : undefined, + mmlCharacterString: undefined, + } as CharacterDescription) + : ownIdentity.characterDescription; + this.characterManager.spawnLocalCharacter( this.clientId!, ownIdentity.username, - ownIdentity.characterDescription, + characterDescription, spawnPosition, spawnRotation, ); From 7f4b9508279fe59c2460f76a3318bbcf65cd9da4 Mon Sep 17 00:00:00 2001 From: Sacha Morgese Date: Tue, 18 Jun 2024 16:46:46 +0100 Subject: [PATCH 02/15] Updated to use the new MMl World Config Avatar Schema Updated to handle MML string as well --- .../client/src/index.ts | 2 +- .../src/Networked3dWebExperienceClient.ts | 39 ++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index c35e0af6..1a5b7b10 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -29,7 +29,7 @@ const app = new Networked3dWebExperienceClient(holder, { environmentConfiguration: {}, avatarConfig: { availableAvatars: [], - }, + } }); app.update(); diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 545ae954..530d6772 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -63,14 +63,33 @@ type MMLDocumentConfiguration = { }; }; +type AvatarType = | { + thumbnailUrl?: string; + name?: string; + meshFileUrl: string; + mmlCharacterString?: null; + mmlCharacterUrl?: null; + isDefaultAvatar?: boolean; + } + | { + thumbnailUrl?: string; + name?: string; + meshFileUrl?: null; + mmlCharacterString: string; + mmlCharacterUrl?: null; + isDefaultAvatar?: boolean; + } + | { + thumbnailUrl?: string; + name?: string; + meshFileUrl?: null; + mmlCharacterString?: null; + mmlCharacterUrl: string; + isDefaultAvatar?: boolean; + }; + type AvatarConfig = { - availableAvatars: Array<{ - avatarFileType: "glb" | "html"; - thumbnailUrl?: string; - isDefaultAvatar?: boolean; - avatarFileUrl: string; - avatarName?: string; - }>; + availableAvatars: Array; allowCustomAvatars?: boolean; customAvatarWebhookUrl?: string; }; @@ -443,10 +462,10 @@ export class Networked3dWebExperienceClient { const characterDescription = defaultAvatar ? ({ meshFileUrl: - defaultAvatar.avatarFileType === "glb" ? defaultAvatar.avatarFileUrl : undefined, + defaultAvatar.meshFileUrl ?? undefined, mmlCharacterUrl: - defaultAvatar.avatarFileType === "html" ? defaultAvatar.avatarFileUrl : undefined, - mmlCharacterString: undefined, + defaultAvatar.mmlCharacterUrl ?? undefined, + mmlCharacterString: defaultAvatar.mmlCharacterString ?? undefined, } as CharacterDescription) : ownIdentity.characterDescription; From f66c8b99a7af44333a47a1a9cecfb42007d2eb14 Mon Sep 17 00:00:00 2001 From: Sacha Morgese Date: Wed, 19 Jun 2024 08:50:20 +0100 Subject: [PATCH 03/15] Linting --- example/multi-user-3d-web-experience/client/src/index.ts | 2 +- .../src/Networked3dWebExperienceClient.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index 1a5b7b10..c35e0af6 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -29,7 +29,7 @@ const app = new Networked3dWebExperienceClient(holder, { environmentConfiguration: {}, avatarConfig: { availableAvatars: [], - } + }, }); app.update(); diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 530d6772..2be1fa06 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -63,7 +63,8 @@ type MMLDocumentConfiguration = { }; }; -type AvatarType = | { +type AvatarType = + | { thumbnailUrl?: string; name?: string; meshFileUrl: string; @@ -461,10 +462,8 @@ export class Networked3dWebExperienceClient { const characterDescription = defaultAvatar ? ({ - meshFileUrl: - defaultAvatar.meshFileUrl ?? undefined, - mmlCharacterUrl: - defaultAvatar.mmlCharacterUrl ?? undefined, + meshFileUrl: defaultAvatar.meshFileUrl ?? undefined, + mmlCharacterUrl: defaultAvatar.mmlCharacterUrl ?? undefined, mmlCharacterString: defaultAvatar.mmlCharacterString ?? undefined, } as CharacterDescription) : ownIdentity.characterDescription; From 0cf845e38d753ef063b6cc51439b40ce22c7c20f Mon Sep 17 00:00:00 2001 From: Sacha Morgese Date: Mon, 24 Jun 2024 18:17:03 +0100 Subject: [PATCH 04/15] Basic functionality and UI --- .../client/src/index.ts | 25 ++- .../server/src/BasicUserAuthenticator.ts | 24 ++- .../server/src/index.ts | 1 + package-lock.json | 25 +++ packages/3d-web-avatar-selection-ui/build.ts | 15 ++ .../3d-web-avatar-selection-ui/package.json | 36 ++++ .../avatar-selection-ui/AvatarSelectionUI.tsx | 66 ++++++++ .../AvatarPanel/AvatarSectionUIComponent.tsx | 155 ++++++++++++++++++ .../AvatarSelectionUIComponent.module.css | 88 ++++++++++ .../src/avatar-selection-ui/helpers.tsx | 31 ++++ .../src/avatar-selection-ui/icons/Chat.svg | 1 + .../avatar-selection-ui/images/gradient.ts | 2 + .../src/images.d.ts | 4 + .../3d-web-avatar-selection-ui/src/index.ts | 1 + .../src/styles.d.ts | 4 + .../3d-web-avatar-selection-ui/tsconfig.json | 23 +++ .../3d-web-experience-client/package.json | 1 + .../src/Networked3dWebExperienceClient.ts | 94 +++++++++-- .../ChatPanel/TextChatUIComponent.module.css | 2 +- .../src/UserNetworkingServer.ts | 1 + 20 files changed, 577 insertions(+), 22 deletions(-) create mode 100644 packages/3d-web-avatar-selection-ui/build.ts create mode 100644 packages/3d-web-avatar-selection-ui/package.json create mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx create mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx create mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css create mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/helpers.tsx create mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg create mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/images/gradient.ts create mode 100644 packages/3d-web-avatar-selection-ui/src/images.d.ts create mode 100644 packages/3d-web-avatar-selection-ui/src/index.ts create mode 100644 packages/3d-web-avatar-selection-ui/src/styles.d.ts create mode 100644 packages/3d-web-avatar-selection-ui/tsconfig.json diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index c35e0af6..3ce46b0e 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -28,8 +28,29 @@ const app = new Networked3dWebExperienceClient(holder, { mmlDocuments: [{ url: `${protocol}//${host}/mml-documents/example-mml.html` }], environmentConfiguration: {}, avatarConfig: { - availableAvatars: [], - }, + availableAvatars: [ + { + thumbnailUrl:"https://e7.pngegg.com/pngimages/799/987/png-clipart-computer-icons-avatar-icon-design-avatar-heroes-computer-wallpaper-thumbnail.png", + mmlCharacterUrl: "https://mmlstorage.com/eYIAFx/1706889930376.html", + name: "Avatar 1", + isDefaultAvatar: false, + }, + { + thumbnailUrl: "https://models.readyplayer.me/65a8dba831b23abb4f401bae.png", + meshFileUrl: "https://models.readyplayer.me/65a8dba831b23abb4f401bae.glb", + name: "Avatar 2", + isDefaultAvatar: true, + }, + { + thumbnailUrl: "https://static.vecteezy.com/system/resources/previews/019/896/008/original/male-user-avatar-icon-in-flat-design-style-person-signs-illustration-png.png", + mmlCharacterString: "\n" + + "", + name: "Avatar 3", + isDefaultAvatar: false, + }, + ], + allowCustomAvatars: true, + } }); app.update(); diff --git a/example/multi-user-3d-web-experience/server/src/BasicUserAuthenticator.ts b/example/multi-user-3d-web-experience/server/src/BasicUserAuthenticator.ts index 5cdc86eb..c11dd7e7 100644 --- a/example/multi-user-3d-web-experience/server/src/BasicUserAuthenticator.ts +++ b/example/multi-user-3d-web-experience/server/src/BasicUserAuthenticator.ts @@ -93,12 +93,28 @@ export class BasicUserAuthenticator { } public onClientUserIdentityUpdate(clientId: number, msg: UserIdentity): UserData | null { - // This implementation does not allow updating user data after initial connect. - // To allow updating user data after initial connect, return the UserData object that reflects the requested change. - // Returning null will not update the user data. - return null; + + const user = this.usersByClientId.get(clientId); + + if (!user) { + console.error(`onClientUserIdentityUpdate - unknown clientId ${clientId}`); + return null; + } + + if (!user.userData) { + console.error(`onClientUserIdentityUpdate - no user data for clientId ${clientId}`); + return null; + } + + const newUserData: UserData = { + username: msg.username ?? user.userData.username, + characterDescription: msg.characterDescription ?? user.userData.characterDescription, + }; + + this.usersByClientId.set(clientId, { ...user, userData: newUserData }); + return newUserData; } public onClientDisconnect(clientId: number) { diff --git a/example/multi-user-3d-web-experience/server/src/index.ts b/example/multi-user-3d-web-experience/server/src/index.ts index 67c3d2d3..616147f7 100644 --- a/example/multi-user-3d-web-experience/server/src/index.ts +++ b/example/multi-user-3d-web-experience/server/src/index.ts @@ -32,6 +32,7 @@ const characterDescription: CharacterDescription = { // // `, }; + const userAuthenticator = new BasicUserAuthenticator(characterDescription, { /* This option allows sessions that are reconnecting from a previous run of the server to connect even if the present a diff --git a/package-lock.json b/package-lock.json index ded0939e..e267db28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2227,6 +2227,10 @@ "resolved": "packages/3d-web-avatar-editor-ui", "link": true }, + "node_modules/@mml-io/3d-web-avatar-selection-ui": { + "resolved": "packages/3d-web-avatar-selection-ui", + "link": true + }, "node_modules/@mml-io/3d-web-client-core": { "resolved": "packages/3d-web-client-core", "link": true @@ -17060,6 +17064,26 @@ "esbuild-css-modules-plugin": "3.1.0" } }, + "packages/3d-web-avatar-selection-ui": { + "name": "@mml-io/3d-web-avatar-selection-ui", + "version": "0.17.0", + "dependencies": { + "express": "4.19.2", + "express-ws": "5.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "ws": "^8.16.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/express-ws": "^3.0.4", + "@types/node": "^20.12.7", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", + "@types/ws": "^8.5.10", + "esbuild-css-modules-plugin": "3.1.0" + } + }, "packages/3d-web-client-core": { "name": "@mml-io/3d-web-client-core", "version": "0.17.0", @@ -17085,6 +17109,7 @@ "name": "@mml-io/3d-web-experience-client", "version": "0.17.0", "dependencies": { + "@mml-io/3d-web-avatar-selection-ui": "^0.17.0", "@mml-io/3d-web-client-core": "^0.17.0", "@mml-io/3d-web-text-chat": "^0.17.0", "@mml-io/3d-web-user-networking": "^0.17.0", diff --git a/packages/3d-web-avatar-selection-ui/build.ts b/packages/3d-web-avatar-selection-ui/build.ts new file mode 100644 index 00000000..6e3aa3be --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/build.ts @@ -0,0 +1,15 @@ +import cssModulesPlugin from "esbuild-css-modules-plugin"; + +import { handleLibraryBuild } from "../../utils/build-library"; + +handleLibraryBuild({ + plugins: [ + cssModulesPlugin({ + inject: true, + emitDeclarationFile: true, + }), + ], + loader: { + ".svg": "text", + }, +}); diff --git a/packages/3d-web-avatar-selection-ui/package.json b/packages/3d-web-avatar-selection-ui/package.json new file mode 100644 index 00000000..24d92512 --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/package.json @@ -0,0 +1,36 @@ +{ + "name": "@mml-io/3d-web-avatar-selection-ui", + "version": "0.17.0", + "publishConfig": { + "access": "public" + }, + "main": "./build/index.js", + "types": "./build/index.d.ts", + "type": "module", + "files": [ + "/build" + ], + "scripts": { + "build": "tsx ./build.ts --build", + "iterate": "tsx ./build.ts --watch", + "type-check": "tsc --noEmit", + "lint": "eslint \"./{src,test}/**/*.{js,jsx,ts,tsx}\" --max-warnings 0", + "lint-fix": "eslint \"./{src,test}/**/*.{js,jsx,ts,tsx}\" --fix" + }, + "dependencies": { + "express": "4.19.2", + "express-ws": "5.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "ws": "^8.16.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/express-ws": "^3.0.4", + "@types/node": "^20.12.7", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", + "@types/ws": "^8.5.10", + "esbuild-css-modules-plugin": "3.1.0" + } +} diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx new file mode 100644 index 00000000..229568d2 --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx @@ -0,0 +1,66 @@ +import { createRef, forwardRef } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, Root } from "react-dom/client"; +import * as React from "react"; +import { AvatarSelectionUIComponent } from "./components/AvatarPanel/AvatarSectionUIComponent"; +import { AvatarType, CustomAvatarType } from "@mml-io/3d-web-experience-client"; + +export type StringToHslOptions = { + hueThresholds?: [number, number][]; + saturationThresholds?: [number, number][]; + lightnessThresholds?: [number, number][]; +}; + +const DEFAULT_HUE_RANGES: [number, number][] = [[10, 350]]; +const DEFAULT_SATURATION_RANGES: [number, number][] = [[60, 100]]; +const DEFAULT_LIGHTNESS_RANGES: [number, number][] = [[65, 75]]; + +export const DEFAULT_HSL_OPTIONS: StringToHslOptions = { + hueThresholds: DEFAULT_HUE_RANGES, + saturationThresholds: DEFAULT_SATURATION_RANGES, + lightnessThresholds: DEFAULT_LIGHTNESS_RANGES, +}; + +const ForwardedAvatarSelectionUIComponent = forwardRef(AvatarSelectionUIComponent); + +export type AvatarSelectionUIProps = { + holderElement: HTMLElement; + clientId: number; + visibleByDefault?: boolean; + stringToHslOptions?: StringToHslOptions; + availableAvatars: AvatarType[]; + selectedAvatar?: CustomAvatarType; + sendMessageToServerMethod: (avatar: CustomAvatarType) => void; + enableCustomAvatar?: boolean; +}; + +export class AvatarSelectionUI { + private root: Root; + private appRef: React.RefObject = createRef(); + + private wrapper = document.createElement("div"); + + constructor(private config: AvatarSelectionUIProps) { + this.config.holderElement.appendChild(this.wrapper); + this.root = createRoot(this.wrapper); + } + + private onUpdateUserAvatar = (avatar: CustomAvatarType) => { + this.config.sendMessageToServerMethod(avatar); + }; + + init() { + flushSync(() => + this.root.render( + , + ), + ); + } +} diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx new file mode 100644 index 00000000..75ccea46 --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import { useRef, useState, ForwardRefRenderFunction, MouseEvent } from "react"; + +import { useClickOutside } from "../../helpers"; +import ChatIcon from "../../icons/Chat.svg"; +import { StringToHslOptions } from "../../AvatarSelectionUI"; + +import styles from "./AvatarSelectionUIComponent.module.css"; +import { AvatarType } from "@mml-io/3d-web-experience-client"; + +type AvatarSelectionUIProps = { + onUpdateUserAvatar: (avatar: AvatarType) => void; + visibleByDefault?: boolean; + stringToHslOptions?: StringToHslOptions; + availableAvatars: AvatarType[]; + enableCustomAvatar?: boolean; +}; + +type CustomAvatarType = AvatarType & { + isCustomAvatar?: boolean; +}; + +export const AvatarSelectionUIComponent: ForwardRefRenderFunction = ( + props: AvatarSelectionUIProps, +) => { + const visibleByDefault: boolean = props.visibleByDefault ?? false; + const [isVisible, setIsVisible] = useState(visibleByDefault); + const [selectedAvatar, setSelectedAvatar] = useState(undefined); + const [customAvatarType, setCustomAvatarType] = useState<"glb" | "html" | "mml">("glb"); + const [customAvatarValue, setCustomAvatarValue] = useState(""); + + const handleRootClick = (e: MouseEvent) => { + e.stopPropagation(); + }; + + const avatarPanelRef = useClickOutside(() => { + setIsVisible(false); + }); + + const selectAvatar = (avatar: CustomAvatarType) => { + setSelectedAvatar(avatar); + props.onUpdateUserAvatar(avatar); + }; + + const addCustomAvatar = () => { + if (!customAvatarValue) { + return; + } + + const selectedAvatar = { + mmlCharacterString: customAvatarType === "mml" ? customAvatarValue : undefined, + mmlCharacterUrl: customAvatarType === "html" ? customAvatarValue : undefined, + meshFileUrl: customAvatarType === "glb" ? customAvatarValue : undefined, + isCustomAvatar: true, + } as CustomAvatarType; + + setSelectedAvatar(selectedAvatar); + props.onUpdateUserAvatar(selectedAvatar); + }; + + return ( + <> +
+
setIsVisible(true)}> + +
+
+
+
+
+

Choose your avatar

+ +
+
+ {props.availableAvatars.map((avatar, index) => { + const isSelected = + !selectedAvatar?.isCustomAvatar && + ((selectedAvatar?.meshFileUrl && + selectedAvatar?.meshFileUrl === avatar.meshFileUrl) || + (selectedAvatar?.mmlCharacterUrl && + selectedAvatar?.mmlCharacterUrl === avatar.mmlCharacterUrl) || + (selectedAvatar?.mmlCharacterString && + selectedAvatar?.mmlCharacterString === avatar.mmlCharacterString)); + + return ( +
selectAvatar(avatar)} + > + {avatar.name} +

{avatar.name}

+
+ ); + })} +
+ {props.enableCustomAvatar && ( +
+

Custom Avatar Section

+ setCustomAvatarType("glb")} + defaultChecked={customAvatarType === "glb"} + checked={customAvatarType === "glb"} + /> + + setCustomAvatarType("html")} + defaultChecked={customAvatarType === "html"} + checked={customAvatarType === "html"} + /> + + setCustomAvatarType("mml")} + defaultChecked={customAvatarType === "mml"} + checked={customAvatarType === "mml"} + /> + + setCustomAvatarValue(value)} + /> + + {selectedAvatar?.isCustomAvatar && ( +
+

Custom Avatar Selected

+
+ )} +
+ )} +
+
+ + ); +}; diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css new file mode 100644 index 00000000..b9a227da --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css @@ -0,0 +1,88 @@ +.menuButton { + width: 70px; + min-height: 70px; + position: fixed; + top: 12px; + left: 12px; + z-index: 102; +} + + +.avatarSelectionContainer { + position: absolute; + display: flex; + top: 0; + left: 0; + right: 0; + bottom: 0; + justify-content: center; + align-items: center; +} + +.avatarSelectionUi { + width: 95%; + height: 90%; + max-width: 1200px; + border-radius: 10px; + background: #000; + padding: 7px; + opacity: 0.7; + overflow-y: scroll; +} + +.avatarSelectionUiHeader { + color: #ffffff; + text-align: center; + position: relative; +} + +.avatarSelectionUiCloseButton { + position: absolute; + right: 20px; + top: 20px; +} + +.avatarSelectionUiContent { + padding: 10px; + margin-top: 20px; + display: flex; + justify-content: space-between; +} + +.avatarSelectionUiAvatar { + flex: 0 0 32%; + aspect-ratio: 1; + position: relative; + color: #ffffff; + text-align: center; + cursor: pointer; +} + +.avatarSelectionUiAvatar img { + aspect-ratio: 1; + width: 100% +} + +.selectedAvatar { + border: 2px solid #00ff00; + box-sizing: border-box; +} + +.avatarSelectionUiAvatar img:hover { + opacity: 0.7; +} + +.customAvatarSection { + justify-content: center; + margin-top: 20px; + color: white; +} + +.customAvatarSection h2 { + text-align: center; +} + +.customAvatarInput { + display: block; + width: 20%; +} \ No newline at end of file diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/helpers.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/helpers.tsx new file mode 100644 index 00000000..d6f3cf2a --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/helpers.tsx @@ -0,0 +1,31 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; + +type ClickAwayCallback = (event: MouseEvent | TouchEvent) => void; + +export function useClickOutside(cb: ClickAwayCallback) { + const ref = useRef(null); + const refCb = useRef(cb); + + useLayoutEffect(() => { + refCb.current = cb; + }); + + useEffect(() => { + const handler = (e: MouseEvent | TouchEvent) => { + const element = ref.current; + if (element && !element.contains(e.target as Node)) { + refCb.current(e); + } + }; + + document.addEventListener("mousedown", handler); + document.addEventListener("touchstart", handler); + + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("touchstart", handler); + }; + }, []); + + return ref; +} diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg new file mode 100644 index 00000000..e8eb6b4b --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/images/gradient.ts b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/images/gradient.ts new file mode 100644 index 00000000..8d5ccb82 --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/images/gradient.ts @@ -0,0 +1,2 @@ +export const gradient = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAH0CAYAAAD8PUeXAAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AYht+2SrVUHOwg4pChCkILoiKOWoUiVAi1QqsOJpf+QZOGJMXFUXAtOPizWHVwcdbVwVUQBH9AXF2cFF2kxO+SQosY7zju4b3vfbn7DvA3Kkw1u8YBVbOMdDIhZHOrQvAVvQjRHENMYqY+J4opeI6ve/j4fhfnWd51f44+JW8ywCcQzzLdsIg3iKc3LZ3zPnGElSSF+Jw4ZtAFiR+5Lrv8xrnosJ9nRoxMep44QiwUO1juYFYyVOIp4qiiapTvz7qscN7irFZqrHVP/sJwXltZ5jqtYSSxiCWIECCjhjIqsBCnXSPFRJrOEx7+IccvkksmVxmMHAuoQoXk+MH/4HdvzcLkhJsUTgDdL7b9MQIEd4Fm3ba/j227eQIEnoErre2vNoCZT9LrbS16BPRvAxfXbU3eAy53gMEnXTIkRwrQ8hcKwPsZfVMOGLgFQmtu31rnOH0AMtSr1A1wcAiMFil73ePdPZ19+7em1b8fjlxysniMtzsAAAAGYktHRAAAAAAAAPlDu38AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnCQoMNiFpV3vAAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAW1JREFUSMftlLFuwzAMRO8ekP7/x5pUM6UDScVGi6AdumUII5u0xcc7S5IkAGwJG7BtJBssIxC2wLKhLm1sg23tlXf46d4vA8aWwRid9tCUVLsWU6ZuzTIyvZqF8RlBCAw2SBZ9ZU9WxYjsWmLVDXD9z27dro0G+Peo1JZn6Etis/UoBuY0dg85+zF1tpqst6DuT6LqJIvOXV4qlcZGYojQFpRnG33v7+TsgX3PXthqAiXR3m16KVlEeRY1m5qooE6aAwVdLtnu1Nl6XbITtbevIo/wr8lfUU62LarrW0DFgVFJWJIVaDtWGidqm7Ae2/31bJ6jMz0L+Szo2U1n3lH6Z4RX+FvG8W7/9vFQZi3fT/G2Yx009TlPcSXqy2NgvA8j7THNaabH4/FAkt7hHd7hHf4t3D/vd3JlstZKMjPJlYuVa5GZaxJrVd1nlayuy8hFZBxEZJIRWZcZmcRxBLlWEEckkUcQsUIft9vtC/MzaCwwmDyNAAAAAElFTkSuQmCC"; diff --git a/packages/3d-web-avatar-selection-ui/src/images.d.ts b/packages/3d-web-avatar-selection-ui/src/images.d.ts new file mode 100644 index 00000000..5e52f805 --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/images.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const content: string; + export default content; +} diff --git a/packages/3d-web-avatar-selection-ui/src/index.ts b/packages/3d-web-avatar-selection-ui/src/index.ts new file mode 100644 index 00000000..7c0fe75b --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/index.ts @@ -0,0 +1 @@ +export { AvatarSelectionUI, AvatarSelectionUIProps, type StringToHslOptions } from "./avatar-selection-ui/AvatarSelectionUI"; \ No newline at end of file diff --git a/packages/3d-web-avatar-selection-ui/src/styles.d.ts b/packages/3d-web-avatar-selection-ui/src/styles.d.ts new file mode 100644 index 00000000..d10f68a2 --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/styles.d.ts @@ -0,0 +1,4 @@ +declare module "*.css" { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/packages/3d-web-avatar-selection-ui/tsconfig.json b/packages/3d-web-avatar-selection-ui/tsconfig.json new file mode 100644 index 00000000..e2abbe6a --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "module": "esnext", + "target": "ES6", + "jsx": "react-jsx", + "lib": ["es2020", "dom"], + "esModuleInterop": true, + "sourceMap": true, + "allowJs": true, + "types": ["node", "jest"], + "strictNullChecks": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "downlevelIteration": true, + "outDir": "./build", + "incremental": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "test/**/*", "./build.ts"], + "exclude": ["**/build/*", "types-src"] +} diff --git a/packages/3d-web-experience-client/package.json b/packages/3d-web-experience-client/package.json index 31e2ec13..9642151d 100644 --- a/packages/3d-web-experience-client/package.json +++ b/packages/3d-web-experience-client/package.json @@ -22,6 +22,7 @@ "@mml-io/3d-web-text-chat": "^0.17.0", "@mml-io/3d-web-user-networking": "^0.17.0", "@mml-io/3d-web-voice-chat": "^0.17.0", + "@mml-io/3d-web-avatar-selection-ui": "^0.17.0", "mml-web": "^0.16.0", "three": "0.163.0" }, diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 2be1fa06..a621f6b1 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -30,7 +30,9 @@ import { AUTHENTICATION_FAILED_ERROR_TYPE, CONNECTION_LIMIT_REACHED_ERROR_TYPE, ServerErrorType, + USER_UPDATE_MESSAGE_TYPE, UserData, + UserIdentity, UserNetworkingClient, UserNetworkingClientUpdate, WebsocketStatus, @@ -43,6 +45,7 @@ import { setGlobalMMLScene, } from "mml-web"; import { AudioListener, Euler, Scene, Vector3 } from "three"; +import { AvatarSelectionUI } from "@mml-io/3d-web-avatar-selection-ui"; type MMLDocumentConfiguration = { url: string; @@ -63,7 +66,7 @@ type MMLDocumentConfiguration = { }; }; -type AvatarType = +export type AvatarType = | { thumbnailUrl?: string; name?: string; @@ -71,7 +74,7 @@ type AvatarType = mmlCharacterString?: null; mmlCharacterUrl?: null; isDefaultAvatar?: boolean; - } + } | { thumbnailUrl?: string; name?: string; @@ -142,6 +145,8 @@ export class Networked3dWebExperienceClient { private networkChat: ChatNetworkingClient | null = null; private textChatUI: TextChatUI | null = null; + private avatarSelectionUI: AvatarSelectionUI | null = null; + private voiceChatManager: VoiceChatManager | null = null; private readonly latestCharacterObject = { characterState: null as null | CharacterState, @@ -154,6 +159,7 @@ export class Networked3dWebExperienceClient { private errorScreen?: ErrorScreen; private currentRequestAnimationFrame: number | null = null; + constructor( private holderElement: HTMLElement, private config: Networked3dWebExperienceClientConfig, @@ -305,6 +311,7 @@ export class Networked3dWebExperienceClient { */ this.connectToVoiceChat(); this.connectToTextChat(); + this.mountAvatarSelectionUI(); this.spawnCharacter(); } }); @@ -329,6 +336,7 @@ export class Networked3dWebExperienceClient { characterDescription: CharacterDescription; } { const user = this.userProfiles.get(clientId)!; + if (!user) { throw new Error(`Failed to resolve user for clientId ${clientId}`); } @@ -347,12 +355,58 @@ export class Networked3dWebExperienceClient { this.characterManager.respawnIfPresent(id); } + private updateUserAvatar(avatar: AvatarType) { + if (this.clientId === null) { + throw new Error("Client ID not set"); + } + const user = this.userProfiles.get(this.clientId); + if (!user) { + throw new Error("User not found"); + } + + const newUser = { + ...user, + characterDescription: { + meshFileUrl: avatar.meshFileUrl ?? undefined, + mmlCharacterUrl: avatar.mmlCharacterUrl ?? undefined, + mmlCharacterString: avatar.mmlCharacterString ?? undefined, + }, + } as UserData; + + this.userProfiles.set(this.clientId, newUser); + this.updateUserProfile(this.clientId, newUser); + } + private sendChatMessageToServer(message: string): void { this.mmlCompositionScene.onChatMessage(message); if (this.clientId === null || this.networkChat === null) return; this.networkChat.sendChatMessage(message); } + private sendIdentityUpdateToServer(avatar: AvatarType) { + if (!this.clientId) { + throw new Error("Client ID not set"); + } + + const userProfile = this.userProfiles.get(this.clientId); + + if (!userProfile) { + throw new Error("User profile not found"); + } + + this.networkClient.sendMessage({ + type: USER_UPDATE_MESSAGE_TYPE, + userIdentity: { + username: userProfile.username, + characterDescription: { + mmlCharacterString: avatar.mmlCharacterString, + mmlCharacterUrl: avatar.mmlCharacterUrl, + meshFileUrl: avatar.meshFileUrl, + } as CharacterDescription, + }, + }); + } + private connectToVoiceChat() { if (this.clientId === null) return; @@ -412,6 +466,27 @@ export class Networked3dWebExperienceClient { } } + private mountAvatarSelectionUI() { + if (this.clientId === null) { + throw new Error("Client ID not set"); + } + const ownIdentity = this.userProfiles.get(this.clientId); + if (!ownIdentity) { + throw new Error("Own identity not found"); + } + + this.avatarSelectionUI = new AvatarSelectionUI({ + holderElement: this.element, + clientId: this.clientId, + visibleByDefault: false, + stringToHslOptions: this.config.userNameToColorOptions, + availableAvatars: this.config.avatarConfig?.availableAvatars ?? [], + sendMessageToServerMethod: this.sendIdentityUpdateToServer.bind(this), + enableCustomAvatar: this.config.avatarConfig?.allowCustomAvatars, + }); + this.avatarSelectionUI.init(); + } + public update(): void { this.timeManager.update(); this.characterManager.update(); @@ -456,25 +531,14 @@ export class Networked3dWebExperienceClient { throw new Error("Own identity not found"); } - const defaultAvatar = - this.config.avatarConfig?.availableAvatars.find((avatar) => avatar.isDefaultAvatar) ?? - this.config.avatarConfig?.availableAvatars[0]; - - const characterDescription = defaultAvatar - ? ({ - meshFileUrl: defaultAvatar.meshFileUrl ?? undefined, - mmlCharacterUrl: defaultAvatar.mmlCharacterUrl ?? undefined, - mmlCharacterString: defaultAvatar.mmlCharacterString ?? undefined, - } as CharacterDescription) - : ownIdentity.characterDescription; - this.characterManager.spawnLocalCharacter( this.clientId!, ownIdentity.username, - characterDescription, + ownIdentity.characterDescription, spawnPosition, spawnRotation, ); + if (cameraPosition !== null) { this.cameraManager.camera.position.copy(cameraPosition); this.cameraManager.setTarget( diff --git a/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css b/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css index 27f318fb..b43c0ee3 100644 --- a/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css +++ b/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css @@ -1,4 +1,4 @@ -.uiHover { +.menuButton { width: 70px; min-height: 70px; position: fixed; diff --git a/packages/3d-web-user-networking/src/UserNetworkingServer.ts b/packages/3d-web-user-networking/src/UserNetworkingServer.ts index 4fa74108..38b137e4 100644 --- a/packages/3d-web-user-networking/src/UserNetworkingServer.ts +++ b/packages/3d-web-user-networking/src/UserNetworkingServer.ts @@ -302,6 +302,7 @@ export class UserNetworkingServer { clientId, message.userIdentity, ); + let resolvedAuthorizedUserData; if (authorizedUserData instanceof Promise) { resolvedAuthorizedUserData = await authorizedUserData; From 05efaa304a02d9cb44dd9fc100f8eaddd0665d88 Mon Sep 17 00:00:00 2001 From: Sacha Morgese Date: Tue, 25 Jun 2024 18:16:32 +0100 Subject: [PATCH 05/15] Updated style Fixed problems and inconsistencies Added new icon --- .../client/src/index.ts | 25 +--- package-lock.json | 1 + .../3d-web-avatar-selection-ui/package.json | 1 + .../avatar-selection-ui/AvatarSelectionUI.tsx | 6 +- .../AvatarPanel/AvatarSectionUIComponent.tsx | 134 +++++++++-------- .../AvatarSelectionUIComponent.module.css | 138 +++++++++++++++--- .../src/avatar-selection-ui/icons/Avatar.svg | 7 + .../src/avatar-selection-ui/icons/Chat.svg | 1 - .../3d-web-avatar-selection-ui/src/index.ts | 6 +- .../src/Networked3dWebExperienceClient.ts | 7 +- 10 files changed, 213 insertions(+), 113 deletions(-) create mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Avatar.svg delete mode 100644 packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index 3ce46b0e..7450ed98 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -27,30 +27,7 @@ const app = new Networked3dWebExperienceClient(holder, { skyboxHdrJpgUrl: hdrJpgUrl, mmlDocuments: [{ url: `${protocol}//${host}/mml-documents/example-mml.html` }], environmentConfiguration: {}, - avatarConfig: { - availableAvatars: [ - { - thumbnailUrl:"https://e7.pngegg.com/pngimages/799/987/png-clipart-computer-icons-avatar-icon-design-avatar-heroes-computer-wallpaper-thumbnail.png", - mmlCharacterUrl: "https://mmlstorage.com/eYIAFx/1706889930376.html", - name: "Avatar 1", - isDefaultAvatar: false, - }, - { - thumbnailUrl: "https://models.readyplayer.me/65a8dba831b23abb4f401bae.png", - meshFileUrl: "https://models.readyplayer.me/65a8dba831b23abb4f401bae.glb", - name: "Avatar 2", - isDefaultAvatar: true, - }, - { - thumbnailUrl: "https://static.vecteezy.com/system/resources/previews/019/896/008/original/male-user-avatar-icon-in-flat-design-style-person-signs-illustration-png.png", - mmlCharacterString: "\n" + - "", - name: "Avatar 3", - isDefaultAvatar: false, - }, - ], - allowCustomAvatars: true, - } + avatarConfig: {} }); app.update(); diff --git a/package-lock.json b/package-lock.json index e267db28..d52572eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17068,6 +17068,7 @@ "name": "@mml-io/3d-web-avatar-selection-ui", "version": "0.17.0", "dependencies": { + "@mml-io/3d-web-experience-client": "^0.17.0", "express": "4.19.2", "express-ws": "5.0.2", "react": "^18.2.0", diff --git a/packages/3d-web-avatar-selection-ui/package.json b/packages/3d-web-avatar-selection-ui/package.json index 24d92512..714c797a 100644 --- a/packages/3d-web-avatar-selection-ui/package.json +++ b/packages/3d-web-avatar-selection-ui/package.json @@ -18,6 +18,7 @@ "lint-fix": "eslint \"./{src,test}/**/*.{js,jsx,ts,tsx}\" --fix" }, "dependencies": { + "@mml-io/3d-web-experience-client": "^0.17.0", "express": "4.19.2", "express-ws": "5.0.2", "react": "^18.2.0", diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx index 229568d2..f5e718ea 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx @@ -1,9 +1,10 @@ +import { AvatarType, CustomAvatarType } from "@mml-io/3d-web-experience-client"; import { createRef, forwardRef } from "react"; +import * as React from "react"; import { flushSync } from "react-dom"; import { createRoot, Root } from "react-dom/client"; -import * as React from "react"; + import { AvatarSelectionUIComponent } from "./components/AvatarPanel/AvatarSectionUIComponent"; -import { AvatarType, CustomAvatarType } from "@mml-io/3d-web-experience-client"; export type StringToHslOptions = { hueThresholds?: [number, number][]; @@ -29,7 +30,6 @@ export type AvatarSelectionUIProps = { visibleByDefault?: boolean; stringToHslOptions?: StringToHslOptions; availableAvatars: AvatarType[]; - selectedAvatar?: CustomAvatarType; sendMessageToServerMethod: (avatar: CustomAvatarType) => void; enableCustomAvatar?: boolean; }; diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx index 75ccea46..a836d353 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx @@ -1,12 +1,16 @@ -import React from "react"; -import { useRef, useState, ForwardRefRenderFunction, MouseEvent } from "react"; +import { AvatarType } from "@mml-io/3d-web-experience-client"; +import React, { + KeyboardEvent, + useRef, + useState, + ForwardRefRenderFunction, + MouseEvent, +} from "react"; -import { useClickOutside } from "../../helpers"; -import ChatIcon from "../../icons/Chat.svg"; import { StringToHslOptions } from "../../AvatarSelectionUI"; +import AvatarIcon from "../../icons/Avatar.svg"; import styles from "./AvatarSelectionUIComponent.module.css"; -import { AvatarType } from "@mml-io/3d-web-experience-client"; type AvatarSelectionUIProps = { onUpdateUserAvatar: (avatar: AvatarType) => void; @@ -28,80 +32,86 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction(undefined); const [customAvatarType, setCustomAvatarType] = useState<"glb" | "html" | "mml">("glb"); const [customAvatarValue, setCustomAvatarValue] = useState(""); + const inputRef = useRef(null); const handleRootClick = (e: MouseEvent) => { e.stopPropagation(); }; - const avatarPanelRef = useClickOutside(() => { - setIsVisible(false); - }); - const selectAvatar = (avatar: CustomAvatarType) => { setSelectedAvatar(avatar); props.onUpdateUserAvatar(avatar); }; + const handleInputChange = (e: React.ChangeEvent) => { + setCustomAvatarValue(e.target.value); + }; + const addCustomAvatar = () => { if (!customAvatarValue) { return; } - const selectedAvatar = { + const newSelectedAvatar = { mmlCharacterString: customAvatarType === "mml" ? customAvatarValue : undefined, mmlCharacterUrl: customAvatarType === "html" ? customAvatarValue : undefined, meshFileUrl: customAvatarType === "glb" ? customAvatarValue : undefined, isCustomAvatar: true, } as CustomAvatarType; - setSelectedAvatar(selectedAvatar); - props.onUpdateUserAvatar(selectedAvatar); + setSelectedAvatar(newSelectedAvatar); + props.onUpdateUserAvatar(newSelectedAvatar); + }; + + const handleKeyPress = (e: KeyboardEvent) => { + e.stopPropagation(); }; return ( <>
-
setIsVisible(true)}> - -
-
-
-
-
-

Choose your avatar

- + {!isVisible && ( +
setIsVisible(true)}> +
-
- {props.availableAvatars.map((avatar, index) => { - const isSelected = - !selectedAvatar?.isCustomAvatar && - ((selectedAvatar?.meshFileUrl && - selectedAvatar?.meshFileUrl === avatar.meshFileUrl) || - (selectedAvatar?.mmlCharacterUrl && - selectedAvatar?.mmlCharacterUrl === avatar.mmlCharacterUrl) || - (selectedAvatar?.mmlCharacterString && - selectedAvatar?.mmlCharacterString === avatar.mmlCharacterString)); + )} +
+ {isVisible && ( +
+
+
+

Choose your avatar

+ +
+
+ {props.availableAvatars.map((avatar, index) => { + const isSelected = + !selectedAvatar?.isCustomAvatar && + ((selectedAvatar?.meshFileUrl && + selectedAvatar?.meshFileUrl === avatar.meshFileUrl) || + (selectedAvatar?.mmlCharacterUrl && + selectedAvatar?.mmlCharacterUrl === avatar.mmlCharacterUrl) || + (selectedAvatar?.mmlCharacterString && + selectedAvatar?.mmlCharacterString === avatar.mmlCharacterString)); - return ( -
selectAvatar(avatar)} - > - {avatar.name} -

{avatar.name}

-
- ); - })} + return ( +
selectAvatar(avatar)} + > + {avatar.name} +

{avatar.name}

+
+ ); + })} +
{props.enableCustomAvatar && (
@@ -133,14 +143,18 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction - setCustomAvatarValue(value)} - /> - +
+ + +
{selectedAvatar?.isCustomAvatar && (

Custom Avatar Selected

@@ -149,7 +163,7 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction )}
-
+ )} ); }; diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css index b9a227da..734b768c 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSelectionUIComponent.module.css @@ -3,31 +3,88 @@ min-height: 70px; position: fixed; top: 12px; - left: 12px; + right: 12px; z-index: 102; + user-select: none; } +.openTab { + background-color: rgba(0, 0, 0, 0.8); + border-radius: 50%; + width: 42px; + height: 42px; + position: fixed; + top: 12px; + right: 12px; + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.8); + border: 1px solid rgba(255, 255, 255, 0.21); + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; +} + +.openTab img { + filter: invert(60%); + width: 35px; + height: 35px; + transition: all 0.3s ease-in-out; + position: relative; + top: 3px; + left: 4px; + transform: scale(0.7); +} .avatarSelectionContainer { - position: absolute; - display: flex; - top: 0; - left: 0; - right: 0; - bottom: 0; - justify-content: center; - align-items: center; + position: fixed; + top: 11px; + right: 60px; + width: 30%; + max-width: 720px; + padding: 8px; + background: #000000b3; + border-radius: 8px; + font-family: "Helvetica", "Lucida Sans", "Lucida Sans Regular", "Lucida Grande", + "Lucida Sans Unicode", Geneva, Verdana, sans-serif; + z-index: 103; + user-select: none; + padding-bottom: 20px; } .avatarSelectionUi { - width: 95%; - height: 90%; - max-width: 1200px; - border-radius: 10px; - background: #000; - padding: 7px; - opacity: 0.7; + padding-right: 4px; overflow-y: scroll; + max-height: 630px; +} + +.avatarSelectionUi::-webkit-scrollbar { + -webkit-appearance: none; + width: 10px; +} + +.avatarSelectionUi::-webkit-scrollbar-thumb { + margin: 4px; + border-radius: 5px; + background-color: #ffffff90; + box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); +} + +.closeButton { + font-family: "arial black"; + background: #000000b3; + color: #ffffff90; + border-radius: 50%; + width: 42px; + height: 42px; + position: fixed; + top: 12px; + right: 12px; + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.8); + border: 1px solid rgba(255, 255, 255, 0.21); + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; } .avatarSelectionUiHeader { @@ -46,11 +103,12 @@ padding: 10px; margin-top: 20px; display: flex; - justify-content: space-between; + gap: 4%; + flex-wrap: wrap; } .avatarSelectionUiAvatar { - flex: 0 0 32%; + flex: 0 0 22%; aspect-ratio: 1; position: relative; color: #ffffff; @@ -60,7 +118,7 @@ .avatarSelectionUiAvatar img { aspect-ratio: 1; - width: 100% + width: 100%; } .selectedAvatar { @@ -82,7 +140,47 @@ text-align: center; } +.customAvatarSection label { + font-size: 20px; + margin-right: 8px; +} + +.customAvatarSection input[type="radio"] { + transform: scale(1.5); + margin-right: 4px; +} + +.customAvatarInputSection { + margin-top: 10px; + display: flex; +} + .customAvatarInput { + font-family: 'Helvetica', 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; + font-size: 15px; + flex: 1; + padding: 10px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.5); + margin-right: 8px; + color: #000000; + display: inline-block; + width: 40%; + background-color: #ffffff; +} + +.customAvatarInputSection button { + width: 60px; + border-radius: 4px; + border: 0; + color: #ffffff; + background-color: #ffffff90; + box-sizing: border-box; + cursor: pointer; + padding: 0; display: block; - width: 20%; +} + +.customAvatarInputSection button[disabled] { + background-color: #ffffff50; } \ No newline at end of file diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Avatar.svg b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Avatar.svg new file mode 100644 index 00000000..657bb6c3 --- /dev/null +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Avatar.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg deleted file mode 100644 index e8eb6b4b..00000000 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/icons/Chat.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/3d-web-avatar-selection-ui/src/index.ts b/packages/3d-web-avatar-selection-ui/src/index.ts index 7c0fe75b..39ae11f6 100644 --- a/packages/3d-web-avatar-selection-ui/src/index.ts +++ b/packages/3d-web-avatar-selection-ui/src/index.ts @@ -1 +1,5 @@ -export { AvatarSelectionUI, AvatarSelectionUIProps, type StringToHslOptions } from "./avatar-selection-ui/AvatarSelectionUI"; \ No newline at end of file +export { + AvatarSelectionUI, + AvatarSelectionUIProps, + type StringToHslOptions, +} from "./avatar-selection-ui/AvatarSelectionUI"; diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index a621f6b1..7da85d5a 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -1,3 +1,4 @@ +import { AvatarSelectionUI } from "@mml-io/3d-web-avatar-selection-ui"; import { AnimationConfig, CameraManager, @@ -45,7 +46,6 @@ import { setGlobalMMLScene, } from "mml-web"; import { AudioListener, Euler, Scene, Vector3 } from "three"; -import { AvatarSelectionUI } from "@mml-io/3d-web-avatar-selection-ui"; type MMLDocumentConfiguration = { url: string; @@ -74,7 +74,7 @@ export type AvatarType = mmlCharacterString?: null; mmlCharacterUrl?: null; isDefaultAvatar?: boolean; - } + } | { thumbnailUrl?: string; name?: string; @@ -93,7 +93,7 @@ export type AvatarType = }; type AvatarConfig = { - availableAvatars: Array; + availableAvatars?: Array; allowCustomAvatars?: boolean; customAvatarWebhookUrl?: string; }; @@ -159,7 +159,6 @@ export class Networked3dWebExperienceClient { private errorScreen?: ErrorScreen; private currentRequestAnimationFrame: number | null = null; - constructor( private holderElement: HTMLElement, private config: Networked3dWebExperienceClientConfig, From f14255761dcd1ba884cf780043ac1ab681d06aed Mon Sep 17 00:00:00 2001 From: Sacha Morgese Date: Tue, 25 Jun 2024 18:31:13 +0100 Subject: [PATCH 06/15] Minor fixes --- .../client/src/index.ts | 2 +- .../avatar-selection-ui/AvatarSelectionUI.tsx | 22 ++++--------------- .../AvatarPanel/AvatarSectionUIComponent.tsx | 7 +----- .../3d-web-avatar-selection-ui/src/index.ts | 6 +---- .../src/Networked3dWebExperienceClient.ts | 2 -- 5 files changed, 7 insertions(+), 32 deletions(-) diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index 7450ed98..d01df005 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -27,7 +27,7 @@ const app = new Networked3dWebExperienceClient(holder, { skyboxHdrJpgUrl: hdrJpgUrl, mmlDocuments: [{ url: `${protocol}//${host}/mml-documents/example-mml.html` }], environmentConfiguration: {}, - avatarConfig: {} + avatarConfig: {}, }); app.update(); diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx index f5e718ea..c7cc0f0c 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx @@ -1,4 +1,4 @@ -import { AvatarType, CustomAvatarType } from "@mml-io/3d-web-experience-client"; +import { AvatarType } from "@mml-io/3d-web-experience-client"; import { createRef, forwardRef } from "react"; import * as React from "react"; import { flushSync } from "react-dom"; @@ -6,29 +6,16 @@ import { createRoot, Root } from "react-dom/client"; import { AvatarSelectionUIComponent } from "./components/AvatarPanel/AvatarSectionUIComponent"; -export type StringToHslOptions = { - hueThresholds?: [number, number][]; - saturationThresholds?: [number, number][]; - lightnessThresholds?: [number, number][]; -}; - -const DEFAULT_HUE_RANGES: [number, number][] = [[10, 350]]; -const DEFAULT_SATURATION_RANGES: [number, number][] = [[60, 100]]; -const DEFAULT_LIGHTNESS_RANGES: [number, number][] = [[65, 75]]; +const ForwardedAvatarSelectionUIComponent = forwardRef(AvatarSelectionUIComponent); -export const DEFAULT_HSL_OPTIONS: StringToHslOptions = { - hueThresholds: DEFAULT_HUE_RANGES, - saturationThresholds: DEFAULT_SATURATION_RANGES, - lightnessThresholds: DEFAULT_LIGHTNESS_RANGES, +export type CustomAvatarType = AvatarType & { + isCustomAvatar?: boolean; }; -const ForwardedAvatarSelectionUIComponent = forwardRef(AvatarSelectionUIComponent); - export type AvatarSelectionUIProps = { holderElement: HTMLElement; clientId: number; visibleByDefault?: boolean; - stringToHslOptions?: StringToHslOptions; availableAvatars: AvatarType[]; sendMessageToServerMethod: (avatar: CustomAvatarType) => void; enableCustomAvatar?: boolean; @@ -56,7 +43,6 @@ export class AvatarSelectionUI { ref={this.appRef} onUpdateUserAvatar={this.onUpdateUserAvatar} visibleByDefault={false} - stringToHslOptions={this.config.stringToHslOptions} availableAvatars={this.config.availableAvatars} enableCustomAvatar={this.config.enableCustomAvatar} />, diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx index a836d353..98b14782 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx @@ -7,7 +7,7 @@ import React, { MouseEvent, } from "react"; -import { StringToHslOptions } from "../../AvatarSelectionUI"; +import { CustomAvatarType } from "../../AvatarSelectionUI"; import AvatarIcon from "../../icons/Avatar.svg"; import styles from "./AvatarSelectionUIComponent.module.css"; @@ -15,15 +15,10 @@ import styles from "./AvatarSelectionUIComponent.module.css"; type AvatarSelectionUIProps = { onUpdateUserAvatar: (avatar: AvatarType) => void; visibleByDefault?: boolean; - stringToHslOptions?: StringToHslOptions; availableAvatars: AvatarType[]; enableCustomAvatar?: boolean; }; -type CustomAvatarType = AvatarType & { - isCustomAvatar?: boolean; -}; - export const AvatarSelectionUIComponent: ForwardRefRenderFunction = ( props: AvatarSelectionUIProps, ) => { diff --git a/packages/3d-web-avatar-selection-ui/src/index.ts b/packages/3d-web-avatar-selection-ui/src/index.ts index 39ae11f6..37fc528c 100644 --- a/packages/3d-web-avatar-selection-ui/src/index.ts +++ b/packages/3d-web-avatar-selection-ui/src/index.ts @@ -1,5 +1 @@ -export { - AvatarSelectionUI, - AvatarSelectionUIProps, - type StringToHslOptions, -} from "./avatar-selection-ui/AvatarSelectionUI"; +export { AvatarSelectionUI, AvatarSelectionUIProps } from "./avatar-selection-ui/AvatarSelectionUI"; diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 7da85d5a..1c798ef7 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -33,7 +33,6 @@ import { ServerErrorType, USER_UPDATE_MESSAGE_TYPE, UserData, - UserIdentity, UserNetworkingClient, UserNetworkingClientUpdate, WebsocketStatus, @@ -478,7 +477,6 @@ export class Networked3dWebExperienceClient { holderElement: this.element, clientId: this.clientId, visibleByDefault: false, - stringToHslOptions: this.config.userNameToColorOptions, availableAvatars: this.config.avatarConfig?.availableAvatars ?? [], sendMessageToServerMethod: this.sendIdentityUpdateToServer.bind(this), enableCustomAvatar: this.config.avatarConfig?.allowCustomAvatars, From 93b26a20ae0a3c7a8263b8138d4c84294bcfbb9e Mon Sep 17 00:00:00 2001 From: Sacha Morgese Date: Thu, 27 Jun 2024 18:21:58 +0100 Subject: [PATCH 07/15] Added placeholders to custom avatar selection and changed MML to textarea --- .../client/src/index.ts | 4 +- .../avatar-selection-ui/AvatarSelectionUI.tsx | 10 +-- .../AvatarPanel/AvatarSectionUIComponent.tsx | 72 +++++++++++++------ 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index d01df005..ae11bc07 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -27,7 +27,9 @@ const app = new Networked3dWebExperienceClient(holder, { skyboxHdrJpgUrl: hdrJpgUrl, mmlDocuments: [{ url: `${protocol}//${host}/mml-documents/example-mml.html` }], environmentConfiguration: {}, - avatarConfig: {}, + avatarConfig: { + allowCustomAvatars: true, + }, }); app.update(); diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx index c7cc0f0c..fa4d54da 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarSelectionUI.tsx @@ -1,4 +1,4 @@ -import { AvatarType } from "@mml-io/3d-web-experience-client"; +import { Avatar } from "@mml-io/3d-web-experience-client"; import { createRef, forwardRef } from "react"; import * as React from "react"; import { flushSync } from "react-dom"; @@ -8,7 +8,7 @@ import { AvatarSelectionUIComponent } from "./components/AvatarPanel/AvatarSecti const ForwardedAvatarSelectionUIComponent = forwardRef(AvatarSelectionUIComponent); -export type CustomAvatarType = AvatarType & { +export type CustomAvatar = Avatar & { isCustomAvatar?: boolean; }; @@ -16,8 +16,8 @@ export type AvatarSelectionUIProps = { holderElement: HTMLElement; clientId: number; visibleByDefault?: boolean; - availableAvatars: AvatarType[]; - sendMessageToServerMethod: (avatar: CustomAvatarType) => void; + availableAvatars: Avatar[]; + sendMessageToServerMethod: (avatar: CustomAvatar) => void; enableCustomAvatar?: boolean; }; @@ -32,7 +32,7 @@ export class AvatarSelectionUI { this.root = createRoot(this.wrapper); } - private onUpdateUserAvatar = (avatar: CustomAvatarType) => { + private onUpdateUserAvatar = (avatar: CustomAvatar) => { this.config.sendMessageToServerMethod(avatar); }; diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx index 98b14782..b552c279 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/components/AvatarPanel/AvatarSectionUIComponent.tsx @@ -1,4 +1,4 @@ -import { AvatarType } from "@mml-io/3d-web-experience-client"; +import { Avatar } from "@mml-io/3d-web-experience-client"; import React, { KeyboardEvent, useRef, @@ -7,38 +7,41 @@ import React, { MouseEvent, } from "react"; -import { CustomAvatarType } from "../../AvatarSelectionUI"; +import { CustomAvatar } from "../../AvatarSelectionUI"; import AvatarIcon from "../../icons/Avatar.svg"; import styles from "./AvatarSelectionUIComponent.module.css"; type AvatarSelectionUIProps = { - onUpdateUserAvatar: (avatar: AvatarType) => void; + onUpdateUserAvatar: (avatar: Avatar) => void; visibleByDefault?: boolean; - availableAvatars: AvatarType[]; + availableAvatars: Avatar[]; enableCustomAvatar?: boolean; }; +type CustomAvatarType = "glb" | "html" | "mml"; + export const AvatarSelectionUIComponent: ForwardRefRenderFunction = ( props: AvatarSelectionUIProps, ) => { const visibleByDefault: boolean = props.visibleByDefault ?? false; const [isVisible, setIsVisible] = useState(visibleByDefault); - const [selectedAvatar, setSelectedAvatar] = useState(undefined); - const [customAvatarType, setCustomAvatarType] = useState<"glb" | "html" | "mml">("glb"); + const [selectedAvatar, setSelectedAvatar] = useState(undefined); + const [customAvatarType, setCustomAvatarType] = useState("glb"); const [customAvatarValue, setCustomAvatarValue] = useState(""); const inputRef = useRef(null); + const textareaRef = useRef(null); const handleRootClick = (e: MouseEvent) => { e.stopPropagation(); }; - const selectAvatar = (avatar: CustomAvatarType) => { + const selectAvatar = (avatar: CustomAvatar) => { setSelectedAvatar(avatar); props.onUpdateUserAvatar(avatar); }; - const handleInputChange = (e: React.ChangeEvent) => { + const handleInputChange = (e: React.ChangeEvent) => { setCustomAvatarValue(e.target.value); }; @@ -52,16 +55,32 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction) => { + const handleKeyPress = (e: KeyboardEvent) => { e.stopPropagation(); }; + const handleTypeSwitch = (type: CustomAvatarType) => { + setCustomAvatarType(type); + setCustomAvatarValue(""); + } + + const getPlaceholderByType = (type: CustomAvatarType) => { + switch (type) { + case "glb": + return "https://example.com/avatar.glb"; + case "html": + return "https://example.com/avatar.html"; + case "mml": + return '\n
@@ -115,7 +134,7 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction setCustomAvatarType("glb")} + onChange={() => handleTypeSwitch("glb")} defaultChecked={customAvatarType === "glb"} checked={customAvatarType === "glb"} /> @@ -124,7 +143,7 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction setCustomAvatarType("html")} + onChange={() => handleTypeSwitch("html")} defaultChecked={customAvatarType === "html"} checked={customAvatarType === "html"} /> @@ -133,19 +152,32 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction setCustomAvatarType("mml")} + onChange={() => handleTypeSwitch("mml")} defaultChecked={customAvatarType === "mml"} checked={customAvatarType === "mml"} />
- + {customAvatarType === "mml" ? ( +