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 796a135e..6711141c 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -24,9 +24,12 @@ const app = new Networked3dWebExperienceClient(holder, { sprintAnimationFileUrl, doubleJumpAnimationFileUrl, }, - skyboxHdrJpgUrl: hdrJpgUrl, - mmlDocuments: [{ url: `${protocol}//${host}/mml-documents/example-mml.html` }], - environmentConfiguration: {}, + mmlDocuments: { example: { url: `${protocol}//${host}/mml-documents/example-mml.html` } }, + environmentConfiguration: { + skybox: { + hdrJpgUrl: hdrJpgUrl, + }, + }, avatarConfiguration: { availableAvatars: [], }, 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 413b7b85..c458f813 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 @@ -3,7 +3,7 @@ import { createRef, forwardRef } from "react"; import { flushSync } from "react-dom"; import { createRoot, Root } from "react-dom/client"; -import { AvatarType } from "./AvatarType"; +import { AvatarConfiguration, AvatarType } from "./AvatarType"; import { AvatarSelectionUIComponent } from "./components/AvatarPanel/AvatarSectionUIComponent"; const ForwardedAvatarSelectionUIComponent = forwardRef(AvatarSelectionUIComponent); @@ -14,12 +14,9 @@ export type CustomAvatar = AvatarType & { export type AvatarSelectionUIProps = { holderElement: HTMLElement; - clientId: number; visibleByDefault?: boolean; - availableAvatars: Array; sendMessageToServerMethod: (avatar: CustomAvatar) => void; - enableCustomAvatar?: boolean; -}; +} & AvatarConfiguration; export class AvatarSelectionUI { private root: Root; @@ -36,15 +33,23 @@ export class AvatarSelectionUI { this.config.sendMessageToServerMethod(avatar); }; + public updateAvatarConfig(avatarConfig: AvatarConfiguration) { + this.config = { + ...this.config, + ...avatarConfig, + }; + this.init(); + } + init() { flushSync(() => this.root.render( , ), ); diff --git a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarType.ts b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarType.ts index 7e4b2b1e..e2a3a110 100644 --- a/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarType.ts +++ b/packages/3d-web-avatar-selection-ui/src/avatar-selection-ui/AvatarType.ts @@ -19,3 +19,8 @@ export type AvatarType = { mmlCharacterUrl: string; } ); + +export type AvatarConfiguration = { + availableAvatars: Array; + allowCustomAvatars?: 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 cd4cfd00..5af31b83 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 @@ -16,7 +16,7 @@ type AvatarSelectionUIProps = { onUpdateUserAvatar: (avatar: AvatarType) => void; visibleByDefault?: boolean; availableAvatars: AvatarType[]; - enableCustomAvatar?: boolean; + allowCustomAvatars?: boolean; }; enum CustomAvatarType { @@ -92,6 +92,10 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction
@@ -146,7 +150,7 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction
)} - {props.enableCustomAvatar && ( + {props.allowCustomAvatars && (
{!!props.availableAvatars.length &&
}

Custom Avatar Section

diff --git a/packages/3d-web-avatar-selection-ui/src/index.ts b/packages/3d-web-avatar-selection-ui/src/index.ts index 260f1d74..6db684de 100644 --- a/packages/3d-web-avatar-selection-ui/src/index.ts +++ b/packages/3d-web-avatar-selection-ui/src/index.ts @@ -1,2 +1,2 @@ export * from "./avatar-selection-ui/AvatarSelectionUI"; -export { AvatarType } from "./avatar-selection-ui/AvatarType"; +export * from "./avatar-selection-ui/AvatarType"; diff --git a/packages/3d-web-client-core/src/mml/MMLCompositionScene.ts b/packages/3d-web-client-core/src/mml/MMLCompositionScene.ts index b7cfce15..ef43f6b2 100644 --- a/packages/3d-web-client-core/src/mml/MMLCompositionScene.ts +++ b/packages/3d-web-client-core/src/mml/MMLCompositionScene.ts @@ -11,6 +11,7 @@ import { ChatProbe, LoadingProgressManager, LinkProps, + MMLDocumentTimeManager, } from "mml-web"; import { AudioListener, Group, Object3D, PerspectiveCamera, Scene, WebGLRenderer } from "three"; @@ -30,6 +31,7 @@ export class MMLCompositionScene { public group: Group; public readonly mmlScene: IMMLScene; + public readonly documentTimeManager: MMLDocumentTimeManager; private readonly promptManager: PromptManager; private readonly interactionManager: InteractionManager; private readonly interactionListener: InteractionListener; @@ -49,6 +51,7 @@ export class MMLCompositionScene { this.interactionManager = interactionManager; this.interactionListener = interactionListener; this.loadingProgressManager = new LoadingProgressManager(); + this.documentTimeManager = new MMLDocumentTimeManager(); this.mmlScene = { getAudioListener: () => this.config.audioListener, diff --git a/packages/3d-web-client-core/src/rendering/composer.ts b/packages/3d-web-client-core/src/rendering/composer.ts index 6efd731b..cee06d3e 100644 --- a/packages/3d-web-client-core/src/rendering/composer.ts +++ b/packages/3d-web-client-core/src/rendering/composer.ts @@ -31,6 +31,7 @@ import { Scene, ShadowMapType, SRGBColorSpace, + Texture, ToneMapping, Vector2, WebGLRenderer, @@ -65,7 +66,14 @@ export type EnvironmentConfiguration = { blurriness?: number; azimuthalAngle?: number; polarAngle?: number; - }; + } & ( + | { + hdrJpgUrl: string; + } + | { + hdrUrl: string; + } + ); envMap?: { intensity?: number; }; @@ -88,8 +96,6 @@ export class Composer { private resizeListener: () => void; public resolution: Vector2 = new Vector2(this.width, this.height); - private isEnvHDRI: boolean = false; - private readonly scene: Scene; public postPostScene: Scene; private readonly camera: PerspectiveCamera; @@ -123,6 +129,14 @@ export class Composer { private ambientLight: AmbientLight | null = null; private environmentConfiguration?: EnvironmentConfiguration; + private skyboxState: { + src: { + hdrJpgUrl?: string; + hdrUrl?: string; + }; + latestPromise: Promise | null; + } = { src: {}, latestPromise: null }; + public sun: Sun | null = null; public spawnSun: boolean; @@ -264,6 +278,14 @@ export class Composer { this.scene.add(this.sun); } + if (this.environmentConfiguration?.skybox) { + if ("hdrJpgUrl" in this.environmentConfiguration.skybox) { + this.useHDRJPG(this.environmentConfiguration.skybox.hdrJpgUrl); + } else if ("hdrUrl" in this.environmentConfiguration.skybox) { + this.useHDRI(this.environmentConfiguration.skybox.hdrUrl); + } + } + this.updateSunValues(); this.resizeListener = () => { @@ -273,6 +295,22 @@ export class Composer { this.fitContainer(); } + public updateEnvironmentConfiguration(environmentConfiguration: EnvironmentConfiguration) { + this.environmentConfiguration = environmentConfiguration; + + if (environmentConfiguration.skybox) { + if ("hdrJpgUrl" in environmentConfiguration.skybox) { + this.useHDRJPG(environmentConfiguration.skybox.hdrJpgUrl); + } else if ("hdrUrl" in environmentConfiguration.skybox) { + this.useHDRI(environmentConfiguration.skybox.hdrUrl); + } + } + + this.updateSkyboxAndEnvValues(); + this.updateAmbientLightValues(); + this.updateSunValues(); + } + public setupTweakPane(tweakPane: TweakPane) { tweakPane.setupRenderPane( this.effectComposer, @@ -368,74 +406,76 @@ export class Composer { ); } - public useHDRJPG(url: string, fromFile: boolean = false): void { - const pmremGenerator = new PMREMGenerator(this.renderer); - const hdrJpg = new HDRJPGLoader(this.renderer).load(url, () => { - const hdrJpgEquirectangularMap = hdrJpg.renderTarget.texture; - - hdrJpgEquirectangularMap.mapping = EquirectangularReflectionMapping; - hdrJpgEquirectangularMap.needsUpdate = true; - - const envMap = pmremGenerator!.fromEquirectangular(hdrJpgEquirectangularMap).texture; - if (envMap) { - envMap.colorSpace = LinearSRGBColorSpace; - envMap.needsUpdate = true; - this.scene.environment = envMap; - this.scene.environmentIntensity = envValues.envMapIntensity; - this.scene.environmentRotation = new Euler( - MathUtils.degToRad(envValues.skyboxPolarAngle), - MathUtils.degToRad(envValues.skyboxAzimuthalAngle), - 0, - ); - this.scene.background = envMap; - this.scene.backgroundIntensity = envValues.skyboxIntensity; - this.scene.backgroundBlurriness = envValues.skyboxBlurriness; - this.scene.backgroundRotation = new Euler( - MathUtils.degToRad(envValues.skyboxPolarAngle), - MathUtils.degToRad(envValues.skyboxAzimuthalAngle), - 0, - ); - this.isEnvHDRI = true; + private async loadHDRJPG(url: string): Promise { + return new Promise((resolve, reject) => { + const pmremGenerator = new PMREMGenerator(this.renderer); + const hdrJpg = new HDRJPGLoader(this.renderer).load(url, () => { + const hdrJpgEquirectangularMap = hdrJpg.renderTarget.texture; + hdrJpgEquirectangularMap.mapping = EquirectangularReflectionMapping; + hdrJpgEquirectangularMap.needsUpdate = true; + + const envMap = pmremGenerator!.fromEquirectangular(hdrJpgEquirectangularMap).texture; hdrJpgEquirectangularMap.dispose(); pmremGenerator!.dispose(); - } - - hdrJpg.dispose(); + hdrJpg.dispose(); + if (envMap) { + envMap.colorSpace = LinearSRGBColorSpace; + envMap.needsUpdate = true; + resolve(envMap); + } else { + reject("Failed to generate environment map"); + } + }); }); } - public useHDRI(url: string, fromFile: boolean = false): void { - if ((this.isEnvHDRI && fromFile === false) || !this.renderer) { - return; - } - const pmremGenerator = new PMREMGenerator(this.renderer); - new RGBELoader(new LoadingManager()).load( - url, - (texture) => { + private async loadHDRi(url: string): Promise { + return new Promise((resolve, reject) => { + const pmremGenerator = new PMREMGenerator(this.renderer); + new RGBELoader(new LoadingManager()).load(url, (texture) => { const envMap = pmremGenerator!.fromEquirectangular(texture).texture; + texture.dispose(); + pmremGenerator!.dispose(); if (envMap) { envMap.colorSpace = LinearSRGBColorSpace; envMap.needsUpdate = true; - this.scene.environment = envMap; - this.scene.environmentIntensity = envValues.envMapIntensity; - this.scene.environmentRotation = new Euler( - MathUtils.degToRad(envValues.skyboxPolarAngle), - MathUtils.degToRad(envValues.skyboxAzimuthalAngle), - 0, - ); - this.scene.background = envMap; - this.scene.backgroundIntensity = envValues.skyboxIntensity; - this.scene.backgroundBlurriness = envValues.skyboxBlurriness; - this.isEnvHDRI = true; - texture.dispose(); - pmremGenerator!.dispose(); + resolve(envMap); + } else { + reject("Failed to generate environment map"); } - }, - () => {}, - (error: ErrorEvent) => { - console.error(`Can't load ${url}: ${JSON.stringify(error)}`); - }, - ); + }); + }); + } + + public useHDRJPG(url: string, fromFile: boolean = false): void { + if (this.skyboxState.src.hdrJpgUrl === url) { + return; + } + + const hdrJPGPromise = this.loadHDRJPG(url); + this.skyboxState.src = { hdrJpgUrl: url }; + this.skyboxState.latestPromise = hdrJPGPromise; + hdrJPGPromise.then((envMap) => { + if (this.skyboxState.latestPromise !== hdrJPGPromise) { + return; + } + this.applyEnvMap(envMap); + }); + } + + public useHDRI(url: string): void { + if (this.skyboxState.src.hdrUrl === url) { + return; + } + const hdrPromise = this.loadHDRi(url); + this.skyboxState.src = { hdrUrl: url }; + this.skyboxState.latestPromise = hdrPromise; + hdrPromise.then((envMap) => { + if (this.skyboxState.latestPromise !== hdrPromise) { + return; + } + this.applyEnvMap(envMap); + }); } public setHDRIFromFile(): void { @@ -453,7 +493,7 @@ export class Composer { const fileURL = URL.createObjectURL(file); if (fileURL) { if (extension === "hdr") { - this.useHDRI(fileURL, true); + this.useHDRI(fileURL); } else if (extension === "jpg") { this.useHDRJPG(fileURL); } else { @@ -542,4 +582,22 @@ export class Composer { } this.setAmbientLight(); } + + private applyEnvMap(envMap: Texture) { + this.scene.environment = envMap; + this.scene.environmentIntensity = envValues.envMapIntensity; + this.scene.environmentRotation = new Euler( + MathUtils.degToRad(envValues.skyboxPolarAngle), + MathUtils.degToRad(envValues.skyboxAzimuthalAngle), + 0, + ); + this.scene.background = envMap; + this.scene.backgroundIntensity = envValues.skyboxIntensity; + this.scene.backgroundBlurriness = envValues.skyboxBlurriness; + this.scene.backgroundRotation = new Euler( + MathUtils.degToRad(envValues.skyboxPolarAngle), + MathUtils.degToRad(envValues.skyboxAzimuthalAngle), + 0, + ); + } } diff --git a/packages/3d-web-client-core/src/tweakpane/TweakPane.ts b/packages/3d-web-client-core/src/tweakpane/TweakPane.ts index cd7becce..a0adf8ba 100644 --- a/packages/3d-web-client-core/src/tweakpane/TweakPane.ts +++ b/packages/3d-web-client-core/src/tweakpane/TweakPane.ts @@ -12,6 +12,7 @@ import { FolderApi, Pane } from "tweakpane"; import { CameraManager } from "../camera/CameraManager"; import { LocalController } from "../character/LocalController"; +import { EventHandlerCollection } from "../input/EventHandlerCollection"; import { BrightnessContrastSaturation } from "../rendering/post-effects/bright-contrast-sat"; import { GaussGrainEffect } from "../rendering/post-effects/gauss-grain"; import { Sun } from "../sun/Sun"; @@ -50,6 +51,7 @@ export class TweakPane { private saveVisibilityInLocalStorage: boolean = true; public guiVisible: boolean = false; private tweakPaneWrapper: HTMLDivElement; + private eventHandlerCollection: EventHandlerCollection; constructor( private holderElement: HTMLElement, @@ -105,25 +107,27 @@ export class TweakPane { this.export = this.gui.addFolder({ title: "import / export", expanded: false }); - this.setupGUIListeners(); - window.addEventListener("keydown", (e) => { - this.processKey(e); - }); - } + this.eventHandlerCollection = new EventHandlerCollection(); - private setupGUIListeners(): void { const gui = this.gui as any; const paneElement: HTMLElement = gui.containerElem_; paneElement.style.right = this.guiVisible ? "0px" : "-450px"; - this.gui.element.addEventListener("mouseenter", () => setTweakpaneActive(true)); - this.gui.element.addEventListener("mousemove", () => setTweakpaneActive(true)); - this.gui.element.addEventListener("mousedown", () => setTweakpaneActive(true)); - this.gui.element.addEventListener("mouseleave", () => setTweakpaneActive(false)); - this.gui.element.addEventListener("mouseup", () => setTweakpaneActive(false)); + this.eventHandlerCollection.add(this.gui.element, "mouseenter", () => setTweakpaneActive(true)); + this.eventHandlerCollection.add(this.gui.element, "mousemove", () => setTweakpaneActive(true)); + this.eventHandlerCollection.add(this.gui.element, "mousedown", () => setTweakpaneActive(true)); + this.eventHandlerCollection.add(this.gui.element, "mouseleave", () => + setTweakpaneActive(false), + ); + this.eventHandlerCollection.add(this.gui.element, "mouseup", () => setTweakpaneActive(false)); + this.eventHandlerCollection.add(window, "keydown", (e) => { + this.processKey(e); + }); } private processKey(e: KeyboardEvent): void { - if (e.key === "p") this.toggleGUI(); + if (e.key === "p") { + this.toggleGUI(); + } } public setupRenderPane( @@ -180,6 +184,7 @@ export class TweakPane { } public dispose() { + this.eventHandlerCollection.clear(); this.gui.dispose(); this.tweakPaneWrapper.remove(); } diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 373c4b9a..fed1713d 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -1,4 +1,8 @@ -import { AvatarSelectionUI, AvatarType } from "@mml-io/3d-web-avatar-selection-ui"; +import { + AvatarSelectionUI, + AvatarType, + AvatarConfiguration, +} from "@mml-io/3d-web-avatar-selection-ui"; import { AnimationConfig, CameraManager, @@ -44,6 +48,7 @@ import { IMMLScene, LoadingProgressManager, registerCustomElementsToWindow, + setGlobalDocumentTimeManager, setGlobalMMLScene, } from "mml-web"; import { AudioListener, Euler, Scene, Vector3 } from "three"; @@ -67,25 +72,23 @@ type MMLDocumentConfiguration = { }; }; -export type AvatarConfiguration = { - availableAvatars?: Array; - allowCustomAvatars?: boolean; -}; - export type Networked3dWebExperienceClientConfig = { + userNetworkAddress: string; sessionToken: string; - chatNetworkAddress?: string; chatVisibleByDefault?: boolean; userNameToColorOptions?: StringToHslOptions; - voiceChatAddress?: string; - userNetworkAddress: string; - mmlDocuments?: Array; animationConfig: AnimationConfig; - environmentConfiguration?: EnvironmentConfiguration; - skyboxHdrJpgUrl: string; - enableTweakPane?: boolean; + voiceChatAddress?: string; updateURLLocation?: boolean; + onServerBroadcast?: (broadcast: { broadcastType: string; payload: any }) => void; +} & UpdatableConfig; + +export type UpdatableConfig = { + chatNetworkAddress?: string | null; + mmlDocuments?: { [key: string]: MMLDocumentConfiguration }; + environmentConfiguration?: EnvironmentConfiguration; avatarConfiguration?: AvatarConfiguration; + enableTweakPane?: boolean; }; export class Networked3dWebExperienceClient { @@ -94,7 +97,7 @@ export class Networked3dWebExperienceClient { private scene = new Scene(); private composer: Composer; - private tweakPane?: TweakPane; + private tweakPane: TweakPane | null = null; private audioListener = new AudioListener(); private cameraManager: CameraManager; @@ -110,7 +113,7 @@ export class Networked3dWebExperienceClient { private virtualJoystick: VirtualJoystick; private mmlCompositionScene: MMLCompositionScene; - private mmlFrames: Array = []; + private mmlFrames: { [key: string]: HTMLElement } = {}; private clientId: number | null = null; private networkClient: UserNetworkingClient; @@ -133,6 +136,7 @@ export class Networked3dWebExperienceClient { private loadingScreen: LoadingScreen; private errorScreen?: ErrorScreen; private currentRequestAnimationFrame: number | null = null; + private groundPlane: GroundPlane | null = null; constructor( private holderElement: HTMLElement, @@ -171,19 +175,10 @@ export class Networked3dWebExperienceClient { spawnSun: true, environmentConfiguration: this.config.environmentConfiguration, }); - - this.composer.useHDRJPG(this.config.skyboxHdrJpgUrl); this.canvasHolder.appendChild(this.composer.renderer.domElement); if (this.config.enableTweakPane !== false) { - this.tweakPane = new TweakPane( - this.element, - this.composer.renderer, - this.scene, - this.composer.effectComposer, - ); - this.cameraManager.setupTweakPane(this.tweakPane); - this.composer.setupTweakPane(this.tweakPane); + this.setupTweakPane(); } const resizeObserver = new ResizeObserver(() => { @@ -251,6 +246,9 @@ export class Networked3dWebExperienceClient { this.disposeWithError(error.message); } }, + onServerBroadcast: (broadcast: { broadcastType: string; payload: any }) => { + this.config.onServerBroadcast?.(broadcast); + }, }); this.characterManager = new CharacterManager({ @@ -274,11 +272,7 @@ export class Networked3dWebExperienceClient { }); this.scene.add(this.characterManager.group); - if (this.config.environmentConfiguration?.groundPlane !== false) { - const groundPlane = new GroundPlane(); - this.collisionsManager.addMeshesGroup(groundPlane); - this.scene.add(groundPlane); - } + this.setGroundPlaneEnabled(this.config.environmentConfiguration?.groundPlane ?? true); this.setupMMLScene(); @@ -302,6 +296,56 @@ export class Networked3dWebExperienceClient { this.loadingProgressManager.setInitialLoad(true); } + private setGroundPlaneEnabled(enabled: boolean) { + if (enabled && this.groundPlane === null) { + this.groundPlane = new GroundPlane(); + this.collisionsManager.addMeshesGroup(this.groundPlane); + this.scene.add(this.groundPlane); + } else if (!enabled && this.groundPlane !== null) { + this.collisionsManager.removeMeshesGroup(this.groundPlane); + this.scene.remove(this.groundPlane); + this.groundPlane = null; + } + } + + public updateConfig(config: Partial) { + this.config = { + ...this.config, + ...config, + }; + if (config.environmentConfiguration) { + this.composer.updateEnvironmentConfiguration(config.environmentConfiguration); + this.setGroundPlaneEnabled(config.environmentConfiguration.groundPlane ?? true); + } + + if (config.avatarConfiguration && this.avatarSelectionUI) { + this.avatarSelectionUI?.updateAvatarConfig(config.avatarConfiguration); + } + + if (config.enableTweakPane !== undefined) { + if (config.enableTweakPane === false && this.tweakPane !== null) { + this.tweakPane.dispose(); + this.tweakPane = null; + } else if (config.enableTweakPane === true && this.tweakPane === null) { + this.setupTweakPane(); + } + } + + if (config.chatNetworkAddress !== undefined) { + if (config.chatNetworkAddress === null && this.networkChat !== null) { + this.networkChat.stop(); + this.networkChat = null; + this.textChatUI?.dispose(); + this.textChatUI = null; + } else { + this.connectToTextChat(); + } + } + if (config.mmlDocuments) { + this.setMMLDocuments(config.mmlDocuments); + } + } + static createFullscreenHolder(): HTMLDivElement { document.body.style.margin = "0"; document.body.style.overflow = "hidden"; @@ -406,6 +450,20 @@ export class Networked3dWebExperienceClient { } } + private setupTweakPane() { + if (this.tweakPane) { + return; + } + this.tweakPane = new TweakPane( + this.element, + this.composer.renderer, + this.scene, + this.composer.effectComposer, + ); + this.cameraManager.setupTweakPane(this.tweakPane); + this.composer.setupTweakPane(this.tweakPane); + } + private connectToTextChat() { if (this.clientId === null) { return; @@ -455,28 +513,12 @@ export class Networked3dWebExperienceClient { } private mountAvatarSelectionUI() { - if ( - !this.config.avatarConfiguration?.availableAvatars?.length && - !this.config.avatarConfiguration?.allowCustomAvatars - ) { - return; - } - - 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, - availableAvatars: this.config.avatarConfiguration?.availableAvatars ?? [], sendMessageToServerMethod: this.sendIdentityUpdateToServer.bind(this), - enableCustomAvatar: this.config.avatarConfiguration?.allowCustomAvatars, + availableAvatars: this.config.avatarConfiguration?.availableAvatars ?? [], + allowCustomAvatars: this.config.avatarConfiguration?.allowCustomAvatars, }); this.avatarSelectionUI.init(); } @@ -551,11 +593,11 @@ export class Networked3dWebExperienceClient { public dispose() { this.networkClient.stop(); this.networkChat?.stop(); - for (const mmlFrame of this.mmlFrames) { - mmlFrame.remove(); + for (const [key, element] of Object.entries(this.mmlFrames)) { + element.remove(); } + this.mmlFrames = {}; this.textChatUI?.dispose(); - this.mmlFrames = []; this.mmlCompositionScene.dispose(); this.composer.dispose(); this.tweakPane?.dispose(); @@ -583,36 +625,9 @@ export class Networked3dWebExperienceClient { }); this.scene.add(this.mmlCompositionScene.group); setGlobalMMLScene(this.mmlCompositionScene.mmlScene as IMMLScene); + setGlobalDocumentTimeManager(this.mmlCompositionScene.documentTimeManager); - if (this.config.mmlDocuments) { - for (const mmlDocument of this.config.mmlDocuments) { - const frameElement = document.createElement("m-frame"); - frameElement.setAttribute("src", mmlDocument.url); - if (mmlDocument.position) { - frameElement.setAttribute("x", mmlDocument.position.x.toString()); - frameElement.setAttribute("y", mmlDocument.position.y.toString()); - frameElement.setAttribute("z", mmlDocument.position.z.toString()); - } - if (mmlDocument.rotation) { - frameElement.setAttribute("rx", mmlDocument.rotation.x.toString()); - frameElement.setAttribute("ry", mmlDocument.rotation.y.toString()); - frameElement.setAttribute("rz", mmlDocument.rotation.z.toString()); - } - if (mmlDocument.scale) { - if (mmlDocument.scale.x !== undefined) { - frameElement.setAttribute("sx", mmlDocument.scale.x.toString()); - } - if (mmlDocument.scale.y !== undefined) { - frameElement.setAttribute("sy", mmlDocument.scale.y.toString()); - } - if (mmlDocument.scale.z !== undefined) { - frameElement.setAttribute("sz", mmlDocument.scale.z.toString()); - } - } - document.body.appendChild(frameElement); - this.mmlFrames.push(frameElement); - } - } + this.setMMLDocuments(this.config.mmlDocuments ?? {}); const mmlProgressManager = this.mmlCompositionScene.mmlScene.getLoadingProgressManager!()!; this.loadingProgressManager.addLoadingDocument(mmlProgressManager, "mml", mmlProgressManager); @@ -621,4 +636,71 @@ export class Networked3dWebExperienceClient { }); mmlProgressManager.setInitialLoad(true); } + + private createFrame(mmlDocument: MMLDocumentConfiguration) { + const frameElement = document.createElement("m-frame"); + frameElement.setAttribute("src", mmlDocument.url); + this.updateFrameAttributes(frameElement, mmlDocument); + return frameElement; + } + + private updateFrameAttributes(frameElement: HTMLElement, mmlDocument: MMLDocumentConfiguration) { + const existingSrc = frameElement.getAttribute("src"); + if (existingSrc !== mmlDocument.url) { + frameElement.setAttribute("src", mmlDocument.url); + } + if (mmlDocument.position) { + frameElement.setAttribute("x", mmlDocument.position.x.toString()); + frameElement.setAttribute("y", mmlDocument.position.y.toString()); + frameElement.setAttribute("z", mmlDocument.position.z.toString()); + } else { + frameElement.setAttribute("x", "0"); + frameElement.setAttribute("y", "0"); + frameElement.setAttribute("z", "0"); + } + if (mmlDocument.rotation) { + frameElement.setAttribute("rx", mmlDocument.rotation.x.toString()); + frameElement.setAttribute("ry", mmlDocument.rotation.y.toString()); + frameElement.setAttribute("rz", mmlDocument.rotation.z.toString()); + } else { + frameElement.setAttribute("rx", "0"); + frameElement.setAttribute("ry", "0"); + frameElement.setAttribute("rz", "0"); + } + if (mmlDocument.scale?.x !== undefined) { + frameElement.setAttribute("sx", mmlDocument.scale.x.toString()); + } else { + frameElement.setAttribute("sx", "1"); + } + if (mmlDocument.scale?.y !== undefined) { + frameElement.setAttribute("sy", mmlDocument.scale.y.toString()); + } else { + frameElement.setAttribute("sy", "1"); + } + if (mmlDocument.scale?.z !== undefined) { + frameElement.setAttribute("sz", mmlDocument.scale.z.toString()); + } else { + frameElement.setAttribute("sz", "1"); + } + } + + private setMMLDocuments(mmlDocuments: { [key: string]: MMLDocumentConfiguration }) { + const newFramesMap: { [key: string]: HTMLElement } = {}; + for (const [key, mmlDocSpec] of Object.entries(mmlDocuments)) { + const existing = this.mmlFrames[key]; + if (!existing) { + const frameElement = this.createFrame(mmlDocSpec); + document.body.appendChild(frameElement); + newFramesMap[key] = frameElement; + } else { + delete this.mmlFrames[key]; + newFramesMap[key] = existing; + this.updateFrameAttributes(existing, mmlDocSpec); + } + } + for (const [key, element] of Object.entries(this.mmlFrames)) { + element.remove(); + } + this.mmlFrames = newFramesMap; + } } diff --git a/packages/3d-web-user-networking/src/UserNetworkingClient.ts b/packages/3d-web-user-networking/src/UserNetworkingClient.ts index 68ef6446..e8a33b53 100644 --- a/packages/3d-web-user-networking/src/UserNetworkingClient.ts +++ b/packages/3d-web-user-networking/src/UserNetworkingClient.ts @@ -2,15 +2,16 @@ import { ReconnectingWebSocket, WebsocketFactory, WebsocketStatus } from "./Reco import { UserNetworkingClientUpdate, UserNetworkingCodec } from "./UserNetworkingCodec"; import { CharacterDescription, - USER_NETWORKING_DISCONNECTED_MESSAGE_TYPE, FromUserNetworkingClientMessage, FromUserNetworkingServerMessage, + USER_NETWORKING_DISCONNECTED_MESSAGE_TYPE, USER_NETWORKING_IDENTITY_MESSAGE_TYPE, USER_NETWORKING_PING_MESSAGE_TYPE, + USER_NETWORKING_SERVER_BROADCAST_MESSAGE_TYPE, USER_NETWORKING_SERVER_ERROR_MESSAGE_TYPE, - UserNetworkingServerErrorType, USER_NETWORKING_USER_AUTHENTICATE_MESSAGE_TYPE, USER_NETWORKING_USER_PROFILE_MESSAGE_TYPE, + UserNetworkingServerErrorType, } from "./UserNetworkingMessages"; export type UserNetworkingClientConfig = { @@ -26,6 +27,7 @@ export type UserNetworkingClientConfig = { characterDescription: CharacterDescription, ) => void; onServerError: (error: { message: string; errorType: UserNetworkingServerErrorType }) => void; + onServerBroadcast?: (broadcast: { broadcastType: string; payload: any }) => void; }; export class UserNetworkingClient extends ReconnectingWebSocket { @@ -74,6 +76,17 @@ export class UserNetworkingClient extends ReconnectingWebSocket { this.sendMessage({ type: "pong" } as FromUserNetworkingClientMessage); break; } + case USER_NETWORKING_SERVER_BROADCAST_MESSAGE_TYPE: { + if (this.config.onServerBroadcast) { + this.config.onServerBroadcast({ + broadcastType: parsed.broadcastType, + payload: parsed.payload, + }); + } else { + console.warn("Unhandled broadcast", parsed); + } + break; + } default: console.error("Unhandled message", parsed); } diff --git a/packages/3d-web-user-networking/src/UserNetworkingMessages.ts b/packages/3d-web-user-networking/src/UserNetworkingMessages.ts index a1d5f678..6d287c84 100644 --- a/packages/3d-web-user-networking/src/UserNetworkingMessages.ts +++ b/packages/3d-web-user-networking/src/UserNetworkingMessages.ts @@ -3,6 +3,7 @@ export const USER_NETWORKING_IDENTITY_MESSAGE_TYPE = "identity"; export const USER_NETWORKING_USER_AUTHENTICATE_MESSAGE_TYPE = "user_auth"; export const USER_NETWORKING_USER_PROFILE_MESSAGE_TYPE = "user_profile"; export const USER_NETWORKING_USER_UPDATE_MESSAGE_TYPE = "user_update"; +export const USER_NETWORKING_SERVER_BROADCAST_MESSAGE_TYPE = "broadcast"; export const USER_NETWORKING_SERVER_ERROR_MESSAGE_TYPE = "error"; export const USER_NETWORKING_PING_MESSAGE_TYPE = "ping"; export const USER_NETWORKING_PONG_MESSAGE_TYPE = "pong"; @@ -57,6 +58,12 @@ export type UserNetworkingServerError = { message: string; }; +export type UserNetworkingServerBroadcast = { + type: typeof USER_NETWORKING_SERVER_BROADCAST_MESSAGE_TYPE; + broadcastType: string; + payload: any; +}; + export type UserNetworkingServerPingMessage = { type: typeof USER_NETWORKING_PING_MESSAGE_TYPE; }; @@ -66,6 +73,7 @@ export type FromUserNetworkingServerMessage = | UserNetworkingProfileMessage | UserNetworkingDisconnectedMessage | UserNetworkingServerPingMessage + | UserNetworkingServerBroadcast | UserNetworkingServerError; export type UserNetworkingClientPongMessage = { diff --git a/packages/3d-web-user-networking/src/UserNetworkingServer.ts b/packages/3d-web-user-networking/src/UserNetworkingServer.ts index 148cdb19..fe652494 100644 --- a/packages/3d-web-user-networking/src/UserNetworkingServer.ts +++ b/packages/3d-web-user-networking/src/UserNetworkingServer.ts @@ -70,9 +70,11 @@ export class UserNetworkingServer { } private pingClients() { + const message: FromUserNetworkingServerMessage = { type: "ping" }; + const messageString = JSON.stringify(message); this.authenticatedClientsById.forEach((client) => { if (client.socket.readyState === WebSocketOpenStatus) { - client.socket.send(JSON.stringify({ type: "ping" } as FromUserNetworkingServerMessage)); + client.socket.send(messageString); } }); } @@ -85,6 +87,20 @@ export class UserNetworkingServer { return id; } + public broadcastMessage(broadcastType: string, broadcastPayload: any) { + const message: FromUserNetworkingServerMessage = { + type: "broadcast", + broadcastType, + payload: broadcastPayload, + }; + const messageString = JSON.stringify(message); + for (const [, client] of this.authenticatedClientsById) { + if (client.socket.readyState === WebSocketOpenStatus) { + client.socket.send(messageString); + } + } + } + public connectClient(socket: WebSocket) { const id = this.getId(); console.log(`Client ID: ${id} joined, waiting for user-identification`);