import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../engine/debug/index.js"; import { Mathf } from "../engine/engine_math.js"; import { RoomEvents } from "../engine/engine_networking.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import * as utils from "../engine/engine_utils.js" import { getParam } from "../engine/engine_utils.js"; import { getIconElement } from "../engine/webcomponents/icons.js"; import { Behaviour } from "./Component.js"; const viewParamName = "view"; const debug = utils.getParam("debugsyncedroom"); /** * SyncedRoom is a behaviour that will attempt to join a networked room based on the URL parameters or a random room. * It will also create a button in the menu to join or leave the room. * You can also join a networked room by calling the core methods like `this.context.connection.joinRoom("roomName")`. * * @example Join a networked room * ```typescript * const myObject = new Object3D(); * myObject.addComponent(SyncedRoom, { roomName: "myRoom" }); * ``` * * @example Join a random networked room * ```typescript * const myObject = new Object3D(); * myObject.addComponent(SyncedRoom, { joinRandomRoom: true }); * ``` * * @example Join a random networked room with prefix - this ensures that no room name collisions happen when running multiple applications on the same server instance * ```typescript * const myObject = new Object3D(); * myObject.addComponent(SyncedRoom, { joinRandomRoom: true, roomPrefix: "myApp_" }); * ``` * * @category Networking * @group Components */ export class SyncedRoom extends Behaviour { /** * The name of the room to join. * @default "" */ @serializable() public roomName: string = ""; /** * The URL parameter name to use for the room name. E.g. if set to "room" the URL will look like `?room=roomName`. * @default "room" */ @serializable() public urlParameterName: string = "room"; /** * If true, the room will be joined automatically when this component becomes active. * @default undefined which means it will join a random room if no roomName is set. */ @serializable() public joinRandomRoom?: boolean; /** * If true and no room parameter is found in the URL then no room will be joined. * @default false */ @serializable() public requireRoomParameter: boolean = false; /** * If true, the room will be rejoined automatically when disconnected. * @default true */ @serializable() public autoRejoin: boolean = true; /** * If true, a join/leave room button will be created in the menu. * @default true */ @serializable() public createJoinButton: boolean = true; /** * If true, a join/leave room button for the view only URL will be created in the menu. * @default false */ @serializable() public createViewOnlyButton: boolean = false; /** * Get current room name from the URL parameter or the view parameter. */ get currentRoomName(): string | null { const view = utils.getParam(viewParamName); if (view) return view as string; return utils.getParam(this.urlParameterName) as string; } private _lastJoinedRoom?: string; /** The room prefix to use for the room name. E.g. if set to "room_" and the room name is "name" the final room name will be "room_name". */ @serializable() set roomPrefix(val: string) { this._roomPrefix = val; } get roomPrefix(): string { return this._roomPrefix; } private _roomPrefix: string = ""; /** @internal */ awake() { if (this.joinRandomRoom === undefined && this.roomName?.length <= 0) { this.joinRandomRoom = true; } if (debug) console.log(`SyncedRoom roomName:${this.roomName}, urlParamName:${this.urlParameterName}, joinRandomRoom:${this.joinRandomRoom}`); } /** @internal */ onEnable() { // if the url contains a view parameter override room and join in view mode const viewId = utils.getParam(viewParamName); if (viewId && typeof viewId === "string" && viewId.length > 0) { console.log("Join as viewer"); this.context.connection.joinRoom(viewId, true); return; } // If setup to join a random room this.tryJoinRoom(); if (this.createJoinButton) { const button = this.createRoomButton(); this.context.menu.appendChild(button); } if (this.createViewOnlyButton) { this.onEnableViewOnlyButton() } } /** @internal */ onDisable(): void { this._roomButton?.remove(); this.onDisableViewOnlyButton(); if (this.roomName && this.roomName.length > 0) this.context.connection.leaveRoom(this.roomName); } /** @internal */ onDestroy(): void { this.destroyRoomButton(); } /** Will generate a random room name, set it as an URL parameter and attempt to join the room */ tryJoinRandomRoom() { this.setRandomRoomUrlParameter(); this.tryJoinRoom(); } /** Try to join the currently set roomName */ tryJoinRoom(call: number = 0): boolean { if (call === undefined) call = 0; let hasRoomParameter = false; if (this.urlParameterName?.length > 0) { const val = utils.getParam(this.urlParameterName); if (val && (typeof val === "string" || typeof val === "number")) { hasRoomParameter = true; const roomNameParam = utils.sanitizeString(val.toString()); this.roomName = roomNameParam; } else if (this.joinRandomRoom) { console.log("No room name found in url, generating random one"); this.setRandomRoomUrlParameter(); if (call < 1) return this.tryJoinRoom(call + 1); } } else { if (this.joinRandomRoom && (this.roomName === null || this.roomName === undefined || this.roomName.length <= 0)) { this.roomName = this.generateRoomName(); } } if (this.requireRoomParameter && !hasRoomParameter) { if (debug || isDevEnvironment()) console.warn("[SyncedRoom] Missing required room parameter \"" + this.urlParameterName + "\" in url - will not connect.\nTo allow joining a room without a query parameter you can set \"requireRoomParameter\" to false."); return false; } if (!this.context.connection.isConnected) { this.context.connection.connect(); } this._lastJoinedRoom = this.roomName; if (this._roomPrefix) this.roomName = this._roomPrefix + this.roomName; if (this.roomName.length <= 0) { console.warn("[SyncedRoom] Room name is not set so we can not join a networked room.\nPlease choose one of the following options to fix this:\nA) Set a room name in the SyncedRoom component\nB) Set a room name in the URL parameter \"?" + this.urlParameterName + "=my_room\"\nC) Set \"joinRandomRoom\" to true"); return false; } if (debug) console.log("Join " + this.roomName) this._userWantsToBeInARoom = true; this.context.connection.joinRoom(this.roomName); return true; } private _lastPingTime: number = 0; private _lastRoomTime: number = -1; private _userWantsToBeInARoom = false; /** @internal */ update(): void { if (this.context.connection.isConnected) { if (this.context.time.time - this._lastPingTime > 3) { this._lastPingTime = this.context.time.time; this.context.connection.sendPing(); } if (this.context.connection.isInRoom) { this._lastRoomTime = this.context.time.time; } } if (this._lastRoomTime > 0 && this.context.time.time - this._lastRoomTime > .3) { this._lastRoomTime = -1; if (this.autoRejoin) { if (this._userWantsToBeInARoom) { console.log("Disconnected from networking backend - attempt reconnecting now") this.tryJoinRoom(); } } else if (isDevEnvironment()) console.warn("You are not connected to a room anymore (possibly because the tab was inactive for too long and the server kicked you?)"); } } /** * Get the URL to view the current room in view only mode. */ getViewOnlyUrl(): string | null { if (this.context.connection.isConnected && this.context.connection.currentRoomViewId) { const url = window.location.search; const urlParams = new URLSearchParams(url); if (urlParams.has(this.urlParameterName)) urlParams.delete(this.urlParameterName); urlParams.set(viewParamName, this.context.connection.currentRoomViewId); return window.location.origin + window.location.pathname + "?" + urlParams.toString(); } return null; } private setRandomRoomUrlParameter() { const params = utils.getUrlParams(); const room = this.generateRoomName(); // if we already have this parameter if (utils.getParam(this.urlParameterName)) { params.set(this.urlParameterName, room); } else params.append(this.urlParameterName, room); utils.setState(room, params); } private generateRoomName(): string { let roomName = ""; for (let i = 0; i < 6; i++) { roomName += Math.floor(Math.random() * 10).toFixed(0); } return roomName; } private _roomButton?: HTMLButtonElement; private _roomButtonIconJoin?: HTMLElement; private _roomButtonIconLeave?: HTMLElement; private createRoomButton() { if (this._roomButton) { return this._roomButton; } const button = document.createElement("button"); this._roomButton = button; button.classList.add("create-room-button"); button.setAttribute("priority", "90"); button.onclick = () => { if (this.context.connection.isInRoom) { if (this.urlParameterName) { utils.setParamWithoutReload(this.urlParameterName, null); } this.context.connection.leaveRoom(); this._userWantsToBeInARoom = false; } else { if (this.urlParameterName) { const name = getParam(this.urlParameterName); // true check for ?room= without an actual name if (!name || name === true) { if (this._lastJoinedRoom) utils.setParamWithoutReload(this.urlParameterName, this._lastJoinedRoom); else this.setRandomRoomUrlParameter(); }; } this.tryJoinRoom(); } }; this._roomButtonIconJoin = getIconElement("group"); this._roomButtonIconLeave = getIconElement("group_off"); this.updateRoomButtonState(); this.context.connection.beginListen(RoomEvents.JoinedRoom, this.updateRoomButtonState); this.context.connection.beginListen(RoomEvents.LeftRoom, this.updateRoomButtonState); return button; } private updateRoomButtonState = () => { if (!this._roomButton) return; if (this.context.connection.isInRoom) { this._roomButton.title = "Leave the networked room"; this._roomButton.textContent = "Leave Room"; this._roomButtonIconJoin?.remove(); this._roomButton.prepend(this._roomButtonIconLeave!); } else { this._roomButton.title = "Create or join a networked room"; this._roomButton.textContent = "Join Room"; this._roomButtonIconLeave?.remove(); this._roomButton.prepend(this._roomButtonIconJoin!); } } private destroyRoomButton() { this.context.connection.stopListen(RoomEvents.JoinedRoom, this.updateRoomButtonState); this.context.connection.stopListen(RoomEvents.LeftRoom, this.updateRoomButtonState); } private _viewOnlyButton?: HTMLButtonElement; private onEnableViewOnlyButton() { if (this.context.connection.isConnected) { this.onCreateViewOnlyButton(); } else { this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onCreateViewOnlyButton); this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onCreateViewOnlyButton); } } private onDisableViewOnlyButton() { this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onCreateViewOnlyButton); this._viewOnlyButton?.remove(); } private onCreateViewOnlyButton = () => { if (!this._viewOnlyButton) { const button = document.createElement("button"); this._viewOnlyButton = button; button.classList.add("view-only-button"); button.setAttribute("priority", "90"); button.onclick = () => { const viewUrl = this.getViewOnlyUrl(); if (viewUrl?.length) { // share if (navigator.canShare({ url: viewUrl })) { navigator.share({ url: viewUrl })?.catch(err => { console.warn(err); }); } else { navigator.clipboard.writeText(viewUrl); showBalloonMessage("View only URL copied to clipboard"); } } else { showBalloonWarning("Could not create view only URL"); } }; button.title = "Copy the view only URL: A page accessed by the view only URL can not be modified by visiting users."; button.textContent = "Share View URL"; button.prepend(getIconElement("visibility")); } this.context.menu.appendChild(this._viewOnlyButton); } }