import type { Subscription } from "rxjs"; import AnimatedTiles from "phaser-animated-tiles"; import { Queue } from "queue-typescript"; import { get, Unsubscriber } from "svelte/store"; import { userMessageManager } from "../../Administration/UserMessageManager"; import { connectionManager } from "../../Connexion/ConnectionManager"; import { coWebsiteManager } from "../../WebRtc/CoWebsiteManager"; import { urlManager } from "../../Url/UrlManager"; import { mediaManager } from "../../WebRtc/MediaManager"; import { UserInputManager } from "../UserInput/UserInputManager"; import { gameManager } from "./GameManager"; import { touchScreenManager } from "../../Touch/TouchScreenManager"; import { PinchManager } from "../UserInput/PinchManager"; import { waScaleManager } from "../Services/WaScaleManager"; import { EmoteManager } from "./EmoteManager"; import { soundManager } from "./SoundManager"; import { SharedVariablesManager } from "./SharedVariablesManager"; import { EmbeddedWebsiteManager } from "./EmbeddedWebsiteManager"; import { lazyLoadPlayerCharacterTextures, loadCustomTexture } from "../Entity/PlayerTexturesLoadingManager"; import { lazyLoadCompanionResource } from "../Companion/CompanionTexturesLoadingManager"; import { iframeListener } from "../../Api/IframeListener"; import { DEBUG_MODE, JITSI_URL, MAX_PER_GROUP, POSITION_DELAY } from "../../Enum/EnvironmentVariable"; import { ProtobufClientUtils } from "../../Network/ProtobufClientUtils"; import { Room } from "../../Connexion/Room"; import { jitsiFactory } from "../../WebRtc/JitsiFactory"; import { TextureError } from "../../Exception/TextureError"; import { localUserStore } from "../../Connexion/LocalUserStore"; import { HtmlUtils } from "../../WebRtc/HtmlUtils"; import { SimplePeer } from "../../WebRtc/SimplePeer"; import { Loader } from "../Components/Loader"; import { RemotePlayer } from "../Entity/RemotePlayer"; import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene"; import { PlayerAnimationDirections } from "../Player/Animation"; import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player"; import { ErrorSceneName } from "../Reconnecting/ErrorScene"; import { ReconnectingSceneName } from "../Reconnecting/ReconnectingScene"; import { GameMap } from "./GameMap"; import { PlayerMovement } from "./PlayerMovement"; import { PlayersPositionInterpolator } from "./PlayersPositionInterpolator"; import { DirtyScene } from "./DirtyScene"; import { TextUtils } from "../Components/TextUtils"; import { joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey } from "../Components/MobileJoystick"; import { StartPositionCalculator } from "./StartPositionCalculator"; import { PropertyUtils } from "../Map/PropertyUtils"; import { GameMapPropertiesListener } from "./GameMapPropertiesListener"; import { analyticsClient } from "../../Administration/AnalyticsClient"; import { GameMapProperties } from "./GameMapProperties"; import { PathfindingManager } from "../../Utils/PathfindingManager"; import { ActivatablesManager } from "./ActivatablesManager"; import type { GroupCreatedUpdatedMessageInterface, MessageUserMovedInterface, MessageUserPositionInterface, OnConnectInterface, PlayerDetailsUpdatedMessageInterface, PointInterface, PositionInterface, RoomJoinedMessageInterface, } from "../../Connexion/ConnexionModels"; import type { RoomConnection } from "../../Connexion/RoomConnection"; import type { ActionableItem } from "../Items/ActionableItem"; import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface"; import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap"; import type { AddPlayerInterface } from "./AddPlayerInterface"; import { CameraManager, CameraManagerEvent, CameraManagerEventCameraUpdateData } from "./CameraManager"; import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent"; import { peerStore } from "../../Stores/PeerStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; import { layoutManagerActionStore } from "../../Stores/LayoutManagerStore"; import { playersStore } from "../../Stores/PlayersStore"; import { emoteStore, emoteMenuStore } from "../../Stores/EmoteStore"; import { userIsAdminStore } from "../../Stores/GameStore"; import { contactPageStore } from "../../Stores/MenuStore"; import type { WasCameraUpdatedEvent } from "../../Api/Events/WasCameraUpdatedEvent"; import { audioManagerFileStore } from "../../Stores/AudioManagerStore"; import EVENT_TYPE = Phaser.Scenes.Events; import Texture = Phaser.Textures.Texture; import Sprite = Phaser.GameObjects.Sprite; import CanvasTexture = Phaser.Textures.CanvasTexture; import GameObject = Phaser.GameObjects.GameObject; import DOMElement = Phaser.GameObjects.DOMElement; import Tileset = Phaser.Tilemaps.Tileset; import SpriteSheetFile = Phaser.Loader.FileTypes.SpriteSheetFile; import { deepCopy } from "deep-copy-ts"; import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR; import { MapStore } from "../../Stores/Utils/MapStore"; import { followUsersColorStore } from "../../Stores/FollowStore"; import { GameSceneUserInputHandler } from "../UserInput/GameSceneUserInputHandler"; import { locale } from "../../i18n/i18n-svelte"; import { StringUtils } from "../../Utils/StringUtils"; import { startLayerNamesStore } from "../../Stores/StartLayerNamesStore"; import { JitsiCoWebsite } from "../../WebRtc/CoWebsite/JitsiCoWebsite"; import { SimpleCoWebsite } from "../../WebRtc/CoWebsite/SimpleCoWebsite"; import type { CoWebsite } from "../../WebRtc/CoWebsite/CoWesbite"; export interface GameSceneInitInterface { initPosition: PointInterface | null; reconnecting: boolean; } interface InitUserPositionEventInterface { type: "InitUserPositionEvent"; event: MessageUserPositionInterface[]; } interface AddPlayerEventInterface { type: "AddPlayerEvent"; event: AddPlayerInterface; } interface RemovePlayerEventInterface { type: "RemovePlayerEvent"; userId: number; } interface UserMovedEventInterface { type: "UserMovedEvent"; event: MessageUserMovedInterface; } interface GroupCreatedUpdatedEventInterface { type: "GroupCreatedUpdatedEvent"; event: GroupCreatedUpdatedMessageInterface; } interface DeleteGroupEventInterface { type: "DeleteGroupEvent"; groupId: number; } interface PlayerDetailsUpdatedInterface { type: "PlayerDetailsUpdated"; details: PlayerDetailsUpdatedMessageInterface; } export class GameScene extends DirtyScene { Terrains: Array; CurrentPlayer!: Player; MapPlayers!: Phaser.Physics.Arcade.Group; MapPlayersByKey: MapStore = new MapStore(); Map!: Phaser.Tilemaps.Tilemap; Objects!: Array; mapFile!: ITiledMap; animatedTiles!: AnimatedTiles; groups: Map; circleTexture!: CanvasTexture; circleRedTexture!: CanvasTexture; pendingEvents = new Queue< | InitUserPositionEventInterface | AddPlayerEventInterface | RemovePlayerEventInterface | UserMovedEventInterface | GroupCreatedUpdatedEventInterface | DeleteGroupEventInterface | PlayerDetailsUpdatedInterface >(); private initPosition: PositionInterface | null = null; private playersPositionInterpolator = new PlayersPositionInterpolator(); public connection: RoomConnection | undefined; private simplePeer!: SimplePeer; private connectionAnswerPromise: Promise; private connectionAnswerPromiseResolve!: ( value: RoomJoinedMessageInterface | PromiseLike ) => void; // A promise that will resolve when the "create" method is called (signaling loading is ended) private createPromise: Promise; private createPromiseResolve!: (value?: void | PromiseLike) => void; private iframeSubscriptionList!: Array; private peerStoreUnsubscribe!: Unsubscriber; private emoteUnsubscribe!: Unsubscriber; private emoteMenuUnsubscribe!: Unsubscriber; private followUsersColorStoreUnsubscribe!: Unsubscriber; private biggestAvailableAreaStoreUnsubscribe!: () => void; MapUrlFile: string; roomUrl: string; instance: string; currentTick!: number; lastSentTick!: number; // The last tick at which a position was sent. lastMoveEventSent: HasPlayerMovedEvent = { direction: "", moving: false, x: -1000, y: -1000, oldX: -1000, oldY: -1000, }; private gameMap!: GameMap; private pusherUrl: string | null = null; private actionableItems: Map = new Map(); public userInputManager!: UserInputManager; private isReconnecting: boolean | undefined = undefined; private playerName!: string; private characterLayers!: string[]; private companion!: string | null; private messageSubscription: Subscription | null = null; private popUpElements: Map = new Map(); private originalMapUrl: string | undefined; private pinchManager: PinchManager | undefined; private mapTransitioning: boolean = false; //used to prevent transitions happening at the same time. private emoteManager!: EmoteManager; private cameraManager!: CameraManager; private pathfindingManager!: PathfindingManager; private activatablesManager!: ActivatablesManager; private preloading: boolean = true; private startPositionCalculator!: StartPositionCalculator; private sharedVariablesManager!: SharedVariablesManager; private objectsByType = new Map(); private embeddedWebsiteManager!: EmbeddedWebsiteManager; private loader: Loader; private lastCameraEvent: WasCameraUpdatedEvent | undefined; private firstCameraUpdateSent: boolean = false; constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ key: customKey ?? room.key, }); this.Terrains = []; this.groups = new Map(); this.instance = room.getInstance(); this.MapUrlFile = MapUrlFile; this.roomUrl = room.key; this.createPromise = new Promise((resolve, reject): void => { this.createPromiseResolve = resolve; }); this.connectionAnswerPromise = new Promise((resolve, reject): void => { this.connectionAnswerPromiseResolve = resolve; }); this.loader = new Loader(this); } //hook preload scene preload(): void { //initialize frame event of scripting API this.listenToIframeEvents(); const localUser = localUserStore.getLocalUser(); const textures = localUser?.textures; if (textures) { for (const texture of textures) { loadCustomTexture(this.load, texture).catch((e) => console.error(e)); } } if (touchScreenManager.supportTouchScreen) { this.load.image(joystickBaseKey, joystickBaseImg); this.load.image(joystickThumbKey, joystickThumbImg); } this.load.audio("audio-webrtc-in", "/resources/objects/webrtc-in.mp3"); this.load.audio("audio-webrtc-out", "/resources/objects/webrtc-out.mp3"); this.load.audio("audio-report-message", "/resources/objects/report-message.mp3"); this.sound.pauseOnBlur = false; this.load.on(FILE_LOAD_ERROR, (file: { src: string }) => { // If we happen to be in HTTP and we are trying to load a URL in HTTPS only... (this happens only in dev environments) if ( window.location.protocol === "http:" && file.src === this.MapUrlFile && file.src.startsWith("http:") && this.originalMapUrl === undefined ) { this.originalMapUrl = this.MapUrlFile; this.MapUrlFile = this.MapUrlFile.replace("http://", "https://"); this.load.tilemapTiledJSON(this.MapUrlFile, this.MapUrlFile); this.load.on( "filecomplete-tilemapJSON-" + this.MapUrlFile, (key: string, type: string, data: unknown) => { this.onMapLoad(data).catch((e) => console.error(e)); } ); return; } // 127.0.0.1, localhost and *.localhost are considered secure, even on HTTP. // So if we are in https, we can still try to load a HTTP local resource (can be useful for testing purposes) // See https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure const base = new URL(window.location.href); base.pathname = ""; const url = new URL(file.src, base.toString()); const host = url.host.split(":")[0]; if ( window.location.protocol === "https:" && file.src === this.MapUrlFile && (host === "127.0.0.1" || host === "localhost" || host.endsWith(".localhost")) && this.originalMapUrl === undefined ) { this.originalMapUrl = this.MapUrlFile; this.MapUrlFile = this.MapUrlFile.replace("https://", "http://"); this.load.tilemapTiledJSON(this.MapUrlFile, this.MapUrlFile); this.load.on( "filecomplete-tilemapJSON-" + this.MapUrlFile, (key: string, type: string, data: unknown) => { this.onMapLoad(data).catch((e) => console.error(e)); } ); // If the map has already been loaded as part of another GameScene, the "on load" event will not be triggered. // In this case, we check in the cache to see if the map is here and trigger the event manually. if (this.cache.tilemap.exists(this.MapUrlFile)) { const data = this.cache.tilemap.get(this.MapUrlFile); this.onMapLoad(data).catch((e) => console.error(e)); } return; } //once preloading is over, we don't want loading errors to crash the game, so we need to disable this behavior after preloading. //if SpriteSheetFile (WOKA file) don't display error and give an access for user if (this.preloading && !(file instanceof SpriteSheetFile)) { //remove loader in progress this.loader.removeLoader(); //display an error scene this.scene.start(ErrorSceneName, { title: "Network error", subTitle: "An error occurred while loading resource:", message: this.originalMapUrl ?? file.src, }); } }); this.load.scenePlugin("AnimatedTiles", AnimatedTiles, "animatedTiles", "animatedTiles"); this.load.on("filecomplete-tilemapJSON-" + this.MapUrlFile, (key: string, type: string, data: unknown) => { this.onMapLoad(data).catch((e) => console.error(e)); }); //TODO strategy to add access token this.load.tilemapTiledJSON(this.MapUrlFile, this.MapUrlFile); // If the map has already been loaded as part of another GameScene, the "on load" event will not be triggered. // In this case, we check in the cache to see if the map is here and trigger the event manually. if (this.cache.tilemap.exists(this.MapUrlFile)) { const data = this.cache.tilemap.get(this.MapUrlFile); this.onMapLoad(data).catch((e) => console.error(e)); } //eslint-disable-next-line @typescript-eslint/no-explicit-any (this.load as any).rexWebFont({ custom: { families: ["Press Start 2P"], urls: ["/resources/fonts/fonts.css"], testString: "abcdefg", }, }); //this function must stay at the end of preload function this.loader.addLoader(); } // FIXME: we need to put a "unknown" instead of a "any" and validate the structure of the JSON we are receiving. // eslint-disable-next-line @typescript-eslint/no-explicit-any private async onMapLoad(data: any): Promise { // Triggered when the map is loaded // Load tiles attached to the map recursively // The map file can be modified by the scripting API and we don't want to tamper the Phaser cache (in case we come back on the map after visiting other maps) // So we are doing a deep copy this.mapFile = deepCopy(data.data); const url = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); this.mapFile.tilesets.forEach((tileset) => { if (typeof tileset.name === "undefined" || typeof tileset.image === "undefined") { console.warn("Don't know how to handle tileset ", tileset); return; } //TODO strategy to add access token this.load.image(`${url}/${tileset.image}`, `${url}/${tileset.image}`); }); // Scan the object layers for objects to load and load them. this.objectsByType = new Map(); for (const layer of this.mapFile.layers) { if (layer.type === "objectgroup") { for (const object of layer.objects) { let objectsOfType: ITiledMapObject[] | undefined; if (!this.objectsByType.has(object.type)) { objectsOfType = new Array(); } else { objectsOfType = this.objectsByType.get(object.type); if (objectsOfType === undefined) { throw new Error("Unexpected object type not found"); } } objectsOfType.push(object); this.objectsByType.set(object.type, objectsOfType); } } } for (const [itemType, objectsOfType] of this.objectsByType) { // FIXME: we would ideally need for the loader to WAIT for the import to be performed, which means writing our own loader plugin. let itemFactory: ItemFactoryInterface; switch (itemType) { case "computer": { const module = await import("../Items/Computer/computer"); itemFactory = module.default; break; } default: continue; //throw new Error('Unsupported object type: "'+ itemType +'"'); } itemFactory.preload(this.load); this.load.start(); // Let's manually start the loader because the import might be over AFTER the loading ends. this.load.on("complete", () => { // FIXME: the factory might fail because the resources might not be loaded yet... // We would need to add a loader ended event in addition to the createPromise this.createPromise .then(async () => { itemFactory.create(this); const roomJoinedAnswer = await this.connectionAnswerPromise; for (const object of objectsOfType) { // TODO: we should pass here a factory to create sprites (maybe?) // Do we have a state for this object? const state = roomJoinedAnswer.items[object.id]; const actionableItem = itemFactory.factory(this, object, state); this.actionableItems.set(actionableItem.getId(), actionableItem); } }) .catch((e) => console.error(e)); }); } } //hook initialisation init(initData: GameSceneInitInterface) { if (initData.initPosition !== undefined) { this.initPosition = initData.initPosition; //todo: still used? } if (initData.initPosition !== undefined) { this.isReconnecting = initData.reconnecting; } } //hook create scene create(): void { this.preloading = false; this.trackDirtyAnims(); gameManager.gameSceneIsCreated(this); urlManager.pushRoomIdToUrl(this.room); analyticsClient.enteredRoom(this.room.id, this.room.group); contactPageStore.set(this.room.contactPage); if (touchScreenManager.supportTouchScreen) { this.pinchManager = new PinchManager(this); } const playerName = gameManager.getPlayerName(); if (!playerName) { throw new Error("playerName is not set"); } this.playerName = playerName; this.characterLayers = gameManager.getCharacterLayers(); this.companion = gameManager.getCompanion(); //initialise map this.Map = this.add.tilemap(this.MapUrlFile); const mapDirUrl = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => { this.Terrains.push( this.Map.addTilesetImage( tileset.name, `${mapDirUrl}/${tileset.image}`, tileset.tilewidth, tileset.tileheight, tileset.margin, tileset.spacing /*, tileset.firstgid*/ ) ); }); //permit to set bound collision this.physics.world.setBounds(0, 0, this.Map.widthInPixels, this.Map.heightInPixels); this.embeddedWebsiteManager = new EmbeddedWebsiteManager(this); //add layer on map this.gameMap = new GameMap(this.mapFile, this.Map, this.Terrains); for (const layer of this.gameMap.flatLayers) { if (layer.type === "tilelayer") { const exitSceneUrl = this.getExitSceneUrl(layer); if (exitSceneUrl !== undefined) { this.loadNextGame( Room.getRoomPathFromExitSceneUrl(exitSceneUrl, window.location.toString(), this.MapUrlFile) ).catch((e) => console.error(e)); } const exitUrl = this.getExitUrl(layer); if (exitUrl !== undefined) { this.loadNextGameFromExitUrl(exitUrl).catch((e) => console.error(e)); } } if (layer.type === "objectgroup") { for (const object of layer.objects) { if (object.text) { TextUtils.createTextFromITiledMapObject(this, object); } if (object.type === "website") { // Let's load iframes in the map const url = PropertyUtils.mustFindStringProperty( GameMapProperties.URL, object.properties, 'in the "' + object.name + '" object of type "website"' ); const allowApi = PropertyUtils.findBooleanProperty( GameMapProperties.ALLOW_API, object.properties ); // TODO: add a "allow" property to iframe this.embeddedWebsiteManager.createEmbeddedWebsite( object.name, url, object.x, object.y, object.width, object.height, object.visible, allowApi ?? false, "", "map", 1 ); } } } } this.gameMap.exitUrls.forEach((exitUrl) => { this.loadNextGameFromExitUrl(exitUrl).catch((e) => console.error(e)); }); this.startPositionCalculator = new StartPositionCalculator( this.gameMap, this.mapFile, this.initPosition, urlManager.getStartLayerNameFromUrl() ); startLayerNamesStore.set(this.startPositionCalculator.getStartPositionNames()); //add entities this.Objects = new Array(); //initialise list of other player this.MapPlayers = this.physics.add.group({ immovable: true }); //create input to move this.userInputManager = new UserInputManager(this, new GameSceneUserInputHandler(this)); mediaManager.setUserInputManager(this.userInputManager); if (localUserStore.getFullscreen()) { document .querySelector("body") ?.requestFullscreen() .catch((e) => console.error(e)); } this.pathfindingManager = new PathfindingManager( this, this.gameMap.getCollisionGrid(), this.gameMap.getTileDimensions() ); //notify game manager can to create currentUser in map this.createCurrentPlayer(); this.removeAllRemotePlayers(); //cleanup the list of remote players in case the scene was rebooted this.tryMovePlayerWithMoveToParameter(); this.cameraManager = new CameraManager( this, { x: this.Map.widthInPixels, y: this.Map.heightInPixels }, waScaleManager ); this.pathfindingManager = new PathfindingManager( this, this.gameMap.getCollisionGrid(), this.gameMap.getTileDimensions() ); this.activatablesManager = new ActivatablesManager(this.CurrentPlayer); biggestAvailableAreaStore.recompute(); this.cameraManager.startFollowPlayer(this.CurrentPlayer); this.animatedTiles.init(this.Map); this.events.on("tileanimationupdate", () => (this.dirty = true)); this.initCirclesCanvas(); // Let's pause the scene if the connection is not established yet if (!this.room.isDisconnected()) { if (this.isReconnecting) { setTimeout(() => { this.scene.sleep(); this.scene.launch(ReconnectingSceneName); }, 0); } else if (this.connection === undefined) { // Let's wait 1 second before printing the "connecting" screen to avoid blinking setTimeout(() => { if (this.connection === undefined) { this.scene.sleep(); this.scene.launch(ReconnectingSceneName); } }, 1000); } } this.createPromiseResolve(); // Now, let's load the script, if any const scripts = this.getScriptUrls(this.mapFile); const disableModuleMode = this.getProperty(this.mapFile, GameMapProperties.SCRIPT_DISABLE_MODULE_SUPPORT) as | boolean | undefined; const scriptPromises = []; for (const script of scripts) { scriptPromises.push(iframeListener.registerScript(script, !disableModuleMode)); } this.reposition(); // From now, this game scene will be notified of reposition events this.biggestAvailableAreaStoreUnsubscribe = biggestAvailableAreaStore.subscribe((box) => this.cameraManager.updateCameraOffset(box) ); new GameMapPropertiesListener(this, this.gameMap).register(); if (!this.room.isDisconnected()) { this.scene.sleep(); this.connect(); } let oldPeerNumber = 0; this.peerStoreUnsubscribe = peerStore.subscribe((peers) => { const newPeerNumber = peers.size; if (newPeerNumber > oldPeerNumber) { this.playSound("audio-webrtc-in"); } else if (newPeerNumber < oldPeerNumber) { this.playSound("audio-webrtc-out"); } oldPeerNumber = newPeerNumber; }); this.emoteUnsubscribe = emoteStore.subscribe((emote) => { if (emote) { this.CurrentPlayer?.playEmote(emote.url); this.connection?.emitEmoteEvent(emote.url); emoteStore.set(null); } }); this.emoteMenuUnsubscribe = emoteMenuStore.subscribe((emoteMenu) => { if (emoteMenu) { this.userInputManager.disableControls(); } else { this.userInputManager.restoreControls(); } }); this.followUsersColorStoreUnsubscribe = followUsersColorStore.subscribe((color) => { if (color !== undefined) { this.CurrentPlayer.setFollowOutlineColor(color); this.connection?.emitPlayerOutlineColor(color); } else { this.CurrentPlayer.removeFollowOutlineColor(); this.connection?.emitPlayerOutlineColor(null); } }); Promise.all([this.connectionAnswerPromise as Promise, ...scriptPromises]) .then(() => { this.scene.wake(); }) .catch((e) => console.error( "Some scripts failed to load ot the connection failed to establish to WorkAdventure server", e ) ); } /** * Initializes the connection to Pusher. */ private connect(): void { this.pusherUrl = this.getPusherUrl(this.mapFile); const camera = this.cameraManager.getCamera(); connectionManager .connectToRoomSocket( this.roomUrl, this.playerName, this.characterLayers, { ...this.startPositionCalculator.startPosition, }, { left: camera.scrollX, top: camera.scrollY, right: camera.scrollX + camera.width, bottom: camera.scrollY + camera.height, }, this.companion, this.pusherUrl ) .then((onConnect: OnConnectInterface) => { this.connection = onConnect.connection; playersStore.connectToRoomConnection(this.connection); userIsAdminStore.set(this.connection.hasTag("admin")); this.connection.userJoinedMessageStream.subscribe((message) => { const userMessage: AddPlayerInterface = { userId: message.userId, characterLayers: message.characterLayers, name: message.name, position: message.position, visitCardUrl: message.visitCardUrl, companion: message.companion, userUuid: message.userUuid, outlineColor: message.outlineColor, }; this.addPlayer(userMessage); }); this.connection.userMovedMessageStream.subscribe((message) => { const position = message.position; if (position === undefined) { throw new Error("Position missing from UserMovedMessage"); } const messageUserMoved: MessageUserMovedInterface = { userId: message.userId, position: ProtobufClientUtils.toPointInterface(position), }; this.updatePlayerPosition(messageUserMoved); }); this.connection.userLeftMessageStream.subscribe((message) => { this.removePlayer(message.userId); }); this.connection.groupUpdateMessageStream.subscribe( (groupPositionMessage: GroupCreatedUpdatedMessageInterface) => { this.shareGroupPosition(groupPositionMessage); } ); this.connection.groupDeleteMessageStream.subscribe((message) => { try { this.deleteGroup(message.groupId); } catch (e) { console.error(e); } }); this.connection.onServerDisconnected(() => { console.log("Player disconnected from server. Reloading scene."); this.cleanupClosingScene(); this.createSuccessorGameScene(true, true); }); this.connection.itemEventMessageStream.subscribe((message) => { const item = this.actionableItems.get(message.itemId); if (item === undefined) { console.warn( 'Received an event about object "' + message.itemId + '" but cannot find this item on the map.' ); return; } item.fire(message.event, message.state, message.parameters); }); this.connection.playerDetailsUpdatedMessageStream.subscribe((message) => { if (message.details === undefined) { throw new Error("Malformed message. Missing details in PlayerDetailsUpdatedMessage"); } this.pendingEvents.enqueue({ type: "PlayerDetailsUpdated", details: { userId: message.userId, outlineColor: message.details.outlineColor, removeOutlineColor: message.details.removeOutlineColor, }, }); }); /** * Triggered when we receive the JWT token to connect to Jitsi */ this.connection.sendJitsiJwtMessageStream.subscribe((message) => { if (!JITSI_URL) { throw new Error("Missing JITSI_URL environment variable."); } let domain = JITSI_URL; if (domain.substring(0, 7) !== "http://" && domain.substring(0, 8) !== "https://") { domain = `${location.protocol}//${domain}`; } const coWebsite = new JitsiCoWebsite(new URL(domain), false, undefined, undefined, false); coWebsiteManager.addCoWebsiteToStore(coWebsite, 0); this.initialiseJitsi(coWebsite, message.jitsiRoom, message.jwt); }); this.messageSubscription = this.connection.worldFullMessageStream.subscribe((message) => { this.showWorldFullError(message); }); // When connection is performed, let's connect SimplePeer this.simplePeer = new SimplePeer(this.connection); userMessageManager.setReceiveBanListener(this.bannedUser.bind(this)); this.CurrentPlayer.on(hasMovedEventName, (event: HasPlayerMovedEvent) => { this.handleCurrentPlayerHasMovedEvent(event); }); // Set up variables manager this.sharedVariablesManager = new SharedVariablesManager( this.connection, this.gameMap, onConnect.room.variables ); //this.initUsersPosition(roomJoinedMessage.users); this.connectionAnswerPromiseResolve(onConnect.room); // Analyze tags to find if we are admin. If yes, show console. if (this.scene.isSleeping()) { this.scene.stop(ReconnectingSceneName); } //init user position and play trigger to check layers properties this.gameMap.setPosition(this.CurrentPlayer.x, this.CurrentPlayer.y); // Init layer change listener this.gameMap.onEnterLayer((layers) => { layers.forEach((layer) => { iframeListener.sendEnterLayerEvent(layer.name); }); }); this.gameMap.onLeaveLayer((layers) => { layers.forEach((layer) => { iframeListener.sendLeaveLayerEvent(layer.name); }); }); this.gameMap.onEnterZone((zones) => { for (const zone of zones) { const focusable = zone.properties?.find((property) => property.name === "focusable"); if (focusable && focusable.value === true) { const zoomMargin = zone.properties?.find((property) => property.name === "zoom_margin"); this.cameraManager.enterFocusMode( { x: zone.x + zone.width * 0.5, y: zone.y + zone.height * 0.5, width: zone.width, height: zone.height, }, zoomMargin ? Math.max(0, Number(zoomMargin.value)) : undefined ); break; } } zones.forEach((zone) => { iframeListener.sendEnterZoneEvent(zone.name); }); }); this.gameMap.onLeaveZone((zones) => { for (const zone of zones) { const focusable = zone.properties?.find((property) => property.name === "focusable"); if (focusable && focusable.value === true) { this.cameraManager.leaveFocusMode(this.CurrentPlayer, 1000); break; } } zones.forEach((zone) => { iframeListener.sendLeaveZoneEvent(zone.name); }); }); this.emoteManager = new EmoteManager(this, this.connection); // this.gameMap.onLeaveLayer((layers) => { // layers.forEach((layer) => { // iframeListener.sendLeaveLayerEvent(layer.name); // }); // }); }) .catch((e) => console.error(e)); } //todo: into dedicated classes private initCirclesCanvas(): void { // Let's generate the circle for the group delimiter let circleElement = Object.values(this.textures.list).find( (object: Texture) => object.key === "circleSprite-white" ); if (circleElement) { this.textures.remove("circleSprite-white"); } circleElement = Object.values(this.textures.list).find((object: Texture) => object.key === "circleSprite-red"); if (circleElement) { this.textures.remove("circleSprite-red"); } //create white circle canvas use to create sprite this.circleTexture = this.textures.createCanvas("circleSprite-white", 96, 96); const context = this.circleTexture.context; context.beginPath(); context.arc(48, 48, 48, 0, 2 * Math.PI, false); // context.lineWidth = 5; context.strokeStyle = "#ffffff"; context.stroke(); this.circleTexture.refresh(); //create red circle canvas use to create sprite this.circleRedTexture = this.textures.createCanvas("circleSprite-red", 96, 96); const contextRed = this.circleRedTexture.context; contextRed.beginPath(); contextRed.arc(48, 48, 48, 0, 2 * Math.PI, false); //context.lineWidth = 5; contextRed.strokeStyle = "#ff0000"; contextRed.stroke(); this.circleRedTexture.refresh(); } private safeParseJSONstring(jsonString: string | undefined, propertyName: string) { try { return jsonString ? JSON.parse(jsonString) : {}; } catch (e) { console.warn('Invalid JSON found in property "' + propertyName + '" of the map:' + jsonString, e); return {}; } } private listenToIframeEvents(): void { this.iframeSubscriptionList = []; this.iframeSubscriptionList.push( iframeListener.openPopupStream.subscribe((openPopupEvent) => { let objectLayerSquare: ITiledMapObject; const targetObjectData = this.getObjectLayerData(openPopupEvent.targetObject); if (targetObjectData !== undefined) { objectLayerSquare = targetObjectData; } else { console.error( "Error while opening a popup. Cannot find an object on the map with name '" + openPopupEvent.targetObject + "'. The first parameter of WA.openPopup() must be the name of a rectangle object in your map." ); return; } const escapedMessage = HtmlUtils.escapeHtml(openPopupEvent.message); let html = '