From cdd61bdb2c7dc48965d739e6b8e08d05eba2e23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Fri, 23 Jul 2021 16:41:38 +0200 Subject: [PATCH] Merging with develop --- CHANGELOG.md | 29 +- back/package.json | 4 + back/src/Controller/DebugController.ts | 2 +- back/src/Enum/EnvironmentVariable.ts | 3 + back/src/Model/GameRoom.ts | 233 +++++++++++--- back/src/Model/RoomIdentifier.ts | 30 -- back/src/RoomManager.ts | 239 ++++++++------ back/src/Services/AdminApi.ts | 24 ++ .../src/Services/AdminApi/CharacterTexture.ts | 11 + back/src/Services/AdminApi/MapDetailsData.ts | 21 ++ back/src/Services/AdminApi/RoomRedirect.ts | 8 + back/src/Services/LocalUrlError.ts | 1 + back/src/Services/MapFetcher.ts | 67 ++++ back/src/Services/MessageHelpers.ts | 47 ++- back/src/Services/RedisClient.ts | 23 ++ .../Repository/RedisVariablesRepository.ts | 43 +++ .../Repository/VariablesRepository.ts | 14 + .../VariablesRepositoryInterface.ts | 10 + .../Repository/VoidVariablesRepository.ts | 14 + back/src/Services/SocketManager.ts | 206 ++++++------ back/src/Services/VariablesManager.ts | 218 +++++++++++++ back/tests/GameRoomTest.ts | 110 ++++--- back/tests/MapFetcherTest.ts | 32 ++ back/tests/RoomIdentifierTest.ts | 19 -- back/tsconfig.json | 2 +- back/yarn.lock | 56 ++++ deeployer.libsonnet | 5 + docker-compose.single-domain.yaml | 5 + docker-compose.yaml | 16 + docs/maps/api-player.md | 59 +++- docs/maps/api-reference.md | 2 + docs/maps/api-room.md | 79 +++-- docs/maps/api-start.md | 30 ++ docs/maps/api-state.md | 136 ++++++++ docs/maps/scripting.md | 4 +- front/dist/index.tmpl.html | 3 +- front/dist/resources/html/gameMenu.html | 3 + front/dist/resources/logos/tcm_full.png | Bin 300 -> 0 bytes front/dist/resources/logos/tcm_short.png | Bin 216 -> 0 bytes front/dist/resources/service-worker.js | 53 ++++ .../static/images/favicons/icon-512x512.png | Bin 0 -> 13061 bytes .../dist/static/images/favicons/manifest.json | 11 +- front/dist/static/images/send.png | Bin 0 -> 8523 bytes front/package.json | 4 +- front/src/Api/Events/GameStateEvent.ts | 3 +- front/src/Api/Events/IframeEvent.ts | 68 +++- .../{DataLayerEvent.ts => MapDataEvent.ts} | 4 +- front/src/Api/Events/SetTilesEvent.ts | 2 +- front/src/Api/Events/SetVariableEvent.ts | 20 ++ front/src/Api/IframeListener.ts | 132 ++++---- front/src/Api/iframe/player.ts | 43 +++ front/src/Api/iframe/room.ts | 89 ++---- front/src/Api/iframe/state.ts | 90 ++++++ front/src/Components/App.svelte | 13 +- front/src/Components/Chat/Chat.svelte | 101 ++++++ front/src/Components/Chat/ChatElement.svelte | 83 +++++ .../Components/Chat/ChatMessageForm.svelte | 56 ++++ .../src/Components/Chat/ChatPlayerName.svelte | 51 +++ front/src/Components/Chat/ChatSubMenu.svelte | 33 ++ .../src/Components/Video/VideoMediaBox.svelte | 2 - front/src/Components/Video/utils.ts | 21 +- .../src/Components/VisitCard/VisitCard.svelte | 5 +- front/src/Connexion/ConnectionManager.ts | 151 +++++---- front/src/Connexion/ConnexionModels.ts | 70 +++-- front/src/Connexion/Room.ts | 166 ++++++---- front/src/Connexion/RoomConnection.ts | 71 ++++- front/src/Phaser/Components/OpenChatIcon.ts | 12 +- .../Entity/PlayerTexturesLoadingManager.ts | 1 - front/src/Phaser/Game/AddPlayerInterface.ts | 11 +- front/src/Phaser/Game/GameManager.ts | 13 +- front/src/Phaser/Game/GameMap.ts | 41 ++- front/src/Phaser/Game/GameScene.ts | 190 +++++++---- front/src/Phaser/Game/PlayerInterface.ts | 11 + .../src/Phaser/Game/SharedVariablesManager.ts | 167 ++++++++++ .../Phaser/Game/StartPositionCalculator.ts | 6 +- front/src/Phaser/Login/EntryScene.ts | 50 +-- .../src/Phaser/Login/SelectCharacterScene.ts | 104 +++--- front/src/Phaser/Map/ITiledMap.ts | 17 +- front/src/Phaser/Menu/MenuScene.ts | 27 +- front/src/Phaser/Menu/ReportMenu.ts | 78 ++--- front/src/Phaser/Reconnecting/ErrorScene.ts | 72 +++-- .../Phaser/Reconnecting/ReconnectingScene.ts | 41 +-- front/src/Stores/ChatStore.ts | 119 +++++++ front/src/Stores/PlayersStore.ts | 69 ++++ front/src/Stores/UserInputStore.ts | 13 +- front/src/WebRtc/BlackListManager.ts | 37 ++- front/src/WebRtc/ColorGenerator.ts | 52 +++ front/src/WebRtc/DiscussionManager.ts | 227 +------------- front/src/WebRtc/HtmlUtils.ts | 14 +- front/src/WebRtc/MediaManager.ts | 67 +--- front/src/WebRtc/ScreenSharingPeer.ts | 21 +- front/src/WebRtc/SimplePeer.ts | 39 +-- front/src/WebRtc/VideoPeer.ts | 69 ++-- front/src/iframe_api.ts | 53 +++- front/src/index.ts | 117 ++++--- front/style/fonts.scss | 4 - front/style/style.scss | 2 +- front/tests/Phaser/Game/RoomTest.ts | 89 ------ front/webpack.config.ts | 1 - front/yarn.lock | 10 +- maps/Floor1/floor1.json | 15 +- maps/Tuto/Attribution-tilesets.txt | 2 +- maps/tests/Attribution-tilesets.txt | 2 +- maps/tests/Metadata/customMenu.html | 19 +- maps/tests/Metadata/getCurrentRoom.html | 16 - maps/tests/Metadata/getCurrentRoom.js | 11 + maps/tests/Metadata/getCurrentRoom.json | 42 +-- maps/tests/Metadata/getCurrentUser.html | 15 - maps/tests/Metadata/getCurrentUser.json | 296 ------------------ maps/tests/Metadata/playerMove.html | 16 +- maps/tests/Metadata/setProperty.html | 17 +- maps/tests/Metadata/showHideLayer.html | 28 +- maps/tests/Variables/script.js | 33 ++ maps/tests/Variables/shared_variables.html | 48 +++ maps/tests/Variables/shared_variables.json | 131 ++++++++ maps/tests/Variables/variables.json | 205 ++++++++++++ maps/tests/index.html | 34 +- messages/protos/messages.proto | 51 ++- .../src/Controller/AuthenticateController.ts | 8 +- pusher/src/Controller/DebugController.ts | 2 +- pusher/src/Controller/IoSocketController.ts | 8 +- pusher/src/Controller/MapController.ts | 52 +-- pusher/src/Model/PusherRoom.ts | 127 ++++++-- pusher/src/Model/RoomIdentifier.ts | 30 -- .../Model/Websocket/ExAdminSocketInterface.ts | 1 - .../src/Model/Websocket/ExSocketInterface.ts | 2 +- pusher/src/Model/Zone.ts | 14 + pusher/src/Services/AdminApi.ts | 54 +--- .../src/Services/AdminApi/CharacterTexture.ts | 11 + .../src/Services/AdminApi/MapDetailsData.ts | 20 ++ pusher/src/Services/AdminApi/RoomRedirect.ts | 8 + pusher/src/Services/JWTTokenManager.ts | 20 +- pusher/src/Services/SocketManager.ts | 88 +++--- pusher/tests/RoomIdentifierTest.ts | 19 -- pusher/tsconfig.json | 2 +- 135 files changed, 4313 insertions(+), 2128 deletions(-) delete mode 100644 back/src/Model/RoomIdentifier.ts create mode 100644 back/src/Services/AdminApi.ts create mode 100644 back/src/Services/AdminApi/CharacterTexture.ts create mode 100644 back/src/Services/AdminApi/MapDetailsData.ts create mode 100644 back/src/Services/AdminApi/RoomRedirect.ts create mode 100644 back/src/Services/LocalUrlError.ts create mode 100644 back/src/Services/MapFetcher.ts create mode 100644 back/src/Services/RedisClient.ts create mode 100644 back/src/Services/Repository/RedisVariablesRepository.ts create mode 100644 back/src/Services/Repository/VariablesRepository.ts create mode 100644 back/src/Services/Repository/VariablesRepositoryInterface.ts create mode 100644 back/src/Services/Repository/VoidVariablesRepository.ts create mode 100644 back/src/Services/VariablesManager.ts create mode 100644 back/tests/MapFetcherTest.ts delete mode 100644 back/tests/RoomIdentifierTest.ts create mode 100644 docs/maps/api-start.md create mode 100644 docs/maps/api-state.md delete mode 100644 front/dist/resources/logos/tcm_full.png delete mode 100644 front/dist/resources/logos/tcm_short.png create mode 100644 front/dist/resources/service-worker.js create mode 100644 front/dist/static/images/favicons/icon-512x512.png create mode 100644 front/dist/static/images/send.png rename front/src/Api/Events/{DataLayerEvent.ts => MapDataEvent.ts} (70%) create mode 100644 front/src/Api/Events/SetVariableEvent.ts create mode 100644 front/src/Api/iframe/state.ts create mode 100644 front/src/Components/Chat/Chat.svelte create mode 100644 front/src/Components/Chat/ChatElement.svelte create mode 100644 front/src/Components/Chat/ChatMessageForm.svelte create mode 100644 front/src/Components/Chat/ChatPlayerName.svelte create mode 100644 front/src/Components/Chat/ChatSubMenu.svelte create mode 100644 front/src/Phaser/Game/PlayerInterface.ts create mode 100644 front/src/Phaser/Game/SharedVariablesManager.ts create mode 100644 front/src/Stores/ChatStore.ts create mode 100644 front/src/Stores/PlayersStore.ts create mode 100644 front/src/WebRtc/ColorGenerator.ts delete mode 100644 front/tests/Phaser/Game/RoomTest.ts delete mode 100644 maps/tests/Metadata/getCurrentRoom.html create mode 100644 maps/tests/Metadata/getCurrentRoom.js delete mode 100644 maps/tests/Metadata/getCurrentUser.html delete mode 100644 maps/tests/Metadata/getCurrentUser.json create mode 100644 maps/tests/Variables/script.js create mode 100644 maps/tests/Variables/shared_variables.html create mode 100644 maps/tests/Variables/shared_variables.json create mode 100644 maps/tests/Variables/variables.json delete mode 100644 pusher/src/Model/RoomIdentifier.ts create mode 100644 pusher/src/Services/AdminApi/CharacterTexture.ts create mode 100644 pusher/src/Services/AdminApi/MapDetailsData.ts create mode 100644 pusher/src/Services/AdminApi/RoomRedirect.ts delete mode 100644 pusher/tests/RoomIdentifierTest.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3dd293..50c09ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,35 @@ - Migrated the admin console to Svelte, and redesigned the console #1211 - Layer properties (like `exitUrl`, `silent`, etc...) can now also used in tile properties #1210 (@jonnytest1) - New scripting API features : + - Use `WA.onInit(): Promise` to wait for scripting API initialization - Use `WA.room.showLayer(): void` to show a layer - Use `WA.room.hideLayer(): void` to hide a layer - - Use `WA.room.setProperty() : void` to add or change existing property of a layer + - Use `WA.room.setProperty() : void` to add, delete or change existing property of a layer - Use `WA.player.onPlayerMove(): void` to track the movement of the current player - - Use `WA.room.getCurrentUser(): Promise` to get the ID, name and tags of the current player - - Use `WA.room.getCurrentRoom(): Promise` to get the ID, JSON map file, url of the map of the current room and the layer where the current player started + - Use `WA.player.id: string|undefined` to get the ID of the current player + - Use `WA.player.name: string` to get the name of the current player + - Use `WA.player.tags: string[]` to get the tags of the current player + - Use `WA.room.id: string` to get the ID of the room + - Use `WA.room.mapURL: string` to get the URL of the map + - Use `WA.room.mapURL: string` to get the URL of the map + - Use `WA.room.getMap(): Promise` to get the JSON map file + - Use `WA.room.setTiles(): void` to add, delete or change an array of tiles - Use `WA.ui.registerMenuCommand(): void` to add a custom menu - - Use `WA.room.setTiles(): void` to change an array of tiles + - Use `WA.state.loadVariable(key: string): unknown` to retrieve a variable + - Use `WA.state.saveVariable(key: string, value: unknown): Promise` to set a variable (across the room, for all users) + - Use `WA.state.onVariableChange(key: string): Observable` to track a variable + - Use `WA.state.[any variable]: unknown` to access directly any variable (this is a shortcut to using `WA.state.loadVariable` and `WA.state.saveVariable`) +- Users blocking now relies on UUID rather than ID. A blocked user that leaves a room and comes back will stay blocked. +- The text chat was redesigned to be prettier and to use more features : + - The chat is now persistent bewteen discussions and always accesible + - The chat now tracks incoming and outcoming users in your conversation + - The chat allows your to see the visit card of users + - You can close the chat window with the escape key +- Added a 'Enable notifications' button in the menu. +- The exchange format between Pusher and Admin servers has changed. If you have your own implementation of an admin server, these endpoints signatures have changed: + - `/api/map`: now accepts a complete room URL instead of organization/world/room slugs + - `/api/ban`: new endpoint to report users + - as a side effect, the "routing" is now completely stored on the admin side, so by implementing your own admin server, you can develop completely custom routing ## Version 1.4.3 - 1.4.4 - 1.4.5 diff --git a/back/package.json b/back/package.json index 7015b9b8..8a1e445e 100644 --- a/back/package.json +++ b/back/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/thecodingmachine/workadventure#readme", "dependencies": { + "@workadventure/tiled-map-type-guard": "^1.0.0", "axios": "^0.21.1", "busboy": "^0.3.1", "circular-json": "^0.5.9", @@ -47,10 +48,12 @@ "generic-type-guard": "^3.2.0", "google-protobuf": "^3.13.0", "grpc": "^1.24.4", + "ipaddr.js": "^2.0.1", "jsonwebtoken": "^8.5.1", "mkdirp": "^1.0.4", "prom-client": "^12.0.0", "query-string": "^6.13.3", + "redis": "^3.1.2", "systeminformation": "^4.31.1", "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", "uuidv4": "^6.0.7" @@ -64,6 +67,7 @@ "@types/jasmine": "^3.5.10", "@types/jsonwebtoken": "^8.3.8", "@types/mkdirp": "^1.0.1", + "@types/redis": "^2.8.31", "@types/uuidv4": "^5.0.0", "@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/parser": "^2.26.0", diff --git a/back/src/Controller/DebugController.ts b/back/src/Controller/DebugController.ts index b7f037fd..88287753 100644 --- a/back/src/Controller/DebugController.ts +++ b/back/src/Controller/DebugController.ts @@ -15,7 +15,7 @@ export class DebugController { const query = parse(req.getQuery()); if (query.token !== ADMIN_API_TOKEN) { - return res.status(401).send("Invalid token sent!"); + return res.writeStatus("401 Unauthorized").end("Invalid token sent!"); } return res diff --git a/back/src/Enum/EnvironmentVariable.ts b/back/src/Enum/EnvironmentVariable.ts index 19eddd3e..92f62b0b 100644 --- a/back/src/Enum/EnvironmentVariable.ts +++ b/back/src/Enum/EnvironmentVariable.ts @@ -12,6 +12,9 @@ const GRPC_PORT = parseInt(process.env.GRPC_PORT || "50051") || 50051; export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed export const TURN_STATIC_AUTH_SECRET = process.env.TURN_STATIC_AUTH_SECRET || ""; export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4"); +export const REDIS_HOST = process.env.REDIS_HOST || undefined; +export const REDIS_PORT = parseInt(process.env.REDIS_PORT || "6379") || 6379; +export const REDIS_PASSWORD = process.env.REDIS_PASSWORD || undefined; export { MINIMUM_DISTANCE, diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 020f4c29..491dd4af 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -5,47 +5,64 @@ import { PositionInterface } from "_Model/PositionInterface"; import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone"; import { PositionNotifier } from "./PositionNotifier"; import { Movable } from "_Model/Movable"; -import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; -import { arrayIntersect } from "../Services/ArrayHelper"; -import { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb"; +import { + BatchToPusherMessage, + BatchToPusherRoomMessage, + EmoteEventMessage, + ErrorMessage, + JoinRoomMessage, + SubToPusherRoomMessage, + VariableMessage, + VariableWithTagMessage, +} from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; -import { ZoneSocket } from "src/RoomManager"; +import { RoomSocket, ZoneSocket } from "src/RoomManager"; import { Admin } from "../Model/Admin"; +import { adminApi } from "../Services/AdminApi"; +import { isMapDetailsData, MapDetailsData } from "../Services/AdminApi/MapDetailsData"; +import { ITiledMap } from "@workadventure/tiled-map-type-guard/dist"; +import { mapFetcher } from "../Services/MapFetcher"; +import { VariablesManager } from "../Services/VariablesManager"; +import { ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import { LocalUrlError } from "../Services/LocalUrlError"; +import { emitErrorOnRoomSocket } from "../Services/MessageHelpers"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; -export enum GameRoomPolicyTypes { - ANONYMOUS_POLICY = 1, - MEMBERS_ONLY_POLICY, - USE_TAGS_POLICY, -} - export class GameRoom { - private readonly minDistance: number; - private readonly groupRadius: number; - // Users, sorted by ID - private readonly users: Map; - private readonly usersByUuid: Map; - private readonly groups: Set; - private readonly admins: Set; + private readonly users = new Map(); + private readonly usersByUuid = new Map(); + private readonly groups = new Set(); + private readonly admins = new Set(); - private readonly connectCallback: ConnectCallback; - private readonly disconnectCallback: DisconnectCallback; - - private itemsState: Map = new Map(); + private itemsState = new Map(); private readonly positionNotifier: PositionNotifier; - public readonly roomId: string; - public readonly roomSlug: string; - public readonly worldSlug: string = ""; - public readonly organizationSlug: string = ""; private versionNumber: number = 1; private nextUserId: number = 1; - constructor( - roomId: string, + private roomListeners: Set = new Set(); + + private constructor( + public readonly roomUrl: string, + private mapUrl: string, + private readonly connectCallback: ConnectCallback, + private readonly disconnectCallback: DisconnectCallback, + private readonly minDistance: number, + private readonly groupRadius: number, + onEnters: EntersCallback, + onMoves: MovesCallback, + onLeaves: LeavesCallback, + onEmote: EmoteCallback + ) { + // A zone is 10 sprites wide. + this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); + } + + public static async create( + roomUrl: string, connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback, minDistance: number, @@ -54,28 +71,23 @@ export class GameRoom { onMoves: MovesCallback, onLeaves: LeavesCallback, onEmote: EmoteCallback - ) { - this.roomId = roomId; + ): Promise { + const mapDetails = await GameRoom.getMapDetails(roomUrl); - if (isRoomAnonymous(roomId)) { - this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); - } else { - const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId); - this.roomSlug = roomSlug; - this.organizationSlug = organizationSlug; - this.worldSlug = worldSlug; - } + const gameRoom = new GameRoom( + roomUrl, + mapDetails.mapUrl, + connectCallback, + disconnectCallback, + minDistance, + groupRadius, + onEnters, + onMoves, + onLeaves, + onEmote + ); - this.users = new Map(); - this.usersByUuid = new Map(); - this.admins = new Set(); - this.groups = new Set(); - this.connectCallback = connectCallback; - this.disconnectCallback = disconnectCallback; - this.minDistance = minDistance; - this.groupRadius = groupRadius; - // A zone is 10 sprites wide. - this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); + return gameRoom; } public getGroups(): Group[] { @@ -183,7 +195,7 @@ export class GameRoom { } else { const closestUser: User = closestItem; const group: Group = new Group( - this.roomId, + this.roomUrl, [user, closestUser], this.connectCallback, this.disconnectCallback, @@ -309,6 +321,37 @@ export class GameRoom { return this.itemsState; } + public async setVariable(name: string, value: string, user: User): Promise { + // First, let's check if "user" is allowed to modify the variable. + const variableManager = await this.getVariableManager(); + + const readableBy = variableManager.setVariable(name, value, user); + + // If the variable was not changed, let's not dispatch anything. + if (readableBy === false) { + return; + } + + // TODO: should we batch those every 100ms? + const variableMessage = new VariableWithTagMessage(); + variableMessage.setName(name); + variableMessage.setValue(value); + if (readableBy) { + variableMessage.setReadableby(readableBy); + } + + const subMessage = new SubToPusherRoomMessage(); + subMessage.setVariablemessage(variableMessage); + + const batchMessage = new BatchToPusherRoomMessage(); + batchMessage.addPayload(subMessage); + + // Dispatch the message on the room listeners + for (const socket of this.roomListeners) { + socket.write(batchMessage); + } + } + public addZoneListener(call: ZoneSocket, x: number, y: number): Set { return this.positionNotifier.addZoneListener(call, x, y); } @@ -338,4 +381,98 @@ export class GameRoom { public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) { this.positionNotifier.emitEmoteEvent(user, emoteEventMessage); } + + public addRoomListener(socket: RoomSocket) { + this.roomListeners.add(socket); + } + + public removeRoomListener(socket: RoomSocket) { + this.roomListeners.delete(socket); + } + + /** + * Connects to the admin server to fetch map details. + * If there is no admin server, the map details are generated by analysing the map URL (that must be in the form: /_/instance/map_url) + */ + private static async getMapDetails(roomUrl: string): Promise { + if (!ADMIN_API_URL) { + const roomUrlObj = new URL(roomUrl); + + const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname); + if (!match) { + console.error("Unexpected room URL", roomUrl); + throw new Error('Unexpected room URL "' + roomUrl + '"'); + } + + const mapUrl = roomUrlObj.protocol + "//" + match[1]; + + return { + mapUrl, + policy_type: 1, + textures: [], + tags: [], + }; + } + + const result = await adminApi.fetchMapDetails(roomUrl); + if (!isMapDetailsData(result)) { + console.error("Unexpected room details received from server", result); + throw new Error("Unexpected room details received from server"); + } + return result; + } + + private mapPromise: Promise | undefined; + + /** + * Returns a promise to the map file. + * @throws LocalUrlError if the map we are trying to load is hosted on a local network + * @throws Error + */ + private getMap(): Promise { + if (!this.mapPromise) { + this.mapPromise = mapFetcher.fetchMap(this.mapUrl); + } + + return this.mapPromise; + } + + private variableManagerPromise: Promise | undefined; + + private getVariableManager(): Promise { + if (!this.variableManagerPromise) { + this.variableManagerPromise = this.getMap() + .then((map) => { + const variablesManager = new VariablesManager(this.roomUrl, map); + return variablesManager.init(); + }) + .catch((e) => { + if (e instanceof LocalUrlError) { + // If we are trying to load a local URL, we are probably in test mode. + // In this case, let's bypass the server-side checks completely. + + // Note: we run this message inside a setTimeout so that the room listeners can have time to connect. + setTimeout(() => { + for (const roomListener of this.roomListeners) { + emitErrorOnRoomSocket( + roomListener, + "You are loading a local map. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled." + ); + } + }, 1000); + + const variablesManager = new VariablesManager(this.roomUrl, null); + return variablesManager.init(); + } else { + throw e; + } + }); + } + return this.variableManagerPromise; + } + + public async getVariablesForTags(tags: string[]): Promise> { + const variablesManager = await this.getVariableManager(); + return variablesManager.getVariablesForTags(tags); + } } diff --git a/back/src/Model/RoomIdentifier.ts b/back/src/Model/RoomIdentifier.ts deleted file mode 100644 index d1de8800..00000000 --- a/back/src/Model/RoomIdentifier.ts +++ /dev/null @@ -1,30 +0,0 @@ -//helper functions to parse room IDs - -export const isRoomAnonymous = (roomID: string): boolean => { - if (roomID.startsWith("_/")) { - return true; - } else if (roomID.startsWith("@/")) { - return false; - } else { - throw new Error("Incorrect room ID: " + roomID); - } -}; - -export const extractRoomSlugPublicRoomId = (roomId: string): string => { - const idParts = roomId.split("/"); - if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId); - return idParts.slice(2).join("/"); -}; -export interface extractDataFromPrivateRoomIdResponse { - organizationSlug: string; - worldSlug: string; - roomSlug: string; -} -export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => { - const idParts = roomId.split("/"); - if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId); - const organizationSlug = idParts[1]; - const worldSlug = idParts[2]; - const roomSlug = idParts[3]; - return { organizationSlug, worldSlug, roomSlug }; -}; diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 9aaf1edb..0465ade6 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -5,6 +5,8 @@ import { AdminPusherToBackMessage, AdminRoomMessage, BanMessage, + BatchToPusherMessage, + BatchToPusherRoomMessage, EmotePromptMessage, EmptyMessage, ItemEventMessage, @@ -13,17 +15,18 @@ import { PusherToBackMessage, QueryJitsiJwtMessage, RefreshRoomPromptMessage, + RoomMessage, ServerToAdminClientMessage, - ServerToClientMessage, SilentMessage, UserMovesMessage, + VariableMessage, WebRtcSignalToServerMessage, WorldFullWarningToRoomMessage, ZoneMessage, } from "./Messages/generated/messages_pb"; import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc"; import { socketManager } from "./Services/SocketManager"; -import { emitError } from "./Services/MessageHelpers"; +import { emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket } from "./Services/MessageHelpers"; import { User, UserSocket } from "./Model/User"; import { GameRoom } from "./Model/GameRoom"; import Debug from "debug"; @@ -32,7 +35,8 @@ import { Admin } from "./Model/Admin"; const debug = Debug("roommanager"); export type AdminSocket = ServerDuplexStream; -export type ZoneSocket = ServerWritableStream; +export type ZoneSocket = ServerWritableStream; +export type RoomSocket = ServerWritableStream; const roomManager: IRoomManagerServer = { joinRoom: (call: UserSocket): void => { @@ -42,79 +46,96 @@ const roomManager: IRoomManagerServer = { let user: User | null = null; call.on("data", (message: PusherToBackMessage) => { - try { - if (room === null || user === null) { - if (message.hasJoinroommessage()) { - socketManager - .handleJoinRoom(call, message.getJoinroommessage() as JoinRoomMessage) - .then(({ room: gameRoom, user: myUser }) => { - if (call.writable) { - room = gameRoom; - user = myUser; - } else { - //Connexion may have been closed before the init was finished, so we have to manually disconnect the user. - socketManager.leaveRoom(gameRoom, myUser); - } - }); - } else { - throw new Error("The first message sent MUST be of type JoinRoomMessage"); - } - } else { - if (message.hasJoinroommessage()) { - throw new Error("Cannot call JoinRoomMessage twice!"); - } else if (message.hasUsermovesmessage()) { - socketManager.handleUserMovesMessage( - room, - user, - message.getUsermovesmessage() as UserMovesMessage - ); - } else if (message.hasSilentmessage()) { - socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage); - } else if (message.hasItemeventmessage()) { - socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage); - } else if (message.hasWebrtcsignaltoservermessage()) { - socketManager.emitVideo( - room, - user, - message.getWebrtcsignaltoservermessage() as WebRtcSignalToServerMessage - ); - } else if (message.hasWebrtcscreensharingsignaltoservermessage()) { - socketManager.emitScreenSharing( - room, - user, - message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage - ); - } else if (message.hasPlayglobalmessage()) { - socketManager.emitPlayGlobalMessage(room, message.getPlayglobalmessage() as PlayGlobalMessage); - } else if (message.hasQueryjitsijwtmessage()) { - socketManager.handleQueryJitsiJwtMessage( - user, - message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage - ); - } else if (message.hasEmotepromptmessage()) { - socketManager.handleEmoteEventMessage( - room, - user, - message.getEmotepromptmessage() as EmotePromptMessage - ); - } else if (message.hasSendusermessage()) { - const sendUserMessage = message.getSendusermessage(); - if (sendUserMessage !== undefined) { - socketManager.handlerSendUserMessage(user, sendUserMessage); - } - } else if (message.hasBanusermessage()) { - const banUserMessage = message.getBanusermessage(); - if (banUserMessage !== undefined) { - socketManager.handlerBanUserMessage(room, user, banUserMessage); + (async () => { + try { + if (room === null || user === null) { + if (message.hasJoinroommessage()) { + socketManager + .handleJoinRoom(call, message.getJoinroommessage() as JoinRoomMessage) + .then(({ room: gameRoom, user: myUser }) => { + if (call.writable) { + room = gameRoom; + user = myUser; + } else { + //Connexion may have been closed before the init was finished, so we have to manually disconnect the user. + socketManager.leaveRoom(gameRoom, myUser); + } + }) + .catch((e) => emitError(call, e)); + } else { + throw new Error("The first message sent MUST be of type JoinRoomMessage"); } } else { - throw new Error("Unhandled message type"); + if (message.hasJoinroommessage()) { + throw new Error("Cannot call JoinRoomMessage twice!"); + } else if (message.hasUsermovesmessage()) { + socketManager.handleUserMovesMessage( + room, + user, + message.getUsermovesmessage() as UserMovesMessage + ); + } else if (message.hasSilentmessage()) { + socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage); + } else if (message.hasItemeventmessage()) { + socketManager.handleItemEvent( + room, + user, + message.getItemeventmessage() as ItemEventMessage + ); + } else if (message.hasVariablemessage()) { + await socketManager.handleVariableEvent( + room, + user, + message.getVariablemessage() as VariableMessage + ); + } else if (message.hasWebrtcsignaltoservermessage()) { + socketManager.emitVideo( + room, + user, + message.getWebrtcsignaltoservermessage() as WebRtcSignalToServerMessage + ); + } else if (message.hasWebrtcscreensharingsignaltoservermessage()) { + socketManager.emitScreenSharing( + room, + user, + message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage + ); + } else if (message.hasPlayglobalmessage()) { + socketManager.emitPlayGlobalMessage( + room, + message.getPlayglobalmessage() as PlayGlobalMessage + ); + } else if (message.hasQueryjitsijwtmessage()) { + socketManager.handleQueryJitsiJwtMessage( + user, + message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage + ); + } else if (message.hasEmotepromptmessage()) { + socketManager.handleEmoteEventMessage( + room, + user, + message.getEmotepromptmessage() as EmotePromptMessage + ); + } else if (message.hasSendusermessage()) { + const sendUserMessage = message.getSendusermessage(); + if (sendUserMessage !== undefined) { + socketManager.handlerSendUserMessage(user, sendUserMessage); + } + } else if (message.hasBanusermessage()) { + const banUserMessage = message.getBanusermessage(); + if (banUserMessage !== undefined) { + socketManager.handlerBanUserMessage(room, user, banUserMessage); + } + } else { + throw new Error("Unhandled message type"); + } } + } catch (e) { + console.error(e); + emitError(call, e); + call.end(); } - } catch (e) { - emitError(call, e); - call.end(); - } + })().catch((e) => console.error(e)); }); call.on("end", () => { @@ -136,20 +157,54 @@ const roomManager: IRoomManagerServer = { debug("listenZone called"); const zoneMessage = call.request; - socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager + .addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => { + emitErrorOnZoneSocket(call, e.toString()); + }); call.on("cancelled", () => { debug("listenZone cancelled"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); call.end(); }); call.on("close", () => { debug("listenZone connection closed"); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); }).on("error", (e) => { console.error("An error occurred in listenZone stream:", e); - socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); + socketManager + .removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()) + .catch((e) => console.error(e)); + call.end(); + }); + }, + + listenRoom(call: RoomSocket): void { + debug("listenRoom called"); + const roomMessage = call.request; + + socketManager.addRoomListener(call, roomMessage.getRoomid()).catch((e) => { + emitErrorOnRoomSocket(call, e.toString()); + }); + + call.on("cancelled", () => { + debug("listenRoom cancelled"); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); + call.end(); + }); + + call.on("close", () => { + debug("listenRoom connection closed"); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); + }).on("error", (e) => { + console.error("An error occurred in listenRoom stream:", e); + socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e)); call.end(); }); }, @@ -165,9 +220,12 @@ const roomManager: IRoomManagerServer = { if (room === null) { if (message.hasSubscribetoroom()) { const roomId = message.getSubscribetoroom(); - socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => { - room = gameRoom; - }); + socketManager + .handleJoinAdminRoom(admin, roomId) + .then((gameRoom: GameRoom) => { + room = gameRoom; + }) + .catch((e) => console.error(e)); } else { throw new Error("The first message sent MUST be of type JoinRoomMessage"); } @@ -192,11 +250,9 @@ const roomManager: IRoomManagerServer = { }); }, sendAdminMessage(call: ServerUnaryCall, callback: sendUnaryData): void { - socketManager.sendAdminMessage( - call.request.getRoomid(), - call.request.getRecipientuuid(), - call.request.getMessage() - ); + socketManager + .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, @@ -207,26 +263,33 @@ const roomManager: IRoomManagerServer = { }, ban(call: ServerUnaryCall, callback: sendUnaryData): void { // FIXME Work in progress - socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()); + socketManager + .banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendAdminMessageToRoom(call: ServerUnaryCall, callback: sendUnaryData): void { - socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager + .sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()) + .catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendWorldFullWarningToRoom( call: ServerUnaryCall, callback: sendUnaryData ): void { - socketManager.dispatchWorlFullWarning(call.request.getRoomid()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, sendRefreshRoomPrompt( call: ServerUnaryCall, callback: sendUnaryData ): void { - socketManager.dispatchRoomRefresh(call.request.getRoomid()); + // FIXME: we could improve return message by returning a Success|ErrorMessage message + socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch((e) => console.error(e)); callback(null, new EmptyMessage()); }, }; diff --git a/back/src/Services/AdminApi.ts b/back/src/Services/AdminApi.ts new file mode 100644 index 00000000..158a47c1 --- /dev/null +++ b/back/src/Services/AdminApi.ts @@ -0,0 +1,24 @@ +import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import Axios from "axios"; +import { MapDetailsData } from "./AdminApi/MapDetailsData"; +import { RoomRedirect } from "./AdminApi/RoomRedirect"; + +class AdminApi { + async fetchMapDetails(playUri: string): Promise { + if (!ADMIN_API_URL) { + return Promise.reject(new Error("No admin backoffice set!")); + } + + const params: { playUri: string } = { + playUri, + }; + + const res = await Axios.get(ADMIN_API_URL + "/api/map", { + headers: { Authorization: `${ADMIN_API_TOKEN}` }, + params, + }); + return res.data; + } +} + +export const adminApi = new AdminApi(); diff --git a/back/src/Services/AdminApi/CharacterTexture.ts b/back/src/Services/AdminApi/CharacterTexture.ts new file mode 100644 index 00000000..055b3033 --- /dev/null +++ b/back/src/Services/AdminApi/CharacterTexture.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; + +export const isCharacterTexture = new tg.IsInterface() + .withProperties({ + id: tg.isNumber, + level: tg.isNumber, + url: tg.isString, + rights: tg.isString, + }) + .get(); +export type CharacterTexture = tg.GuardedType; diff --git a/back/src/Services/AdminApi/MapDetailsData.ts b/back/src/Services/AdminApi/MapDetailsData.ts new file mode 100644 index 00000000..d3402b92 --- /dev/null +++ b/back/src/Services/AdminApi/MapDetailsData.ts @@ -0,0 +1,21 @@ +import * as tg from "generic-type-guard"; +import { isCharacterTexture } from "./CharacterTexture"; +import { isAny, isNumber } from "generic-type-guard"; + +/*const isNumericEnum = + (vs: T) => + (v: any): v is T => + typeof v === "number" && v in vs;*/ + +export const isMapDetailsData = new tg.IsInterface() + .withProperties({ + mapUrl: tg.isString, + policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes), + tags: tg.isArray(tg.isString), + textures: tg.isArray(isCharacterTexture), + }) + .withOptionalProperties({ + roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated + }) + .get(); +export type MapDetailsData = tg.GuardedType; diff --git a/back/src/Services/AdminApi/RoomRedirect.ts b/back/src/Services/AdminApi/RoomRedirect.ts new file mode 100644 index 00000000..7257ebd3 --- /dev/null +++ b/back/src/Services/AdminApi/RoomRedirect.ts @@ -0,0 +1,8 @@ +import * as tg from "generic-type-guard"; + +export const isRoomRedirect = new tg.IsInterface() + .withProperties({ + redirectUrl: tg.isString, + }) + .get(); +export type RoomRedirect = tg.GuardedType; diff --git a/back/src/Services/LocalUrlError.ts b/back/src/Services/LocalUrlError.ts new file mode 100644 index 00000000..a4984fdd --- /dev/null +++ b/back/src/Services/LocalUrlError.ts @@ -0,0 +1 @@ +export class LocalUrlError extends Error {} diff --git a/back/src/Services/MapFetcher.ts b/back/src/Services/MapFetcher.ts new file mode 100644 index 00000000..0a8cb4bd --- /dev/null +++ b/back/src/Services/MapFetcher.ts @@ -0,0 +1,67 @@ +import Axios from "axios"; +import ipaddr from "ipaddr.js"; +import { Resolver } from "dns"; +import { promisify } from "util"; +import { LocalUrlError } from "./LocalUrlError"; +import { ITiledMap } from "@workadventure/tiled-map-type-guard"; +import { isTiledMap } from "@workadventure/tiled-map-type-guard/dist"; + +class MapFetcher { + async fetchMap(mapUrl: string): Promise { + // Before trying to make the query, let's verify the map is actually on the open internet (and not a local test map) + + if (await this.isLocalUrl(mapUrl)) { + throw new LocalUrlError('URL for map "' + mapUrl + '" targets a local map'); + } + + // Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that + // returns local URLs. Alas, Axios cannot pin a URL to a given IP. So "isLocalUrl" and Axios.get could potentially + // target to different servers (and one could trick Axios.get into loading resources on the internal network + // despite isLocalUrl checking that. + // We can deem this problem not that important because: + // - We make sure we are only passing "GET" requests + // - The result of the query is never displayed to the end user + const res = await Axios.get(mapUrl, { + maxContentLength: 50 * 1024 * 1024, // Max content length: 50MB. Maps should not be bigger + timeout: 10000, // Timeout after 10 seconds + }); + + if (!isTiledMap(res.data)) { + throw new Error("Invalid map format for map " + mapUrl); + } + + return res.data; + } + + /** + * Returns true if the domain name is localhost of *.localhost + * Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x) + * + * @private + */ + async isLocalUrl(url: string): Promise { + const urlObj = new URL(url); + if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) { + return true; + } + + let addresses = []; + if (!ipaddr.isValid(urlObj.hostname)) { + const resolver = new Resolver(); + addresses = await promisify(resolver.resolve).bind(resolver)(urlObj.hostname); + } else { + addresses = [urlObj.hostname]; + } + + for (const address of addresses) { + const addr = ipaddr.parse(address); + if (addr.range() !== "unicast") { + return true; + } + } + + return false; + } +} + +export const mapFetcher = new MapFetcher(); diff --git a/back/src/Services/MessageHelpers.ts b/back/src/Services/MessageHelpers.ts index 493f7173..606374be 100644 --- a/back/src/Services/MessageHelpers.ts +++ b/back/src/Services/MessageHelpers.ts @@ -1,5 +1,14 @@ -import { ErrorMessage, ServerToClientMessage } from "../Messages/generated/messages_pb"; +import { + BatchMessage, + BatchToPusherMessage, + BatchToPusherRoomMessage, + ErrorMessage, + ServerToClientMessage, + SubToPusherMessage, + SubToPusherRoomMessage, +} from "../Messages/generated/messages_pb"; import { UserSocket } from "_Model/User"; +import { RoomSocket, ZoneSocket } from "../RoomManager"; export function emitError(Client: UserSocket, message: string): void { const errorMessage = new ErrorMessage(); @@ -13,3 +22,39 @@ export function emitError(Client: UserSocket, message: string): void { //} console.warn(message); } + +export function emitErrorOnRoomSocket(Client: RoomSocket, message: string): void { + console.error(message); + + const errorMessage = new ErrorMessage(); + errorMessage.setMessage(message); + + const subToPusherRoomMessage = new SubToPusherRoomMessage(); + subToPusherRoomMessage.setErrormessage(errorMessage); + + const batchToPusherMessage = new BatchToPusherRoomMessage(); + batchToPusherMessage.addPayload(subToPusherRoomMessage); + + //if (!Client.disconnecting) { + Client.write(batchToPusherMessage); + //} + console.warn(message); +} + +export function emitErrorOnZoneSocket(Client: ZoneSocket, message: string): void { + console.error(message); + + const errorMessage = new ErrorMessage(); + errorMessage.setMessage(message); + + const subToPusherMessage = new SubToPusherMessage(); + subToPusherMessage.setErrormessage(errorMessage); + + const batchToPusherMessage = new BatchToPusherMessage(); + batchToPusherMessage.addPayload(subToPusherMessage); + + //if (!Client.disconnecting) { + Client.write(batchToPusherMessage); + //} + console.warn(message); +} diff --git a/back/src/Services/RedisClient.ts b/back/src/Services/RedisClient.ts new file mode 100644 index 00000000..1f8c1ecd --- /dev/null +++ b/back/src/Services/RedisClient.ts @@ -0,0 +1,23 @@ +import { ClientOpts, createClient, RedisClient } from "redis"; +import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from "../Enum/EnvironmentVariable"; + +let redisClient: RedisClient | null = null; + +if (REDIS_HOST !== undefined) { + const config: ClientOpts = { + host: REDIS_HOST, + port: REDIS_PORT, + }; + + if (REDIS_PASSWORD) { + config.password = REDIS_PASSWORD; + } + + redisClient = createClient(config); + + redisClient.on("error", (err) => { + console.error("Error connecting to Redis:", err); + }); +} + +export { redisClient }; diff --git a/back/src/Services/Repository/RedisVariablesRepository.ts b/back/src/Services/Repository/RedisVariablesRepository.ts new file mode 100644 index 00000000..95d757ca --- /dev/null +++ b/back/src/Services/Repository/RedisVariablesRepository.ts @@ -0,0 +1,43 @@ +import { promisify } from "util"; +import { RedisClient } from "redis"; +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +/** + * Class in charge of saving/loading variables from the data store + */ +export class RedisVariablesRepository implements VariablesRepositoryInterface { + private readonly hgetall: OmitThisParameter<(arg1: string) => Promise<{ [p: string]: string }>>; + private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise>; + private readonly hdel: OmitThisParameter<(arg1: string, arg2: string) => Promise>; + + constructor(private redisClient: RedisClient) { + /* eslint-disable @typescript-eslint/unbound-method */ + this.hgetall = promisify(redisClient.hgetall).bind(redisClient); + this.hset = promisify(redisClient.hset).bind(redisClient); + this.hdel = promisify(redisClient.hdel).bind(redisClient); + /* eslint-enable @typescript-eslint/unbound-method */ + } + + /** + * Load all variables for a room. + * + * Note: in Redis, variables are stored in a hashmap and the key is the roomUrl + */ + async loadVariables(roomUrl: string): Promise<{ [key: string]: string }> { + return this.hgetall(roomUrl); + } + + async saveVariable(roomUrl: string, key: string, value: string): Promise { + // The value is passed to JSON.stringify client side. If value is "undefined", JSON.stringify returns "undefined" + // which is translated to empty string when fetching the value in the pusher. + // Therefore, empty string server side == undefined client side. + if (value === "") { + return this.hdel(roomUrl, key); + } + + // TODO: SLOW WRITING EVERY 2 SECONDS WITH A TIMEOUT + + // @ts-ignore See https://stackoverflow.com/questions/63539317/how-do-i-use-hmset-with-node-promisify + return this.hset(roomUrl, key, value); + } +} diff --git a/back/src/Services/Repository/VariablesRepository.ts b/back/src/Services/Repository/VariablesRepository.ts new file mode 100644 index 00000000..9f668bcf --- /dev/null +++ b/back/src/Services/Repository/VariablesRepository.ts @@ -0,0 +1,14 @@ +import { RedisVariablesRepository } from "./RedisVariablesRepository"; +import { redisClient } from "../RedisClient"; +import { VoidVariablesRepository } from "./VoidVariablesRepository"; +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +let variablesRepository: VariablesRepositoryInterface; +if (!redisClient) { + console.warn("WARNING: Redis isnot configured. No variables will be persisted."); + variablesRepository = new VoidVariablesRepository(); +} else { + variablesRepository = new RedisVariablesRepository(redisClient); +} + +export { variablesRepository }; diff --git a/back/src/Services/Repository/VariablesRepositoryInterface.ts b/back/src/Services/Repository/VariablesRepositoryInterface.ts new file mode 100644 index 00000000..d927f5ff --- /dev/null +++ b/back/src/Services/Repository/VariablesRepositoryInterface.ts @@ -0,0 +1,10 @@ +export interface VariablesRepositoryInterface { + /** + * Load all variables for a room. + * + * Note: in Redis, variables are stored in a hashmap and the key is the roomUrl + */ + loadVariables(roomUrl: string): Promise<{ [key: string]: string }>; + + saveVariable(roomUrl: string, key: string, value: string): Promise; +} diff --git a/back/src/Services/Repository/VoidVariablesRepository.ts b/back/src/Services/Repository/VoidVariablesRepository.ts new file mode 100644 index 00000000..0a2664e8 --- /dev/null +++ b/back/src/Services/Repository/VoidVariablesRepository.ts @@ -0,0 +1,14 @@ +import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface"; + +/** + * Mock class in charge of NOT saving/loading variables from the data store + */ +export class VoidVariablesRepository implements VariablesRepositoryInterface { + loadVariables(roomUrl: string): Promise<{ [key: string]: string }> { + return Promise.resolve({}); + } + + saveVariable(roomUrl: string, key: string, value: string): Promise { + return Promise.resolve(0); + } +} diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index e61763cd..a7a10f5f 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -30,6 +30,9 @@ import { BanUserMessage, RefreshRoomMessage, EmotePromptMessage, + VariableMessage, + BatchToPusherRoomMessage, + SubToPusherRoomMessage, } from "../Messages/generated/messages_pb"; import { User, UserSocket } from "../Model/User"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; @@ -48,7 +51,7 @@ import Jwt from "jsonwebtoken"; import { JITSI_URL } from "../Enum/EnvironmentVariable"; import { clientEventsEmitter } from "./ClientEventsEmitter"; import { gaugeManager } from "./GaugeManager"; -import { ZoneSocket } from "../RoomManager"; +import { RoomSocket, ZoneSocket } from "../RoomManager"; import { Zone } from "_Model/Zone"; import Debug from "debug"; import { Admin } from "_Model/Admin"; @@ -65,7 +68,9 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo } export class SocketManager { - private rooms: Map = new Map(); + //private rooms = new Map(); + // List of rooms in process of loading. + private roomsPromises = new Map>(); constructor() { clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => { @@ -101,6 +106,16 @@ export class SocketManager { roomJoinedMessage.addItem(itemStateMessage); } + const variables = await room.getVariablesForTags(user.tags); + + for (const [name, value] of variables.entries()) { + const variableMessage = new VariableMessage(); + variableMessage.setName(name); + variableMessage.setValue(value); + + roomJoinedMessage.addVariable(variableMessage); + } + roomJoinedMessage.setCurrentuserid(user.id); const serverToClientMessage = new ServerToClientMessage(); @@ -114,30 +129,25 @@ export class SocketManager { } handleUserMovesMessage(room: GameRoom, user: User, userMovesMessage: UserMovesMessage) { - try { - const userMoves = userMovesMessage.toObject(); - const position = userMovesMessage.getPosition(); + const userMoves = userMovesMessage.toObject(); + const position = userMovesMessage.getPosition(); - // If CPU is high, let's drop messages of users moving (we will only dispatch the final position) - if (cpuTracker.isOverHeating() && userMoves.position?.moving === true) { - return; - } - - if (position === undefined) { - throw new Error("Position not found in message"); - } - const viewport = userMoves.viewport; - if (viewport === undefined) { - throw new Error("Viewport not found in message"); - } - - // update position in the world - room.updatePosition(user, ProtobufUtils.toPointInterface(position)); - //room.setViewport(client, client.viewport); - } catch (e) { - console.error('An error occurred on "user_position" event'); - console.error(e); + // If CPU is high, let's drop messages of users moving (we will only dispatch the final position) + if (cpuTracker.isOverHeating() && userMoves.position?.moving === true) { + return; } + + if (position === undefined) { + throw new Error("Position not found in message"); + } + const viewport = userMoves.viewport; + if (viewport === undefined) { + throw new Error("Viewport not found in message"); + } + + // update position in the world + room.updatePosition(user, ProtobufUtils.toPointInterface(position)); + //room.setViewport(client, client.viewport); } // Useless now, will be useful again if we allow editing details in game @@ -156,32 +166,26 @@ export class SocketManager { }*/ handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) { - try { - room.setSilent(user, silentMessage.getSilent()); - } catch (e) { - console.error('An error occurred on "handleSilentMessage"'); - console.error(e); - } + room.setSilent(user, silentMessage.getSilent()); } handleItemEvent(room: GameRoom, user: User, itemEventMessage: ItemEventMessage) { const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage); - try { - const subMessage = new SubMessage(); - subMessage.setItemeventmessage(itemEventMessage); + const subMessage = new SubMessage(); + subMessage.setItemeventmessage(itemEventMessage); - // Let's send the event without using the SocketIO room. - // TODO: move this in the GameRoom class. - for (const user of room.getUsers().values()) { - user.emitInBatch(subMessage); - } - - room.setItemState(itemEvent.itemId, itemEvent.state); - } catch (e) { - console.error('An error occurred on "item_event"'); - console.error(e); + // Let's send the event without using the SocketIO room. + // TODO: move this in the GameRoom class. + for (const user of room.getUsers().values()) { + user.emitInBatch(subMessage); } + + room.setItemState(itemEvent.itemId, itemEvent.state); + } + + handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage): Promise { + return room.setVariable(variableMessage.getName(), variableMessage.getValue(), user); } emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { @@ -250,21 +254,21 @@ export class SocketManager { //user leave previous world room.leave(user); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.roomsPromises.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); - debug('Room is empty. Deleting room "%s"', room.roomId); + debug('Room is empty. Deleting room "%s"', room.roomUrl); } } finally { - clientEventsEmitter.emitClientLeave(user.uuid, room.roomId); + clientEventsEmitter.emitClientLeave(user.uuid, room.roomUrl); console.log("A user left"); } } async getOrCreateRoom(roomId: string): Promise { - //check and create new world for a room - let world = this.rooms.get(roomId); - if (world === undefined) { - world = new GameRoom( + //check and create new room + let roomPromise = this.roomsPromises.get(roomId); + if (roomPromise === undefined) { + roomPromise = GameRoom.create( roomId, (user: User, group: Group) => this.joinWebRtcRoom(user, group), (user: User, group: Group) => this.disConnectedUser(user, group), @@ -278,11 +282,18 @@ export class SocketManager { this.onClientLeave(thing, newZone, listener), (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => this.onEmote(emoteEventMessage, listener) - ); - gaugeManager.incNbRoomGauge(); - this.rooms.set(roomId, world); + ) + .then((gameRoom) => { + gaugeManager.incNbRoomGauge(); + return gameRoom; + }) + .catch((e) => { + this.roomsPromises.delete(roomId); + throw e; + }); + this.roomsPromises.set(roomId, roomPromise); } - return Promise.resolve(world); + return roomPromise; } private async joinRoom( @@ -308,6 +319,7 @@ export class SocketManager { throw new Error("clientUser.userId is not an integer " + thing.id); } userJoinedZoneMessage.setUserid(thing.id); + userJoinedZoneMessage.setUseruuid(thing.uuid); userJoinedZoneMessage.setName(thing.name); userJoinedZoneMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(thing.characterLayers)); userJoinedZoneMessage.setPosition(ProtobufUtils.toPositionMessage(thing.getPosition())); @@ -425,7 +437,6 @@ export class SocketManager { // Let's send 2 messages: one to the user joining the group and one to the other user const webrtcStartMessage1 = new WebRtcStartMessage(); webrtcStartMessage1.setUserid(otherUser.id); - webrtcStartMessage1.setName(otherUser.name); webrtcStartMessage1.setInitiator(true); if (TURN_STATIC_AUTH_SECRET !== "") { const { username, password } = this.getTURNCredentials("" + otherUser.id, TURN_STATIC_AUTH_SECRET); @@ -436,14 +447,10 @@ export class SocketManager { const serverToClientMessage1 = new ServerToClientMessage(); serverToClientMessage1.setWebrtcstartmessage(webrtcStartMessage1); - //if (!user.socket.disconnecting) { user.socket.write(serverToClientMessage1); - //console.log('Sending webrtcstart initiator to '+user.socket.userId) - //} const webrtcStartMessage2 = new WebRtcStartMessage(); webrtcStartMessage2.setUserid(user.id); - webrtcStartMessage2.setName(user.name); webrtcStartMessage2.setInitiator(false); if (TURN_STATIC_AUTH_SECRET !== "") { const { username, password } = this.getTURNCredentials("" + user.id, TURN_STATIC_AUTH_SECRET); @@ -454,10 +461,7 @@ export class SocketManager { const serverToClientMessage2 = new ServerToClientMessage(); serverToClientMessage2.setWebrtcstartmessage(webrtcStartMessage2); - //if (!otherUser.socket.disconnecting) { otherUser.socket.write(serverToClientMessage2); - //console.log('Sending webrtcstart to '+otherUser.socket.userId) - //} } } @@ -515,21 +519,16 @@ export class SocketManager { } emitPlayGlobalMessage(room: GameRoom, playGlobalMessage: PlayGlobalMessage) { - try { - const serverToClientMessage = new ServerToClientMessage(); - serverToClientMessage.setPlayglobalmessage(playGlobalMessage); + const serverToClientMessage = new ServerToClientMessage(); + serverToClientMessage.setPlayglobalmessage(playGlobalMessage); - for (const [id, user] of room.getUsers().entries()) { - user.socket.write(serverToClientMessage); - } - } catch (e) { - console.error('An error occurred on "emitPlayGlobalMessage" event'); - console.error(e); + for (const [id, user] of room.getUsers().entries()) { + user.socket.write(serverToClientMessage); } } - public getWorlds(): Map { - return this.rooms; + public getWorlds(): Map> { + return this.roomsPromises; } public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) { @@ -599,11 +598,10 @@ export class SocketManager { }, 10000); } - public addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): void { - const room = this.rooms.get(roomId); + public async addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In addZoneListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In addZoneListener, could not find room with id '" + roomId + "'"); } const things = room.addZoneListener(call, x, y); @@ -614,6 +612,7 @@ export class SocketManager { if (thing instanceof User) { const userJoinedMessage = new UserJoinedZoneMessage(); userJoinedMessage.setUserid(thing.id); + userJoinedMessage.setUseruuid(thing.uuid); userJoinedMessage.setName(thing.name); userJoinedMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(thing.characterLayers)); userJoinedMessage.setPosition(ProtobufUtils.toPositionMessage(thing.getPosition())); @@ -643,16 +642,37 @@ export class SocketManager { call.write(batchMessage); } - removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number) { - const room = this.rooms.get(roomId); + async removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { - console.error("In removeZoneListener, could not find room with id '" + roomId + "'"); - return; + throw new Error("In removeZoneListener, could not find room with id '" + roomId + "'"); } room.removeZoneListener(call, x, y); } + async addRoomListener(call: RoomSocket, roomId: string) { + const room = await this.getOrCreateRoom(roomId); + if (!room) { + throw new Error("In addRoomListener, could not find room with id '" + roomId + "'"); + } + + room.addRoomListener(call); + + const batchMessage = new BatchToPusherRoomMessage(); + + call.write(batchMessage); + } + + async removeRoomListener(call: RoomSocket, roomId: string) { + const room = await this.roomsPromises.get(roomId); + if (!room) { + throw new Error("In removeRoomListener, could not find room with id '" + roomId + "'"); + } + + room.removeRoomListener(call); + } + public async handleJoinAdminRoom(admin: Admin, roomId: string): Promise { const room = await socketManager.getOrCreateRoom(roomId); @@ -664,14 +684,14 @@ export class SocketManager { public leaveAdminRoom(room: GameRoom, admin: Admin) { room.adminLeave(admin); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.roomsPromises.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); - debug('Room is empty. Deleting room "%s"', room.roomId); + debug('Room is empty. Deleting room "%s"', room.roomUrl); } } - public sendAdminMessage(roomId: string, recipientUuid: string, message: string): void { - const room = this.rooms.get(roomId); + public async sendAdminMessage(roomId: string, recipientUuid: string, message: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { console.error( "In sendAdminMessage, could not find room with id '" + @@ -701,8 +721,8 @@ export class SocketManager { recipient.socket.write(serverToClientMessage); } - public banUser(roomId: string, recipientUuid: string, message: string): void { - const room = this.rooms.get(roomId); + public async banUser(roomId: string, recipientUuid: string, message: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { console.error( "In banUser, could not find room with id '" + @@ -737,8 +757,8 @@ export class SocketManager { recipient.socket.end(); } - sendAdminRoomMessage(roomId: string, message: string) { - const room = this.rooms.get(roomId); + async sendAdminRoomMessage(roomId: string, message: string) { + const room = await this.roomsPromises.get(roomId); if (!room) { //todo: this should cause the http call to return a 500 console.error( @@ -761,8 +781,8 @@ export class SocketManager { }); } - dispatchWorlFullWarning(roomId: string): void { - const room = this.rooms.get(roomId); + async dispatchWorldFullWarning(roomId: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { //todo: this should cause the http call to return a 500 console.error( @@ -783,8 +803,8 @@ export class SocketManager { }); } - dispatchRoomRefresh(roomId: string): void { - const room = this.rooms.get(roomId); + async dispatchRoomRefresh(roomId: string): Promise { + const room = await this.roomsPromises.get(roomId); if (!room) { return; } diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts new file mode 100644 index 00000000..e8aaef25 --- /dev/null +++ b/back/src/Services/VariablesManager.ts @@ -0,0 +1,218 @@ +/** + * Handles variables shared between the scripting API and the server. + */ +import { ITiledMap, ITiledMapObject, ITiledMapObjectLayer } from "@workadventure/tiled-map-type-guard/dist"; +import { User } from "_Model/User"; +import { variablesRepository } from "./Repository/VariablesRepository"; +import { redisClient } from "./RedisClient"; + +interface Variable { + defaultValue?: string; + persist?: boolean; + readableBy?: string; + writableBy?: string; +} + +export class VariablesManager { + /** + * The actual values of the variables for the current room + */ + private _variables = new Map(); + + /** + * The list of variables that are allowed + */ + private variableObjects: Map | undefined; + + /** + * @param map The map can be "null" if it is hosted on a private network. In this case, we assume this is a test setup and bypass any server-side checks. + */ + constructor(private roomUrl: string, private map: ITiledMap | null) { + // We initialize the list of variable object at room start. The objects cannot be edited later + // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) + if (map) { + this.variableObjects = VariablesManager.findVariablesInMap(map); + + // Let's initialize default values + for (const [name, variableObject] of this.variableObjects.entries()) { + if (variableObject.defaultValue !== undefined) { + this._variables.set(name, variableObject.defaultValue); + } + } + } + } + + /** + * Let's load data from the Redis backend. + */ + public async init(): Promise { + if (!this.shouldPersist()) { + return this; + } + const variables = await variablesRepository.loadVariables(this.roomUrl); + for (const key in variables) { + // Let's only set variables if they are in the map (if the map has changed, maybe stored variables do not exist anymore) + if (this.variableObjects) { + const variableObject = this.variableObjects.get(key); + if (variableObject === undefined) { + continue; + } + if (!variableObject.persist) { + continue; + } + } + + this._variables.set(key, variables[key]); + } + return this; + } + + /** + * Returns true if saving should be enabled, and false otherwise. + * + * Saving is enabled if REDIS_HOST is set + * unless we are editing a local map + * unless we are in dev mode in which case it is ok to save + * + * @private + */ + private shouldPersist(): boolean { + return redisClient !== null && (this.map !== null || process.env.NODE_ENV === "development"); + } + + private static findVariablesInMap(map: ITiledMap): Map { + const objects = new Map(); + for (const layer of map.layers) { + if (layer.type === "objectgroup") { + for (const object of (layer as ITiledMapObjectLayer).objects) { + if (object.type === "variable") { + if (object.template) { + console.warn( + 'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.' + ); + continue; + } + + // We store a copy of the object (to make it immutable) + objects.set(object.name, this.iTiledObjectToVariable(object)); + } + } + } + } + return objects; + } + + private static iTiledObjectToVariable(object: ITiledMapObject): Variable { + const variable: Variable = {}; + + if (object.properties) { + for (const property of object.properties) { + const value = property.value; + switch (property.name) { + case "default": + variable.defaultValue = JSON.stringify(value); + break; + case "persist": + if (typeof value !== "boolean") { + throw new Error('The persist property of variable "' + object.name + '" must be a boolean'); + } + variable.persist = value; + break; + case "writableBy": + if (typeof value !== "string") { + throw new Error( + 'The writableBy property of variable "' + object.name + '" must be a string' + ); + } + if (value) { + variable.writableBy = value; + } + break; + case "readableBy": + if (typeof value !== "string") { + throw new Error( + 'The readableBy property of variable "' + object.name + '" must be a string' + ); + } + if (value) { + variable.readableBy = value; + } + break; + } + } + } + + return variable; + } + + /** + * Sets the variable. + * + * Returns who is allowed to read the variable (the readableby property) or "undefined" if anyone can read it. + * Also, returns "false" if the variable was not modified (because we set it to the value it already has) + * + * @param name + * @param value + * @param user + */ + setVariable(name: string, value: string, user: User): string | undefined | false { + let readableBy: string | undefined; + let variableObject: Variable | undefined; + if (this.variableObjects) { + variableObject = this.variableObjects.get(name); + if (variableObject === undefined) { + throw new Error('Trying to set a variable "' + name + '" that is not defined as an object in the map.'); + } + + if (variableObject.writableBy && !user.tags.includes(variableObject.writableBy)) { + throw new Error( + 'Trying to set a variable "' + + name + + '". User "' + + user.name + + '" does not have sufficient permission. Required tag: "' + + variableObject.writableBy + + '". User tags: ' + + user.tags.join(", ") + + "." + ); + } + + readableBy = variableObject.readableBy; + } + + // If the value is not modified, return false + if (this._variables.get(name) === value) { + return false; + } + + this._variables.set(name, value); + + if (variableObject !== undefined && variableObject.persist) { + variablesRepository + .saveVariable(this.roomUrl, name, value) + .catch((e) => console.error("Error while saving variable in Redis:", e)); + } + + return readableBy; + } + + public getVariablesForTags(tags: string[]): Map { + if (this.variableObjects === undefined) { + return this._variables; + } + + const readableVariables = new Map(); + + for (const [key, value] of this._variables.entries()) { + const variableObject = this.variableObjects.get(key); + if (variableObject === undefined) { + throw new Error('Unexpected variable "' + key + '" found has no associated variableObject.'); + } + if (!variableObject.readableBy || tags.includes(variableObject.readableBy)) { + readableVariables.set(key, value); + } + } + return readableVariables; + } +} diff --git a/back/tests/GameRoomTest.ts b/back/tests/GameRoomTest.ts index 6bdc6912..7540ad94 100644 --- a/back/tests/GameRoomTest.ts +++ b/back/tests/GameRoomTest.ts @@ -1,59 +1,62 @@ import "jasmine"; -import {ConnectCallback, DisconnectCallback, GameRoom} from "../src/Model/GameRoom"; -import {Point} from "../src/Model/Websocket/MessageUserPosition"; -import {Group} from "../src/Model/Group"; -import {User, UserSocket} from "_Model/User"; -import {JoinRoomMessage, PositionMessage} from "../src/Messages/generated/messages_pb"; +import { ConnectCallback, DisconnectCallback, GameRoom } from "../src/Model/GameRoom"; +import { Point } from "../src/Model/Websocket/MessageUserPosition"; +import { Group } from "../src/Model/Group"; +import { User, UserSocket } from "_Model/User"; +import { JoinRoomMessage, PositionMessage } from "../src/Messages/generated/messages_pb"; import Direction = PositionMessage.Direction; -import {EmoteCallback} from "_Model/Zone"; +import { EmoteCallback } from "_Model/Zone"; function createMockUser(userId: number): User { return { - userId + userId, } as unknown as User; } function createMockUserSocket(): UserSocket { - return { - } as unknown as UserSocket; + return {} as unknown as UserSocket; } -function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMessage -{ +function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMessage { const positionMessage = new PositionMessage(); positionMessage.setX(x); positionMessage.setY(y); positionMessage.setDirection(Direction.DOWN); positionMessage.setMoving(false); const joinRoomMessage = new JoinRoomMessage(); - joinRoomMessage.setUseruuid('1'); - joinRoomMessage.setIpaddress('10.0.0.2'); - joinRoomMessage.setName('foo'); - joinRoomMessage.setRoomid('_/global/test.json'); + joinRoomMessage.setUseruuid("1"); + joinRoomMessage.setIpaddress("10.0.0.2"); + joinRoomMessage.setName("foo"); + joinRoomMessage.setRoomid("_/global/test.json"); joinRoomMessage.setPositionmessage(positionMessage); return joinRoomMessage; } -const emote: EmoteCallback = (emoteEventMessage, listener): void => {} +const emote: EmoteCallback = (emoteEventMessage, listener): void => {}; describe("GameRoom", () => { - it("should connect user1 and user2", () => { + it("should connect user1 and user2", async () => { let connectCalledNumber: number = 0; const connect: ConnectCallback = (user: User, group: Group): void => { connectCalledNumber++; - } - const disconnect: DisconnectCallback = (user: User, group: Group): void => { + }; + const disconnect: DisconnectCallback = (user: User, group: Group): void => {}; - } + const world = await GameRoom.create( + "https://play.workadventu.re/_/global/localhost/test.json", + connect, + disconnect, + 160, + 160, + () => {}, + () => {}, + () => {}, + emote + ); + const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100)); - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); - - - - const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); - - const user2 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 500, 100)); + const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 500, 100)); world.updatePosition(user2, new Point(261, 100)); @@ -67,26 +70,34 @@ describe("GameRoom", () => { expect(connectCalledNumber).toBe(2); }); - it("should connect 3 users", () => { + it("should connect 3 users", async () => { let connectCalled: boolean = false; const connect: ConnectCallback = (user: User, group: Group): void => { connectCalled = true; - } - const disconnect: DisconnectCallback = (user: User, group: Group): void => { + }; + const disconnect: DisconnectCallback = (user: User, group: Group): void => {}; - } + const world = await GameRoom.create( + "https://play.workadventu.re/_/global/localhost/test.json", + connect, + disconnect, + 160, + 160, + () => {}, + () => {}, + () => {}, + emote + ); - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); + const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100)); - const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); - - const user2 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 200, 100)); + const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 200, 100)); expect(connectCalled).toBe(true); connectCalled = false; // baz joins at the outer limit of the group - const user3 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 311, 100)); + const user3 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 311, 100)); expect(connectCalled).toBe(false); @@ -95,31 +106,40 @@ describe("GameRoom", () => { expect(connectCalled).toBe(true); }); - it("should disconnect user1 and user2", () => { + it("should disconnect user1 and user2", async () => { let connectCalled: boolean = false; let disconnectCallNumber: number = 0; const connect: ConnectCallback = (user: User, group: Group): void => { connectCalled = true; - } + }; const disconnect: DisconnectCallback = (user: User, group: Group): void => { disconnectCallNumber++; - } + }; - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); + const world = await GameRoom.create( + "https://play.workadventu.re/_/global/localhost/test.json", + connect, + disconnect, + 160, + 160, + () => {}, + () => {}, + () => {}, + emote + ); - const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); + const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100)); - const user2 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 259, 100)); + const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 259, 100)); expect(connectCalled).toBe(true); expect(disconnectCallNumber).toBe(0); - world.updatePosition(user2, new Point(100+160+160+1, 100)); + world.updatePosition(user2, new Point(100 + 160 + 160 + 1, 100)); expect(disconnectCallNumber).toBe(2); world.updatePosition(user2, new Point(262, 100)); expect(disconnectCallNumber).toBe(2); }); - -}) +}); diff --git a/back/tests/MapFetcherTest.ts b/back/tests/MapFetcherTest.ts new file mode 100644 index 00000000..1e7ca447 --- /dev/null +++ b/back/tests/MapFetcherTest.ts @@ -0,0 +1,32 @@ +import { arrayIntersect } from "../src/Services/ArrayHelper"; +import { mapFetcher } from "../src/Services/MapFetcher"; + +describe("MapFetcher", () => { + it("should return true on localhost ending URLs", async () => { + expect(await mapFetcher.isLocalUrl("https://localhost")).toBeTrue(); + expect(await mapFetcher.isLocalUrl("https://foo.localhost")).toBeTrue(); + }); + + it("should return true on DNS resolving to a local domain", async () => { + expect(await mapFetcher.isLocalUrl("https://127.0.0.1.nip.io")).toBeTrue(); + }); + + it("should return true on an IP resolving to a local domain", async () => { + expect(await mapFetcher.isLocalUrl("https://127.0.0.1")).toBeTrue(); + expect(await mapFetcher.isLocalUrl("https://192.168.0.1")).toBeTrue(); + }); + + it("should return false on an IP resolving to a global domain", async () => { + expect(await mapFetcher.isLocalUrl("https://51.12.42.42")).toBeFalse(); + }); + + it("should return false on an DNS resolving to a global domain", async () => { + expect(await mapFetcher.isLocalUrl("https://maps.workadventu.re")).toBeFalse(); + }); + + it("should throw error on invalid domain", async () => { + await expectAsync( + mapFetcher.isLocalUrl("https://this.domain.name.doesnotexistfoobgjkgfdjkgldf.com") + ).toBeRejected(); + }); +}); diff --git a/back/tests/RoomIdentifierTest.ts b/back/tests/RoomIdentifierTest.ts deleted file mode 100644 index c3817ff7..00000000 --- a/back/tests/RoomIdentifierTest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier"; - -describe("RoomIdentifier", () => { - it("should flag public id as anonymous", () => { - expect(isRoomAnonymous('_/global/test')).toBe(true); - }); - it("should flag public id as not anonymous", () => { - expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false); - }); - it("should extract roomSlug from public ID", () => { - expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json'); - }); - it("should extract correct from private ID", () => { - const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor'); - expect(organizationSlug).toBe('afup'); - expect(worldSlug).toBe('afup2020'); - expect(roomSlug).toBe('1floor'); - }); -}) \ No newline at end of file diff --git a/back/tsconfig.json b/back/tsconfig.json index 6972715f..e149d304 100644 --- a/back/tsconfig.json +++ b/back/tsconfig.json @@ -3,7 +3,7 @@ "experimentalDecorators": true, /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "downlevelIteration": true, "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ diff --git a/back/yarn.lock b/back/yarn.lock index 242728db..98d675ee 100644 --- a/back/yarn.lock +++ b/back/yarn.lock @@ -122,6 +122,13 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/redis@^2.8.31": + version "2.8.31" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.31.tgz#c11c1b269fec132ac2ec9eb891edf72fc549149e" + integrity sha512-daWrrTDYaa5iSDFbgzZ9gOOzyp2AJmYK59OlG/2KGBgYWF3lfs8GDKm1c//tik5Uc93hDD36O+qLPvzDolChbA== + dependencies: + "@types/node" "*" + "@types/strip-bom@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" @@ -187,6 +194,13 @@ semver "^7.3.2" tsutils "^3.17.1" +"@workadventure/tiled-map-type-guard@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@workadventure/tiled-map-type-guard/-/tiled-map-type-guard-1.0.0.tgz#02524602ee8b2688429a1f56df1d04da3fc171ba" + integrity sha512-Mc0SE128otQnYlScQWVaQVyu1+CkailU/FTBh09UTrVnBAhyMO+jIn9vT9+Dv244xq+uzgQDpXmiVdjgrYFQ+A== + dependencies: + generic-type-guard "^3.4.1" + abbrev@1: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" @@ -797,6 +811,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +denque@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de" + integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ== + detect-libc@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" @@ -1181,6 +1200,11 @@ generic-type-guard@^3.2.0: resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.3.3.tgz#954b846fecff91047cadb0dcc28930811fcb9dc1" integrity sha512-SXraZvNW/uTfHVgB48iEwWaD1XFJ1nvZ8QP6qy9pSgaScEyQqFHYN5E6d6rCsJgrvlWKygPrNum7QeJHegzNuQ== +generic-type-guard@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.4.1.tgz#0896dc018de915c890562a34763858076e4676da" + integrity sha512-sXce0Lz3Wfy2rR1W8O8kUemgEriTeG1x8shqSJeWGb0FwJu2qBEkB1M2qXbdSLmpgDnHcIXo0Dj/1VLNJkK/QA== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -1417,6 +1441,11 @@ invert-kv@^1.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= +ipaddr.js@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" + integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -2424,6 +2453,33 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" +redis-commands@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" + integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + +redis@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.2.tgz#766851117e80653d23e0ed536254677ab647638c" + integrity sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw== + dependencies: + denque "^1.5.0" + redis-commands "^1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" diff --git a/deeployer.libsonnet b/deeployer.libsonnet index 8d9c2bfd..494c72b8 100644 --- a/deeployer.libsonnet +++ b/deeployer.libsonnet @@ -22,6 +22,7 @@ "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, + "REDIS_HOST": "redis", } + (if adminUrl != null then { "ADMIN_API_URL": adminUrl, } else {}) @@ -40,6 +41,7 @@ "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, + "REDIS_HOST": "redis", } + (if adminUrl != null then { "ADMIN_API_URL": adminUrl, } else {}) @@ -97,6 +99,9 @@ }, "ports": [80] }, + "redis": { + "image": "redis:6", + } }, "config": { k8sextension(k8sConf):: diff --git a/docker-compose.single-domain.yaml b/docker-compose.single-domain.yaml index 345ccf8d..b2e9b7c8 100644 --- a/docker-compose.single-domain.yaml +++ b/docker-compose.single-domain.yaml @@ -120,6 +120,8 @@ services: JITSI_URL: $JITSI_URL JITSI_ISS: $JITSI_ISS MAX_PER_GROUP: "$MAX_PER_GROUP" + REDIS_HOST: redis + NODE_ENV: development volumes: - ./back:/usr/src/app labels: @@ -168,6 +170,9 @@ services: - ./front:/usr/src/front - ./pusher:/usr/src/pusher + redis: + image: redis:6 + # coturn: # image: coturn/coturn:4.5.2 # command: diff --git a/docker-compose.yaml b/docker-compose.yaml index 1c1bcb8f..d0254d21 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -115,6 +115,8 @@ services: JITSI_ISS: $JITSI_ISS TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret MAX_PER_GROUP: "MAX_PER_GROUP" + REDIS_HOST: redis + NODE_ENV: development volumes: - ./back:/usr/src/app labels: @@ -157,6 +159,20 @@ services: - ./front:/usr/src/front - ./pusher:/usr/src/pusher + redis: + image: redis:6 + + redisinsight: + image: redislabs/redisinsight:latest + labels: + - "traefik.http.routers.redisinsight.rule=Host(`redis.workadventure.localhost`)" + - "traefik.http.routers.redisinsight.entryPoints=web" + - "traefik.http.services.redisinsight.loadbalancer.server.port=8001" + - "traefik.http.routers.redisinsight-ssl.rule=Host(`redis.workadventure.localhost`)" + - "traefik.http.routers.redisinsight-ssl.entryPoints=websecure" + - "traefik.http.routers.redisinsight-ssl.tls=true" + - "traefik.http.routers.redisinsight-ssl.service=redisinsight" + # coturn: # image: coturn/coturn:4.5.2 # command: diff --git a/docs/maps/api-player.md b/docs/maps/api-player.md index f483731e..ed73c32d 100644 --- a/docs/maps/api-player.md +++ b/docs/maps/api-player.md @@ -1,6 +1,63 @@ {.section-title.accent.text-primary} # API Player functions Reference +### Get the player name + +``` +WA.player.name: string; +``` + +The player name is available from the `WA.player.name` property. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.name` + +```typescript +WA.onInit().then(() => { + console.log('Player name: ', WA.player.name); +}) +``` + +### Get the player ID + +``` +WA.player.id: string|undefined; +``` + +The player ID is available from the `WA.player.id` property. +This is a unique identifier for a given player. Anonymous player might not have an id. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.id` + +```typescript +WA.onInit().then(() => { + console.log('Player ID: ', WA.player.id); +}) +``` + +### Get the tags of the player + +``` +WA.player.tags: string[]; +``` + +The player tags are available from the `WA.player.tags` property. +They represent a set of rights the player acquires after login in. + +{.alert.alert-warn} +Tags attributed to a user depend on the authentication system you are using. For the hosted version +of WorkAdventure, you can define tags related to the user in the [administration panel](https://workadventu.re/admin-guide/manage-members). + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.tags` + +```typescript +WA.onInit().then(() => { + console.log('Tags: ', WA.player.tags); +}) +``` + ### Listen to player movement ``` WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void; @@ -18,4 +75,4 @@ The event has the following attributes : Example : ```javascript WA.player.onPlayerMove(console.log); -``` \ No newline at end of file +``` diff --git a/docs/maps/api-reference.md b/docs/maps/api-reference.md index 8c8205d8..d044668f 100644 --- a/docs/maps/api-reference.md +++ b/docs/maps/api-reference.md @@ -1,9 +1,11 @@ {.section-title.accent.text-primary} # API Reference +- [Start / Init functions](api-start.md) - [Navigation functions](api-nav.md) - [Chat functions](api-chat.md) - [Room functions](api-room.md) +- [State related functions](api-state.md) - [Player functions](api-player.md) - [UI functions](api-ui.md) - [Sound functions](api-sound.md) diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index 9d08ce1b..ca708b29 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -54,6 +54,7 @@ WA.room.showLayer(layerName : string): void WA.room.hideLayer(layerName : string) : void ``` These 2 methods can be used to show and hide a layer. +if `layerName` is the name of a group layer, show/hide all the layer in that group layer. Example : ```javascript @@ -70,49 +71,66 @@ WA.room.setProperty(layerName : string, propertyName : string, propertyValue : s Set the value of the `propertyName` property of the layer `layerName` at `propertyValue`. If the property doesn't exist, create the property `propertyName` and set the value of the property at `propertyValue`. +Note : +To unset a property from a layer, use `setProperty` with `propertyValue` set to `undefined`. + Example : ```javascript WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); ``` -### Getting information on the current room -``` -WA.room.getCurrentRoom(): Promise -``` -Return a promise that resolves to a `Room` object with the following attributes : -* **id (string) :** ID of the current room -* **map (ITiledMap) :** contains the JSON map file with the properties that were set by the script if `setProperty` was called. -* **mapUrl (string) :** Url of the JSON map file -* **startLayer (string | null) :** Name of the layer where the current user started, only if different from `start` layer +### Get the room id -Example : -```javascript -WA.room.getCurrentRoom((room) => { - if (room.id === '42') { - console.log(room.map); - window.open(room.mapUrl, '_blank'); - } +``` +WA.room.id: string; +``` + +The ID of the current room is available from the `WA.room.id` property. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.room.id` + +```typescript +WA.onInit().then(() => { + console.log('Room id: ', WA.room.id); + // Will output something like: 'https://play.workadventu.re/@/myorg/myworld/myroom', or 'https://play.workadventu.re/_/global/mymap.org/map.json" }) ``` -### Getting information on the current user -``` -WA.player.getCurrentUser(): Promise -``` -Return a promise that resolves to a `User` object with the following attributes : -* **id (string) :** ID of the current user -* **nickName (string) :** name displayed above the current user -* **tags (string[]) :** list of all the tags of the current user +### Get the map URL -Example : -```javascript -WA.room.getCurrentUser().then((user) => { - if (user.nickName === 'ABC') { - console.log(user.tags); - } +``` +WA.room.mapURL: string; +``` + +The URL of the map is available from the `WA.room.mapURL` property. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.room.mapURL` + +```typescript +WA.onInit().then(() => { + console.log('Map URL: ', WA.room.mapURL); + // Will output something like: 'https://mymap.org/map.json" }) ``` + + +### Getting map data +``` +WA.room.getTiledMap(): Promise +``` + +Returns a promise that resolves to the JSON map file. + +```javascript +const map = await WA.room.getTiledMap(); +console.log("Map generated with Tiled version ", map.tiledversion); +``` + +Check the [Tiled documentation to learn more about the format of the JSON map](https://doc.mapeditor.org/en/stable/reference/json-map-format/). + ### Changing tiles ``` WA.room.setTiles(tiles: TileDescriptor[]): void @@ -134,6 +152,7 @@ If `tile` is a string, it's not the id of the tile but the value of the property **Important !** : If you use `tile` as a number, be sure to add the `firstgid` of the tileset of the tile that you want to the id of the tile in Tiled Editor. +Note: If you want to unset a tile, use `setTiles` with `tile` set to `null`. Example : ```javascript diff --git a/docs/maps/api-start.md b/docs/maps/api-start.md new file mode 100644 index 00000000..0fcfc62d --- /dev/null +++ b/docs/maps/api-start.md @@ -0,0 +1,30 @@ +{.section-title.accent.text-primary} +# API start functions Reference + +### Waiting for WorkAdventure API to be available + +When your script / iFrame loads WorkAdventure, it takes a few milliseconds for your script / iFrame to exchange +data with WorkAdventure. You should wait for the WorkAdventure API to be fully ready using the `WA.onInit()` method. + +``` +WA.onInit(): Promise +``` + +Some properties (like the current user name, or the room ID) are not available until `WA.onInit` has completed. + +Example: + +```typescript +WA.onInit().then(() => { + console.log('Current player name: ', WA.player.name); +}); +``` + +Or the same code, using await/async: + +```typescript +(async () => { + await WA.onInit(); + console.log('Current player name: ', WA.player.name); +})(); +``` diff --git a/docs/maps/api-state.md b/docs/maps/api-state.md new file mode 100644 index 00000000..87a8b3aa --- /dev/null +++ b/docs/maps/api-state.md @@ -0,0 +1,136 @@ +{.section-title.accent.text-primary} +# API state related functions Reference + +## Saving / loading state + +The `WA.state` functions allow you to easily share a common state between all the players in a given room. +Moreover, `WA.state` functions can be used to persist this state across reloads. + +``` +WA.state.saveVariable(key : string, data : unknown): void +WA.state.loadVariable(key : string) : unknown +WA.state.onVariableChange(key : string).subscribe((data: unknown) => {}) : Subscription +WA.state.[any property]: unknown +``` + +These methods and properties can be used to save, load and track changes in variables related to the current room. + +Variables stored in `WA.state` can be any value that is serializable in JSON. + +Please refrain from storing large amounts of data in a room. Those functions are typically useful for saving or restoring +configuration / metadata. + +{.alert.alert-warning} +We are in the process of fine-tuning variables, and we will eventually put limits on the maximum size a variable can hold. We will also put limits on the number of calls you can make to saving variables, so don't change the value of a variable every 10ms, this will fail in the future. + + +Example : +```javascript +WA.state.saveVariable('config', { + 'bottomExitUrl': '/@/org/world/castle', + 'topExitUrl': '/@/org/world/tower', + 'enableBirdSound': true +}).catch(e => console.error('Something went wrong while saving variable', e)); +//... +let config = WA.state.loadVariable('config'); +``` + +You can use the shortcut properties to load and save variables. The code above is similar to: + +```javascript +WA.state.config = { + 'bottomExitUrl': '/@/org/world/castle', + 'topExitUrl': '/@/org/world/tower', + 'enableBirdSound': true +}; + +//... +let config = WA.state.config; +``` + +Note: `saveVariable` returns a promise that will fail in case the variable cannot be saved. This +can happen if your user does not have the required rights (more on that in the next chapter). +In contrast, if you use the WA.state properties, you cannot access the promise and therefore cannot +know for sure if your variable was properly saved. + +If you are using Typescript, please note that the type of variables is `unknown`. This is +for security purpose, as we don't know the type of the variable. In order to use the returned value, +you will need to cast it to the correct type (or better, use a [Type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to actually check at runtime +that you get the expected type). + +{.alert.alert-warning} +For security reasons, the list of variables you are allowed to access and modify is **restricted** (otherwise, anyone on your map could set any data). +Variables storage is subject to an authorization process. Read below to learn more. + +### Declaring allowed keys + +In order to declare allowed keys related to a room, you need to add **objects** in an "object layer" of the map. + +Each object will represent a variable. + +
+
+ +
+
+ +The name of the variable is the name of the object. +The object **type** MUST be **variable**. + +You can set a default value for the object in the `default` property. + +### Persisting variables state + +Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay +in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the +server restarts). + +{.alert.alert-info} +Do not use `persist` for highly dynamic values that have a short life spawn. + +### Managing access rights to variables + +With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string +representing a "tag". Anyone having this "tag" can read/write in the variable. + +{.alert.alert-warning} +`readableBy` and `writableBy` are specific to the "online" version of WorkAdventure because the notion of tags +is not available unless you have an "admin" server (that is not part of the self-hosted version of WorkAdventure). + +Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable. +Trying to set a variable to a value that is not compatible with the schema will fail. + + +## Tracking variables changes + +The properties of the `WA.state` object are shared in real-time between users of a same room. You can listen to modifications +of any property of `WA.state` by using the `WA.state.onVariableChange()` method. + +``` +WA.state.onVariableChange(name: string): Observable +``` + +Usage: + +```javascript +WA.state.onVariableChange('config').subscribe((value) => { + console.log('Variable "config" changed. New value: ', value); +}); +``` + +The `WA.state.onVariableChange` method returns an [RxJS `Observable` object](https://rxjs.dev/guide/observable). This is +an object on which you can add subscriptions using the `subscribe` method. + +### Stopping tracking variables + +If you want to stop tracking a variable change, the `subscribe` method returns a subscription object with an `unsubscribe` method. + +**Example with unsubscription:** + +```javascript +const subscription = WA.state.onVariableChange('config').subscribe((value) => { + console.log('Variable "config" changed. New value: ', value); +}); +// Later: +subscription.unsubscribe(); +``` diff --git a/docs/maps/scripting.md b/docs/maps/scripting.md index 5f645b81..5be57ee1 100644 --- a/docs/maps/scripting.md +++ b/docs/maps/scripting.md @@ -55,10 +55,10 @@ Start by testing this with a simple message sent to the chat. **script.js** ```javascript -WA.sendChatMessage('Hello world', 'Mr Robot'); +WA.chat.sendChatMessage('Hello world', 'Mr Robot'); ``` -The `WA` objects contains a number of useful methods enabling you to interact with the WorkAdventure game. For instance, `WA.sendChatMessage` opens the chat and adds a message in it. +The `WA` objects contains a number of useful methods enabling you to interact with the WorkAdventure game. For instance, `WA.chat.sendChatMessage` opens the chat and adds a message in it. In your browser console, when you open the map, the chat message should be displayed right away. diff --git a/front/dist/index.tmpl.html b/front/dist/index.tmpl.html index aa63229f..30ea8353 100644 --- a/front/dist/index.tmpl.html +++ b/front/dist/index.tmpl.html @@ -37,8 +37,7 @@
-
-
+
diff --git a/front/dist/resources/html/gameMenu.html b/front/dist/resources/html/gameMenu.html index 26be2a1c..bb0a6e1e 100644 --- a/front/dist/resources/html/gameMenu.html +++ b/front/dist/resources/html/gameMenu.html @@ -57,6 +57,9 @@
+
+ +
diff --git a/front/dist/resources/logos/tcm_full.png b/front/dist/resources/logos/tcm_full.png deleted file mode 100644 index 3ea27990a3ca3cfd62f1866b766d92d407709a46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 300 zcmV+{0n`48P)sgDDXIM|1Dp| zmbyeoV8V);i2YXtGwvUAa6OXpc(I5BlZpe=e#L|`)0nGQe!HC7{xdq`c;SNf%l|DR zO%vl3%D$^I>gH-3kbP*;zq@gog-GzZChK)p7q}jY{C8p4l`AQ+)1LE}UWDR>;!c)b zJI`-5Qf1)ITE-zS+`tiDcJM^))FyL1*W0XTW;~kO!{X5ZLVQKHxBlLdx_JxGp$wj` MelF{r5}Fto0L7nB_y7O^ diff --git a/front/dist/resources/service-worker.js b/front/dist/resources/service-worker.js new file mode 100644 index 00000000..e496f7fc --- /dev/null +++ b/front/dist/resources/service-worker.js @@ -0,0 +1,53 @@ +let CACHE_NAME = 'workavdenture-cache-v1'; +let urlsToCache = [ + '/' +]; + +self.addEventListener('install', function(event) { + // Perform install steps + event.waitUntil( + caches.open(CACHE_NAME) + .then(function(cache) { + console.log('Opened cache'); + return cache.addAll(urlsToCache); + }) + ); +}); + +self.addEventListener('fetch', function(event) { + event.respondWith( + caches.match(event.request) + .then(function(response) { + // Cache hit - return response + if (response) { + return response; + } + + return fetch(event.request).then( + function(response) { + // Check if we received a valid response + if(!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + // IMPORTANT: Clone the response. A response is a stream + // and because we want the browser to consume the response + // as well as the cache consuming the response, we need + // to clone it so we have two streams. + var responseToCache = response.clone(); + + caches.open(CACHE_NAME) + .then(function(cache) { + cache.put(event.request, responseToCache); + }); + + return response; + } + ); + }) + ); +}); + +self.addEventListener('activate', function(event) { + //TODO activate service worker +}); \ No newline at end of file diff --git a/front/dist/static/images/favicons/icon-512x512.png b/front/dist/static/images/favicons/icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..86cb7477eb23e028be7e129b025e0bfc49b9fb71 GIT binary patch literal 13061 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 TvtN}2_6tB3)~5 extends MessageEvent { data: T; @@ -49,7 +55,6 @@ export type IframeEventMap = { showLayer: LayerEvent; hideLayer: LayerEvent; setProperty: SetPropertyEvent; - getDataLayer: undefined; loadSound: LoadSoundEvent; playSound: PlaySoundEvent; stopSound: null; @@ -75,8 +80,8 @@ export interface IframeResponseEventMap { leaveEvent: EnterLeaveEvent; buttonClickedEvent: ButtonClickedEvent; hasPlayerMoved: HasPlayerMovedEvent; - dataLayer: DataLayerEvent; menuItemClicked: MenuItemClickedEvent; + setVariable: SetVariableEvent; messageTriggered: MessageReferenceEvent; } export interface IframeResponseEvent { @@ -90,22 +95,40 @@ export const isIframeResponseEventWrapper = (event: { }): event is IframeResponseEvent => typeof event.type === "string"; /** - * List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame + * List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame. + * Types are defined using Type guards that will actually bused to enforce and check types. */ -export type IframeQueryMap = { +export const iframeQueryMapTypeGuards = { getState: { - query: undefined; - answer: GameStateEvent; - }; + query: tg.isUndefined, + answer: isGameStateEvent, + }, + getMapData: { + query: tg.isUndefined, + answer: isMapDataEvent, + }, + setVariable: { + query: isSetVariableEvent, + answer: tg.isUndefined, + }, + triggerMessage: { + query: isTriggerMessageEvent, + answer: tg.isUndefined, + }, + removeTriggerMessage: { + query: isMessageReferenceEvent, + answer: tg.isUndefined, + }, +}; - [triggerMessage]: { - query: TriggerMessageEvent; - answer: void; - }; +type GuardedType = T extends (x: unknown) => x is infer T ? T : never; +type IframeQueryMapTypeGuardsType = typeof iframeQueryMapTypeGuards; +type UnknownToVoid = undefined extends T ? void : T; - [removeTriggerMessage]: { - query: MessageReferenceEvent; - answer: void; +export type IframeQueryMap = { + [key in keyof IframeQueryMapTypeGuardsType]: { + query: GuardedType; + answer: UnknownToVoid>; }; }; @@ -119,8 +142,21 @@ export interface IframeQueryWrapper { query: IframeQuery; } +export const isIframeQueryKey = (type: string): type is keyof IframeQueryMap => { + return type in iframeQueryMapTypeGuards; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isIframeQuery = (event: any): event is IframeQuery => typeof event.type === "string"; +export const isIframeQuery = (event: any): event is IframeQuery => { + const type = event.type; + if (typeof type !== "string") { + return false; + } + if (!isIframeQueryKey(type)) { + return false; + } + return iframeQueryMapTypeGuards[type].query(event.data); +}; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper => diff --git a/front/src/Api/Events/DataLayerEvent.ts b/front/src/Api/Events/MapDataEvent.ts similarity index 70% rename from front/src/Api/Events/DataLayerEvent.ts rename to front/src/Api/Events/MapDataEvent.ts index 3062c1bc..f63164ed 100644 --- a/front/src/Api/Events/DataLayerEvent.ts +++ b/front/src/Api/Events/MapDataEvent.ts @@ -1,6 +1,6 @@ import * as tg from "generic-type-guard"; -export const isDataLayerEvent = new tg.IsInterface() +export const isMapDataEvent = new tg.IsInterface() .withProperties({ data: tg.isObject, }) @@ -9,4 +9,4 @@ export const isDataLayerEvent = new tg.IsInterface() /** * A message sent from the game to the iFrame when the data of the layers change after the iFrame send a message to the game that it want to listen to the data of the layers */ -export type DataLayerEvent = tg.GuardedType; +export type MapDataEvent = tg.GuardedType; diff --git a/front/src/Api/Events/SetTilesEvent.ts b/front/src/Api/Events/SetTilesEvent.ts index c7f8f16d..371f0884 100644 --- a/front/src/Api/Events/SetTilesEvent.ts +++ b/front/src/Api/Events/SetTilesEvent.ts @@ -5,7 +5,7 @@ export const isSetTilesEvent = tg.isArray( .withProperties({ x: tg.isNumber, y: tg.isNumber, - tile: tg.isUnion(tg.isNumber, tg.isString), + tile: tg.isUnion(tg.isUnion(tg.isNumber, tg.isString), tg.isNull), layer: tg.isString, }) .get() diff --git a/front/src/Api/Events/SetVariableEvent.ts b/front/src/Api/Events/SetVariableEvent.ts new file mode 100644 index 00000000..3b4e9c85 --- /dev/null +++ b/front/src/Api/Events/SetVariableEvent.ts @@ -0,0 +1,20 @@ +import * as tg from "generic-type-guard"; +import { isMenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent"; + +export const isSetVariableEvent = new tg.IsInterface() + .withProperties({ + key: tg.isString, + value: tg.isUnknown, + }) + .get(); +/** + * A message sent from the iFrame to the game to change the value of the property of the layer + */ +export type SetVariableEvent = tg.GuardedType; + +export const isSetVariableIframeEvent = new tg.IsInterface() + .withProperties({ + type: tg.isSingletonString("setVariable"), + data: isSetVariableEvent, + }) + .get(); diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index feeee4cf..d6eec7de 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -14,7 +14,6 @@ import { IframeErrorAnswerEvent, IframeEvent, IframeEventMap, - IframeQuery, IframeQueryMap, IframeResponseEvent, IframeResponseEventMap, @@ -29,22 +28,27 @@ import { isLoadSoundEvent, LoadSoundEvent } from "./Events/LoadSoundEvent"; import { isSetPropertyEvent, SetPropertyEvent } from "./Events/setPropertyEvent"; import { isLayerEvent, LayerEvent } from "./Events/LayerEvent"; import { isMenuItemRegisterEvent } from "./Events/ui/MenuItemRegisterEvent"; -import type { DataLayerEvent } from "./Events/DataLayerEvent"; +import type { MapDataEvent } from "./Events/MapDataEvent"; import type { GameStateEvent } from "./Events/GameStateEvent"; import type { HasPlayerMovedEvent } from "./Events/HasPlayerMovedEvent"; import { isLoadPageEvent } from "./Events/LoadPageEvent"; import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent"; import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent"; +import type { SetVariableEvent } from "./Events/SetVariableEvent"; type AnswererCallback = ( - query: IframeQueryMap[T]["query"] -) => IframeQueryMap[T]["answer"] | Promise; + query: IframeQueryMap[T]["query"], + source: MessageEventSource | null +) => IframeQueryMap[T]["answer"] | PromiseLike; /** * Listens to messages from iframes and turn those messages into easy to use observables. * Also allows to send messages to those iframes. */ class IframeListener { + private readonly _readyStream: Subject = new Subject(); + public readonly readyStream = this._readyStream.asObservable(); + private readonly _chatStream: Subject = new Subject(); public readonly chatStream = this._chatStream.asObservable(); @@ -90,9 +94,6 @@ class IframeListener { private readonly _setPropertyStream: Subject = new Subject(); public readonly setPropertyStream = this._setPropertyStream.asObservable(); - private readonly _dataLayerChangeStream: Subject = new Subject(); - public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable(); - private readonly _registerMenuCommandStream: Subject = new Subject(); public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable(); @@ -116,16 +117,15 @@ class IframeListener { private readonly scripts = new Map(); private sendPlayerMove: boolean = false; + // Note: we are forced to type this in unknown and later cast with "as" because of https://github.com/microsoft/TypeScript/issues/31904 private answerers: { - [key in keyof IframeQueryMap]?: AnswererCallback; + [str in keyof IframeQueryMap]?: unknown; } = {}; init() { window.addEventListener( "message", - ( - message: TypedMessageEvent> - ) => { + (message: MessageEvent) => { // Do we trust the sender of this message? // Let's only accept messages from the iframe that are allowed. // Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain). @@ -157,9 +157,9 @@ class IframeListener { if (isIframeQueryWrapper(payload)) { const queryId = payload.id; - const query = payload.query as IframeQuery; + const query = payload.query; - const answerer = this.answerers[query.type] as AnswererCallback | undefined; + const answerer = this.answerers[query.type] as AnswererCallback | undefined; if (answerer === undefined) { const errorMsg = 'The iFrame sent a message of type "' + @@ -177,35 +177,43 @@ class IframeListener { return; } - Promise.resolve(answerer(query.data)) - .then((value) => { - iframe?.contentWindow?.postMessage( - { - id: queryId, - type: query.type, - data: value, - }, - "*" - ); - }) - .catch((reason) => { - console.error("An error occurred while responding to an iFrame query.", reason); - let reasonMsg: string; - if (reason instanceof Error) { - reasonMsg = reason.message; - } else { - reasonMsg = reason.toString(); - } + const errorHandler = (reason: unknown) => { + console.error("An error occurred while responding to an iFrame query.", reason); + let reasonMsg: string = ""; + if (reason instanceof Error) { + reasonMsg = reason.message; + } else if (typeof reason === "object") { + reasonMsg = reason ? reason.toString() : ""; + } else if (typeof reason === "string") { + reasonMsg = reason; + } - iframe?.contentWindow?.postMessage( - { - id: queryId, - type: query.type, - error: reasonMsg, - } as IframeErrorAnswerEvent, - "*" - ); - }); + iframe?.contentWindow?.postMessage( + { + id: queryId, + type: query.type, + error: reasonMsg, + } as IframeErrorAnswerEvent, + "*" + ); + }; + + try { + Promise.resolve(answerer(query.data, message.source)) + .then((value) => { + iframe?.contentWindow?.postMessage( + { + id: queryId, + type: query.type, + data: value, + }, + "*" + ); + }) + .catch(errorHandler); + } catch (reason) { + errorHandler(reason); + } } else if (isIframeEventWrapper(payload)) { if (payload.type === "showLayer" && isLayerEvent(payload.data)) { this._showLayerStream.next(payload.data); @@ -250,8 +258,6 @@ class IframeListener { this._removeBubbleStream.next(); } else if (payload.type == "onPlayerMove") { this.sendPlayerMove = true; - } else if (payload.type == "getDataLayer") { - this._dataLayerChangeStream.next(); } else if (isMenuItemRegisterIframeEvent(payload)) { const data = payload.data.menutItem; // @ts-ignore @@ -268,13 +274,6 @@ class IframeListener { ); } - sendDataLayerEvent(dataLayerEvent: DataLayerEvent) { - this.postMessage({ - type: "dataLayer", - data: dataLayerEvent, - }); - } - /** * Allows the passed iFrame to send/receive messages via the API. */ @@ -414,6 +413,13 @@ class IframeListener { }); } + setVariable(setVariableEvent: SetVariableEvent) { + this.postMessage({ + type: "setVariable", + data: setVariableEvent, + }); + } + /** * Sends the message... to all allowed iframes. */ @@ -431,17 +437,31 @@ class IframeListener { * @param key The "type" of the query we are answering * @param callback */ - public registerAnswerer>( - key: T, - callback: AnswererCallback, - typeChecker?: Guard - ): void { - this.answerers[key] = callback as never; + public registerAnswerer(key: T, callback: AnswererCallback): void { + this.answerers[key] = callback; } public unregisterAnswerer(key: keyof IframeQueryMap): void { delete this.answerers[key]; } + + dispatchVariableToOtherIframes(key: string, value: unknown, source: MessageEventSource | null) { + // Let's dispatch the message to the other iframes + for (const iframe of this.iframes) { + if (iframe.contentWindow !== source) { + iframe.contentWindow?.postMessage( + { + type: "setVariable", + data: { + key, + value, + }, + }, + "*" + ); + } + } + } } export const iframeListener = new IframeListener(); diff --git a/front/src/Api/iframe/player.ts b/front/src/Api/iframe/player.ts index e130d3f2..078a1926 100644 --- a/front/src/Api/iframe/player.ts +++ b/front/src/Api/iframe/player.ts @@ -6,6 +6,24 @@ import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent"; const moveStream = new Subject(); +let playerName: string | undefined; + +export const setPlayerName = (name: string) => { + playerName = name; +}; + +let tags: string[] | undefined; + +export const setTags = (_tags: string[]) => { + tags = _tags; +}; + +let uuid: string | undefined; + +export const setUuid = (_uuid: string | undefined) => { + uuid = _uuid; +}; + export class WorkadventurePlayerCommands extends IframeApiContribution { callbacks = [ apiCallback({ @@ -24,6 +42,31 @@ export class WorkadventurePlayerCommands extends IframeApiContribution> = new Map>(); const leaveStreams: Map> = new Map>(); -const dataLayerResolver = new Subject(); -const stateResolvers = new Subject(); - -let immutableDataPromise: Promise | undefined = undefined; - -interface Room { - id: string; - mapUrl: string; - map: ITiledMap; - startLayer: string | null; -} - -interface User { - id: string | undefined; - nickName: string | null; - tags: string[]; -} interface TileDescriptor { x: number; y: number; - tile: number | string; + tile: number | string | null; layer: string; } -function getGameState(): Promise { - if (immutableDataPromise === undefined) { - immutableDataPromise = queryWorkadventure({ type: "getState", data: undefined }); - } - return immutableDataPromise; -} +let roomId: string | undefined; -function getDataLayer(): Promise { - return new Promise((resolver, thrower) => { - dataLayerResolver.subscribe(resolver); - sendToWorkadventure({ type: "getDataLayer", data: null }); - }); -} +export const setRoomId = (id: string) => { + roomId = id; +}; + +let mapURL: string | undefined; + +export const setMapURL = (url: string) => { + mapURL = url; +}; export class WorkadventureRoomCommands extends IframeApiContribution { callbacks = [ @@ -68,13 +45,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - dataLayerResolver.next(payloadData); - }, - }), ]; onEnterZone(name: string, callback: () => void): void { @@ -109,22 +79,9 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - return getGameState().then((gameState) => { - return getDataLayer().then((mapJson) => { - return { - id: gameState.roomId, - map: mapJson.data as ITiledMap, - mapUrl: gameState.mapUrl, - startLayer: gameState.startLayerName, - }; - }); - }); - } - getCurrentUser(): Promise { - return getGameState().then((gameState) => { - return { id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags }; - }); + async getTiledMap(): Promise { + const event = await queryWorkadventure({ type: "getMapData", data: undefined }); + return event.data as ITiledMap; } setTiles(tiles: TileDescriptor[]) { sendToWorkadventure({ @@ -132,6 +89,22 @@ export class WorkadventureRoomCommands extends IframeApiContribution(); +const variables = new Map(); +const variableSubscribers = new Map>(); + +export const initVariables = (_variables: Map): void => { + for (const [name, value] of _variables.entries()) { + // In case the user already decided to put values in the variables (before onInit), let's make sure onInit does not override this. + if (!variables.has(name)) { + variables.set(name, value); + } + } +}; + +setVariableResolvers.subscribe((event) => { + const oldValue = variables.get(event.key); + // If we are setting the same value, no need to do anything. + // No need to do this check since it is already performed in SharedVariablesManager + /*if (JSON.stringify(oldValue) === JSON.stringify(event.value)) { + return; + }*/ + + variables.set(event.key, event.value); + const subject = variableSubscribers.get(event.key); + if (subject !== undefined) { + subject.next(event.value); + } +}); + +export class WorkadventureStateCommands extends IframeApiContribution { + callbacks = [ + apiCallback({ + type: "setVariable", + typeChecker: isSetVariableEvent, + callback: (payloadData) => { + setVariableResolvers.next(payloadData); + }, + }), + ]; + + saveVariable(key: string, value: unknown): Promise { + variables.set(key, value); + return queryWorkadventure({ + type: "setVariable", + data: { + key, + value, + }, + }); + } + + loadVariable(key: string): unknown { + return variables.get(key); + } + + onVariableChange(key: string): Observable { + let subject = variableSubscribers.get(key); + if (subject === undefined) { + subject = new Subject(); + variableSubscribers.set(key, subject); + } + return subject.asObservable(); + } +} + +const proxyCommand = new Proxy(new WorkadventureStateCommands(), { + get(target: WorkadventureStateCommands, p: PropertyKey, receiver: unknown): unknown { + if (p in target) { + return Reflect.get(target, p, receiver); + } + return target.loadVariable(p.toString()); + }, + set(target: WorkadventureStateCommands, p: PropertyKey, value: unknown, receiver: unknown): boolean { + // Note: when using "set", there is no way to wait, so we ignore the return of the promise. + // User must use WA.state.saveVariable to have error message. + target.saveVariable(p.toString(), value); + return true; + }, +}) as WorkadventureStateCommands & { [key: string]: unknown }; + +export default proxyCommand; diff --git a/front/src/Components/App.svelte b/front/src/Components/App.svelte index 8ade9398..0f808074 100644 --- a/front/src/Components/App.svelte +++ b/front/src/Components/App.svelte @@ -10,12 +10,14 @@ import {errorStore} from "../Stores/ErrorStore"; import CustomCharacterScene from "./CustomCharacterScene/CustomCharacterScene.svelte"; import LoginScene from "./Login/LoginScene.svelte"; + import Chat from "./Chat/Chat.svelte"; import {loginSceneVisibleStore} from "../Stores/LoginSceneStore"; import EnableCameraScene from "./EnableCamera/EnableCameraScene.svelte"; import VisitCard from "./VisitCard/VisitCard.svelte"; import {requestVisitCardsStore} from "../Stores/GameStore"; import type {Game} from "../Phaser/Game/Game"; + import {chatVisibilityStore} from "../Stores/ChatStore"; import {helpCameraSettingsVisibleStore} from "../Stores/HelpCameraSettingsStore"; import HelpCameraSettingsPopup from "./HelpCameraSettings/HelpCameraSettingsPopup.svelte"; import AudioPlaying from "./UI/AudioPlaying.svelte"; @@ -61,14 +63,6 @@
{/if} - - {#if $gameOverlayVisibilityStore}
@@ -94,4 +88,7 @@
{/if} + {#if $chatVisibilityStore} + + {/if}
diff --git a/front/src/Components/Chat/Chat.svelte b/front/src/Components/Chat/Chat.svelte new file mode 100644 index 00000000..e39d1a59 --- /dev/null +++ b/front/src/Components/Chat/Chat.svelte @@ -0,0 +1,101 @@ + + + + + + + + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatElement.svelte b/front/src/Components/Chat/ChatElement.svelte new file mode 100644 index 00000000..66ed724b --- /dev/null +++ b/front/src/Components/Chat/ChatElement.svelte @@ -0,0 +1,83 @@ + + +
+
+ {#if message.type === ChatMessageTypes.userIncoming} + >> {#each targets as target, index}{#if !isLastIteration(index)}, {/if}{/each} entered ({renderDate(message.date)}) + {:else if message.type === ChatMessageTypes.userOutcoming} + << {#each targets as target, index}{#if !isLastIteration(index)}, {/if}{/each} left ({renderDate(message.date)}) + {:else if message.type === ChatMessageTypes.me} +

Me: ({renderDate(message.date)})

+ {#each texts as text} +

{@html urlifyText(text)}

+ {/each} + {:else} +

: ({renderDate(message.date)})

+ {#each texts as text} +

{@html urlifyText(text)}

+ {/each} + {/if} +
+
+ + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatMessageForm.svelte b/front/src/Components/Chat/ChatMessageForm.svelte new file mode 100644 index 00000000..cd2ea66e --- /dev/null +++ b/front/src/Components/Chat/ChatMessageForm.svelte @@ -0,0 +1,56 @@ + + +
+ + +
+ + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatPlayerName.svelte b/front/src/Components/Chat/ChatPlayerName.svelte new file mode 100644 index 00000000..9b0630c0 --- /dev/null +++ b/front/src/Components/Chat/ChatPlayerName.svelte @@ -0,0 +1,51 @@ + + + + + {player.name} + + {#if isSubMenuOpen} + + {/if} + + + + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatSubMenu.svelte b/front/src/Components/Chat/ChatSubMenu.svelte new file mode 100644 index 00000000..6690699e --- /dev/null +++ b/front/src/Components/Chat/ChatSubMenu.svelte @@ -0,0 +1,33 @@ + + +
    +
  • +
  • +
+ + + \ No newline at end of file diff --git a/front/src/Components/Video/VideoMediaBox.svelte b/front/src/Components/Video/VideoMediaBox.svelte index 1a581914..d46f3ca7 100644 --- a/front/src/Components/Video/VideoMediaBox.svelte +++ b/front/src/Components/Video/VideoMediaBox.svelte @@ -37,9 +37,7 @@ Report this user Report/Block - {#if $streamStore } - {/if} {#if $constraintStore && $constraintStore.audio !== false} diff --git a/front/src/Components/Video/utils.ts b/front/src/Components/Video/utils.ts index ca1f3b41..06bfcfa7 100644 --- a/front/src/Components/Video/utils.ts +++ b/front/src/Components/Video/utils.ts @@ -1,3 +1,6 @@ +import type { UserSimplePeerInterface } from "../../WebRtc/SimplePeer"; +import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../../Enum/EnvironmentVariable"; + export function getColorByString(str: string): string | null { let hash = 0; if (str.length === 0) { @@ -15,7 +18,7 @@ export function getColorByString(str: string): string | null { return color; } -export function srcObject(node: HTMLVideoElement, stream: MediaStream) { +export function srcObject(node: HTMLVideoElement, stream: MediaStream | null) { node.srcObject = stream; return { update(newStream: MediaStream) { @@ -25,3 +28,19 @@ export function srcObject(node: HTMLVideoElement, stream: MediaStream) { }, }; } + +export function getIceServersConfig(user: UserSimplePeerInterface): RTCIceServer[] { + const config: RTCIceServer[] = [ + { + urls: STUN_SERVER.split(","), + }, + ]; + if (TURN_SERVER !== "") { + config.push({ + urls: TURN_SERVER.split(","), + username: user.webRtcUser || TURN_USER, + credential: user.webRtcPassword || TURN_PASSWORD, + }); + } + return config; +} diff --git a/front/src/Components/VisitCard/VisitCard.svelte b/front/src/Components/VisitCard/VisitCard.svelte index 78f10359..e9eca3b1 100644 --- a/front/src/Components/VisitCard/VisitCard.svelte +++ b/front/src/Components/VisitCard/VisitCard.svelte @@ -45,8 +45,9 @@ .visitCard { pointer-events: all; - margin-left: auto; - margin-right: auto; + position: absolute; + left: 50%; + transform: translate(-50%, 0); margin-top: 200px; max-width: 80vw; diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index 8112ba17..0c459629 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -1,92 +1,107 @@ import Axios from "axios"; -import {PUSHER_URL, START_ROOM_URL} from "../Enum/EnvironmentVariable"; -import {RoomConnection} from "./RoomConnection"; -import type {OnConnectInterface, PositionInterface, ViewportInterface} from "./ConnexionModels"; -import {GameConnexionTypes, urlManager} from "../Url/UrlManager"; -import {localUserStore} from "./LocalUserStore"; -import {CharacterTexture, LocalUser} from "./LocalUser"; -import {Room} from "./Room"; - +import { PUSHER_URL, START_ROOM_URL } from "../Enum/EnvironmentVariable"; +import { RoomConnection } from "./RoomConnection"; +import type { OnConnectInterface, PositionInterface, ViewportInterface } from "./ConnexionModels"; +import { GameConnexionTypes, urlManager } from "../Url/UrlManager"; +import { localUserStore } from "./LocalUserStore"; +import { CharacterTexture, LocalUser } from "./LocalUser"; +import { Room } from "./Room"; class ConnectionManager { - private localUser!:LocalUser; + private localUser!: LocalUser; - private connexionType?: GameConnexionTypes - private reconnectingTimeout: NodeJS.Timeout|null = null; - private _unloading:boolean = false; + private connexionType?: GameConnexionTypes; + private reconnectingTimeout: NodeJS.Timeout | null = null; + private _unloading: boolean = false; - get unloading () { + get unloading() { return this._unloading; } constructor() { - window.addEventListener('beforeunload', () => { + window.addEventListener("beforeunload", () => { this._unloading = true; - if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout) - }) + if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout); + }); } /** * Tries to login to the node server and return the starting map url to be loaded */ public async initGameConnexion(): Promise { - const connexionType = urlManager.getGameConnexionType(); this.connexionType = connexionType; - if(connexionType === GameConnexionTypes.register) { - const organizationMemberToken = urlManager.getOrganizationToken(); - const data = await Axios.post(`${PUSHER_URL}/register`, {organizationMemberToken}).then(res => res.data); + if (connexionType === GameConnexionTypes.register) { + const organizationMemberToken = urlManager.getOrganizationToken(); + const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then( + (res) => res.data + ); this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); localUserStore.saveUser(this.localUser); - const organizationSlug = data.organizationSlug; - const worldSlug = data.worldSlug; - const roomSlug = data.roomSlug; + const roomUrl = data.roomUrl; - const room = new Room('/@/'+organizationSlug+'/'+worldSlug+'/'+roomSlug + window.location.search + window.location.hash); + const room = await Room.createRoom( + new URL( + window.location.protocol + + "//" + + window.location.host + + roomUrl + + window.location.search + + window.location.hash + ) + ); urlManager.pushRoomIdToUrl(room); return Promise.resolve(room); - } else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { - + } else if ( + connexionType === GameConnexionTypes.organization || + connexionType === GameConnexionTypes.anonymous || + connexionType === GameConnexionTypes.empty + ) { let localUser = localUserStore.getLocalUser(); if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) { this.localUser = localUser; try { await this.verifyToken(localUser.jwtToken); - } catch(e) { + } catch (e) { // If the token is invalid, let's generate an anonymous one. - console.error('JWT token invalid. Did it expire? Login anonymously instead.'); + console.error("JWT token invalid. Did it expire? Login anonymously instead."); await this.anonymousLogin(); } - }else{ + } else { await this.anonymousLogin(); } localUser = localUserStore.getLocalUser(); - if(!localUser){ + if (!localUser) { throw "Error to store local user data"; } - let roomId: string; + let roomPath: string; if (connexionType === GameConnexionTypes.empty) { - roomId = START_ROOM_URL; + roomPath = window.location.protocol + "//" + window.location.host + START_ROOM_URL; } else { - roomId = window.location.pathname + window.location.search + window.location.hash; + roomPath = + window.location.protocol + + "//" + + window.location.host + + window.location.pathname + + window.location.search + + window.location.hash; } //get detail map for anonymous login and set texture in local storage - const room = new Room(roomId); - const mapDetail = await room.getMapDetail(); - if(mapDetail.textures != undefined && mapDetail.textures.length > 0) { + const room = await Room.createRoom(new URL(roomPath)); + if (room.textures != undefined && room.textures.length > 0) { //check if texture was changed - if(localUser.textures.length === 0){ - localUser.textures = mapDetail.textures; - }else{ - mapDetail.textures.forEach((newTexture) => { + if (localUser.textures.length === 0) { + localUser.textures = room.textures; + } else { + room.textures.forEach((newTexture) => { const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); - if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ + if (localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1) { return; } - localUser?.textures.push(newTexture) + localUser?.textures.push(newTexture); }); } this.localUser = localUser; @@ -95,55 +110,79 @@ class ConnectionManager { return Promise.resolve(room); } - return Promise.reject(new Error('Invalid URL')); + return Promise.reject(new Error("Invalid URL")); } private async verifyToken(token: string): Promise { - await Axios.get(`${PUSHER_URL}/verify`, {params: {token}}); + await Axios.get(`${PUSHER_URL}/verify`, { params: { token } }); } public async anonymousLogin(isBenchmark: boolean = false): Promise { - const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then(res => res.data); + const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then((res) => res.data); this.localUser = new LocalUser(data.userUuid, data.authToken, []); - if (!isBenchmark) { // In benchmark, we don't have a local storage. + if (!isBenchmark) { + // In benchmark, we don't have a local storage. localUserStore.saveUser(this.localUser); } } public initBenchmark(): void { - this.localUser = new LocalUser('', 'test', []); + this.localUser = new LocalUser("", "test", []); } - public connectToRoomSocket(roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise { + public connectToRoomSocket( + roomUrl: string, + name: string, + characterLayers: string[], + position: PositionInterface, + viewport: ViewportInterface, + companion: string | null + ): Promise { return new Promise((resolve, reject) => { - const connection = new RoomConnection(this.localUser.jwtToken, roomId, name, characterLayers, position, viewport, companion); + const connection = new RoomConnection( + this.localUser.jwtToken, + roomUrl, + name, + characterLayers, + position, + viewport, + companion + ); connection.onConnectError((error: object) => { - console.log('An error occurred while connecting to socket server. Retrying'); + console.log("An error occurred while connecting to socket server. Retrying"); reject(error); }); connection.onConnectingError((event: CloseEvent) => { - console.log('An error occurred while connecting to socket server. Retrying'); - reject(new Error('An error occurred while connecting to socket server. Retrying. Code: '+event.code+', Reason: '+event.reason)); + console.log("An error occurred while connecting to socket server. Retrying"); + reject( + new Error( + "An error occurred while connecting to socket server. Retrying. Code: " + + event.code + + ", Reason: " + + event.reason + ) + ); }); connection.onConnect((connect: OnConnectInterface) => { resolve(connect); }); - }).catch((err) => { // Let's retry in 4-6 seconds return new Promise((resolve, reject) => { this.reconnectingTimeout = setTimeout(() => { //todo: allow a way to break recursion? //todo: find a way to avoid recursive function. Otherwise, the call stack will grow indefinitely. - this.connectToRoomSocket(roomId, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection)); - }, 4000 + Math.floor(Math.random() * 2000) ); + this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then( + (connection) => resolve(connection) + ); + }, 4000 + Math.floor(Math.random() * 2000)); }); }); } - get getConnexionType(){ + get getConnexionType() { return this.connexionType; } } diff --git a/front/src/Connexion/ConnexionModels.ts b/front/src/Connexion/ConnexionModels.ts index b5a66296..2f4c414b 100644 --- a/front/src/Connexion/ConnexionModels.ts +++ b/front/src/Connexion/ConnexionModels.ts @@ -1,8 +1,8 @@ -import type {SignalData} from "simple-peer"; -import type {RoomConnection} from "./RoomConnection"; -import type {BodyResourceDescriptionInterface} from "../Phaser/Entity/PlayerTextures"; +import type { SignalData } from "simple-peer"; +import type { RoomConnection } from "./RoomConnection"; +import type { BodyResourceDescriptionInterface } from "../Phaser/Entity/PlayerTextures"; -export enum EventMessage{ +export enum EventMessage { CONNECT = "connect", WEBRTC_SIGNAL = "webrtc-signal", WEBRTC_SCREEN_SHARING_SIGNAL = "webrtc-screen-sharing-signal", @@ -17,7 +17,7 @@ export enum EventMessage{ GROUP_CREATE_UPDATE = "group-create-update", GROUP_DELETE = "group-delete", SET_PLAYER_DETAILS = "set-player-details", // Send the name and character to the server (on connect), receive back the id. - ITEM_EVENT = 'item-event', + ITEM_EVENT = "item-event", CONNECT_ERROR = "connect_error", CONNECTING_ERROR = "connecting_error", @@ -31,12 +31,13 @@ export enum EventMessage{ TELEPORT = "teleport", USER_MESSAGE = "user-message", START_JITSI_ROOM = "start-jitsi-room", + SET_VARIABLE = "set-variable", } export interface PointInterface { x: number; y: number; - direction : string; + direction: string; moving: boolean; } @@ -45,8 +46,9 @@ export interface MessageUserPositionInterface { name: string; characterLayers: BodyResourceDescriptionInterface[]; position: PointInterface; - visitCardUrl: string|null; - companion: string|null; + visitCardUrl: string | null; + companion: string | null; + userUuid: string; } export interface MessageUserMovedInterface { @@ -60,58 +62,60 @@ export interface MessageUserJoined { characterLayers: BodyResourceDescriptionInterface[]; position: PointInterface; visitCardUrl: string | null; - companion: string|null; + companion: string | null; + userUuid: string; } export interface PositionInterface { - x: number, - y: number + x: number; + y: number; } export interface GroupCreatedUpdatedMessageInterface { - position: PositionInterface, - groupId: number, - groupSize: number + position: PositionInterface; + groupId: number; + groupSize: number; } export interface WebRtcDisconnectMessageInterface { - userId: number + userId: number; } export interface WebRtcSignalReceivedMessageInterface { - userId: number, - signal: SignalData, - webRtcUser: string | undefined, - webRtcPassword: string | undefined + userId: number; + signal: SignalData; + webRtcUser: string | undefined; + webRtcPassword: string | undefined; } export interface ViewportInterface { - left: number, - top: number, - right: number, - bottom: number, + left: number; + top: number; + right: number; + bottom: number; } export interface ItemEventMessageInterface { - itemId: number, - event: string, - state: unknown, - parameters: unknown + itemId: number; + event: string; + state: unknown; + parameters: unknown; } export interface RoomJoinedMessageInterface { //users: MessageUserPositionInterface[], //groups: GroupCreatedUpdatedMessageInterface[], - items: { [itemId: number] : unknown } + items: { [itemId: number]: unknown }; + variables: Map; } export interface PlayGlobalMessageInterface { - id: string - type: string - message: string + id: string; + type: string; + message: string; } export interface OnConnectInterface { - connection: RoomConnection, - room: RoomJoinedMessageInterface + connection: RoomConnection; + room: RoomJoinedMessageInterface; } diff --git a/front/src/Connexion/Room.ts b/front/src/Connexion/Room.ts index 7b138198..2053911d 100644 --- a/front/src/Connexion/Room.ts +++ b/front/src/Connexion/Room.ts @@ -6,18 +6,20 @@ export class MapDetail { constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) {} } +export interface RoomRedirect { + redirectUrl: string; +} + export class Room { public readonly id: string; public readonly isPublic: boolean; - private mapUrl: string | undefined; - private textures: CharacterTexture[] | undefined; + private _mapUrl: string | undefined; + private _textures: CharacterTexture[] | undefined; private instance: string | undefined; - private _search: URLSearchParams; + private readonly _search: URLSearchParams; - constructor(id: string) { - const url = new URL(id, "https://example.com"); - - this.id = url.pathname; + private constructor(private roomUrl: URL) { + this.id = roomUrl.pathname; if (this.id.startsWith("/")) { this.id = this.id.substr(1); @@ -30,74 +32,74 @@ export class Room { throw new Error("Invalid room ID"); } - this._search = new URLSearchParams(url.search); + this._search = new URLSearchParams(roomUrl.search); } - public static getIdFromIdentifier( - identifier: string, - baseUrl: string, - currentInstance: string - ): { roomId: string; hash: string | null } { - let roomId = ""; - let hash = null; - if (!identifier.startsWith("/_/") && !identifier.startsWith("/@/")) { - //relative file link - //Relative identifier can be deep enough to rewrite the base domain, so we cannot use the variable 'baseUrl' as the actual base url for the URL objects. - //We instead use 'workadventure' as a dummy base value. - const baseUrlObject = new URL(baseUrl); - const absoluteExitSceneUrl = new URL( - identifier, - "http://workadventure/_/" + currentInstance + "/" + baseUrlObject.hostname + baseUrlObject.pathname - ); - roomId = absoluteExitSceneUrl.pathname; //in case of a relative url, we need to create a public roomId - roomId = roomId.substring(1); //remove the leading slash - hash = absoluteExitSceneUrl.hash; - hash = hash.substring(1); //remove the leading diese - if (!hash.length) { - hash = null; - } - } else { - //absolute room Id - const parts = identifier.split("#"); - roomId = parts[0]; - roomId = roomId.substring(1); //remove the leading slash - if (parts.length > 1) { - hash = parts[1]; + /** + * Creates a "Room" object representing the room. + * This method will follow room redirects if necessary, so the instance returned is a "real" room. + */ + public static async createRoom(roomUrl: URL): Promise { + let redirectCount = 0; + while (redirectCount < 32) { + const room = new Room(roomUrl); + const result = await room.getMapDetail(); + if (result instanceof MapDetail) { + return room; } + redirectCount++; + roomUrl = new URL(result.redirectUrl); } - return { roomId, hash }; + throw new Error("Room resolving seems stuck in a redirect loop after 32 redirect attempts"); } - public async getMapDetail(): Promise { - return new Promise((resolve, reject) => { - if (this.mapUrl !== undefined && this.textures != undefined) { - resolve(new MapDetail(this.mapUrl, this.textures)); - return; - } + public static getRoomPathFromExitUrl(exitUrl: string, currentRoomUrl: string): URL { + const url = new URL(exitUrl, currentRoomUrl); + return url; + } - if (this.isPublic) { - const match = /_\/[^/]+\/(.+)/.exec(this.id); - if (!match) throw new Error('Could not extract url from "' + this.id + '"'); - this.mapUrl = window.location.protocol + "//" + match[1]; - resolve(new MapDetail(this.mapUrl, this.textures)); - return; - } else { - // We have a private ID, we need to query the map URL from the server. - const urlParts = this.parsePrivateUrl(this.id); + /** + * @deprecated USage of exitSceneUrl is deprecated and therefore, this method is deprecated too. + */ + public static getRoomPathFromExitSceneUrl( + exitSceneUrl: string, + currentRoomUrl: string, + currentMapUrl: string + ): URL { + const absoluteExitSceneUrl = new URL(exitSceneUrl, currentMapUrl); + const baseUrl = new URL(currentRoomUrl); - Axios.get(`${PUSHER_URL}/map`, { - params: urlParts, - }) - .then(({ data }) => { - console.log("Map ", this.id, " resolves to URL ", data.mapUrl); - resolve(data); - return; - }) - .catch((reason) => { - reject(reason); - }); - } + const currentRoom = new Room(baseUrl); + let instance: string = "global"; + if (currentRoom.isPublic) { + instance = currentRoom.instance as string; + } + + baseUrl.pathname = "/_/" + instance + "/" + absoluteExitSceneUrl.host + absoluteExitSceneUrl.pathname; + if (absoluteExitSceneUrl.hash) { + baseUrl.hash = absoluteExitSceneUrl.hash; + } + + return baseUrl; + } + + private async getMapDetail(): Promise { + const result = await Axios.get(`${PUSHER_URL}/map`, { + params: { + playUri: this.roomUrl.toString(), + }, }); + + const data = result.data; + if (data.redirectUrl) { + return { + redirectUrl: data.redirectUrl as string, + }; + } + console.log("Map ", this.id, " resolves to URL ", data.mapUrl); + this._mapUrl = data.mapUrl; + this._textures = data.textures; + return new MapDetail(data.mapUrl, data.textures); } /** @@ -123,6 +125,9 @@ export class Room { } } + /** + * @deprecated + */ private parsePrivateUrl(url: string): { organizationSlug: string; worldSlug: string; roomSlug?: string } { const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm; const match = regex.exec(url); @@ -150,4 +155,33 @@ export class Room { public get search(): URLSearchParams { return this._search; } + + /** + * 2 rooms are equal if they share the same path (but not necessarily the same hash) + * @param room + */ + public isEqual(room: Room): boolean { + return room.key === this.key; + } + + /** + * A key representing this room + */ + public get key(): string { + const newUrl = new URL(this.roomUrl.toString()); + newUrl.search = ""; + newUrl.hash = ""; + return newUrl.toString(); + } + + get textures(): CharacterTexture[] | undefined { + return this._textures; + } + + get mapUrl(): string { + if (!this._mapUrl) { + throw new Error("Map URL not fetched yet"); + } + return this._mapUrl; + } } diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 1b080a55..89da6b65 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -32,6 +32,8 @@ import { EmotePromptMessage, SendUserMessage, BanUserMessage, + VariableMessage, + ErrorMessage, } from "../Messages/generated/messages_pb"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; @@ -75,11 +77,11 @@ export class RoomConnection implements RoomConnection { /** * * @param token A JWT token containing the UUID of the user - * @param roomId The ID of the room in the form "_/[instance]/[map_url]" or "@/[org]/[event]/[map]" + * @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]" */ public constructor( token: string | null, - roomId: string, + roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, @@ -92,7 +94,7 @@ export class RoomConnection implements RoomConnection { url += "/"; } url += "room"; - url += "?roomId=" + (roomId ? encodeURIComponent(roomId) : ""); + url += "?roomId=" + encodeURIComponent(roomUrl); url += "&token=" + (token ? encodeURIComponent(token) : ""); url += "&name=" + encodeURIComponent(name); for (const layer of characterLayers) { @@ -164,6 +166,12 @@ export class RoomConnection implements RoomConnection { } else if (subMessage.hasEmoteeventmessage()) { const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage; emoteEventStream.fire(emoteMessage.getActoruserid(), emoteMessage.getEmote()); + } else if (subMessage.hasErrormessage()) { + const errorMessage = subMessage.getErrormessage() as ErrorMessage; + console.error("An error occurred server side: " + errorMessage.getMessage()); + } else if (subMessage.hasVariablemessage()) { + event = EventMessage.SET_VARIABLE; + payload = subMessage.getVariablemessage(); } else { throw new Error("Unexpected batch message type"); } @@ -180,6 +188,22 @@ export class RoomConnection implements RoomConnection { items[item.getItemid()] = JSON.parse(item.getStatejson()); } + const variables = new Map(); + for (const variable of roomJoinedMessage.getVariableList()) { + try { + variables.set(variable.getName(), JSON.parse(variable.getValue())); + } catch (e) { + console.error( + 'Unable to unserialize value received from server for variable "' + + variable.getName() + + '". Value received: "' + + variable.getValue() + + '". Error: ', + e + ); + } + } + this.userId = roomJoinedMessage.getCurrentuserid(); this.tags = roomJoinedMessage.getTagList(); @@ -187,6 +211,7 @@ export class RoomConnection implements RoomConnection { connection: this, room: { items, + variables, } as RoomJoinedMessageInterface, }); } else if (message.hasWorldfullmessage()) { @@ -365,6 +390,7 @@ export class RoomConnection implements RoomConnection { visitCardUrl: message.getVisitcardurl(), position: ProtobufClientUtils.toPointInterface(position), companion: companion ? companion.getName() : null, + userUuid: message.getUseruuid(), }; } @@ -466,7 +492,6 @@ export class RoomConnection implements RoomConnection { this.onMessage(EventMessage.WEBRTC_START, (message: WebRtcStartMessage) => { callback({ userId: message.getUserid(), - name: message.getName(), initiator: message.getInitiator(), webRtcUser: message.getWebrtcusername() ?? undefined, webRtcPassword: message.getWebrtcpassword() ?? undefined, @@ -536,6 +561,17 @@ export class RoomConnection implements RoomConnection { this.socket.send(clientToServerMessage.serializeBinary().buffer); } + emitSetVariableEvent(name: string, value: unknown): void { + const variableMessage = new VariableMessage(); + variableMessage.setName(name); + variableMessage.setValue(JSON.stringify(value)); + + const clientToServerMessage = new ClientToServerMessage(); + clientToServerMessage.setVariablemessage(variableMessage); + + this.socket.send(clientToServerMessage.serializeBinary().buffer); + } + onActionableEvent(callback: (message: ItemEventMessageInterface) => void): void { this.onMessage(EventMessage.ITEM_EVENT, (message: ItemEventMessage) => { callback({ @@ -592,9 +628,9 @@ export class RoomConnection implements RoomConnection { this.socket.send(clientToServerMessage.serializeBinary().buffer); } - public emitReportPlayerMessage(reportedUserId: number, reportComment: string): void { + public emitReportPlayerMessage(reportedUserUuid: string, reportComment: string): void { const reportPlayerMessage = new ReportPlayerMessage(); - reportPlayerMessage.setReporteduserid(reportedUserId); + reportPlayerMessage.setReporteduseruuid(reportedUserUuid); reportPlayerMessage.setReportcomment(reportComment); const clientToServerMessage = new ClientToServerMessage(); @@ -622,6 +658,29 @@ export class RoomConnection implements RoomConnection { }); } + public onSetVariable(callback: (name: string, value: unknown) => void): void { + this.onMessage(EventMessage.SET_VARIABLE, (message: VariableMessage) => { + const name = message.getName(); + const serializedValue = message.getValue(); + let value: unknown = undefined; + if (serializedValue) { + try { + value = JSON.parse(serializedValue); + } catch (e) { + console.error( + 'Unable to unserialize value received from server for variable "' + + name + + '". Value received: "' + + serializedValue + + '". Error: ', + e + ); + } + } + callback(name, value); + }); + } + public hasTag(tag: string): boolean { return this.tags.includes(tag); } diff --git a/front/src/Phaser/Components/OpenChatIcon.ts b/front/src/Phaser/Components/OpenChatIcon.ts index ab07a80c..8c648bc1 100644 --- a/front/src/Phaser/Components/OpenChatIcon.ts +++ b/front/src/Phaser/Components/OpenChatIcon.ts @@ -1,7 +1,7 @@ -import {discussionManager} from "../../WebRtc/DiscussionManager"; -import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; +import { DEPTH_INGAME_TEXT_INDEX } from "../Game/DepthIndexes"; +import { chatVisibilityStore } from "../../Stores/ChatStore"; -export const openChatIconName = 'openChatIcon'; +export const openChatIconName = "openChatIcon"; export class OpenChatIcon extends Phaser.GameObjects.Image { constructor(scene: Phaser.Scene, x: number, y: number) { super(scene, x, y, openChatIconName, 3); @@ -9,9 +9,9 @@ export class OpenChatIcon extends Phaser.GameObjects.Image { this.setScrollFactor(0, 0); this.setOrigin(0, 1); this.setInteractive(); - this.setVisible(false); + //this.setVisible(false); this.setDepth(DEPTH_INGAME_TEXT_INDEX); - this.on("pointerup", () => discussionManager.showDiscussionPart()); + this.on("pointerup", () => chatVisibilityStore.set(true)); } -} \ No newline at end of file +} diff --git a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts index d2a659ec..3c47c9d9 100644 --- a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts +++ b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts @@ -101,7 +101,6 @@ export const createLoadingPromise = ( frameConfig: FrameConfig ) => { return new Promise((res, rej) => { - console.log("count", loadPlugin.listenerCount("loaderror")); if (loadPlugin.textureManager.exists(playerResourceDescriptor.name)) { return res(playerResourceDescriptor); } diff --git a/front/src/Phaser/Game/AddPlayerInterface.ts b/front/src/Phaser/Game/AddPlayerInterface.ts index 1a5176f0..cf7f9092 100644 --- a/front/src/Phaser/Game/AddPlayerInterface.ts +++ b/front/src/Phaser/Game/AddPlayerInterface.ts @@ -1,11 +1,6 @@ -import type {PointInterface} from "../../Connexion/ConnexionModels"; -import type {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures"; +import type { PointInterface } from "../../Connexion/ConnexionModels"; +import type { PlayerInterface } from "./PlayerInterface"; -export interface AddPlayerInterface { - userId: number; - name: string; - characterLayers: BodyResourceDescriptionInterface[]; +export interface AddPlayerInterface extends PlayerInterface { position: PointInterface; - visitCardUrl: string|null; - companion: string|null; } diff --git a/front/src/Phaser/Game/GameManager.ts b/front/src/Phaser/Game/GameManager.ts index 3e39de9a..7f0b2061 100644 --- a/front/src/Phaser/Game/GameManager.ts +++ b/front/src/Phaser/Game/GameManager.ts @@ -28,7 +28,7 @@ export class GameManager { public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise { this.startRoom = await connectionManager.initGameConnexion(); - await this.loadMap(this.startRoom, scenePlugin); + this.loadMap(this.startRoom, scenePlugin); if (!this.playerName) { return LoginSceneName; @@ -68,20 +68,19 @@ export class GameManager { return this.companion; } - public async loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin): Promise { - const roomID = room.id; - const mapDetail = await room.getMapDetail(); + public loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin) { + const roomID = room.key; const gameIndex = scenePlugin.getIndex(roomID); if (gameIndex === -1) { - const game: Phaser.Scene = new GameScene(room, mapDetail.mapUrl); + const game: Phaser.Scene = new GameScene(room, room.mapUrl); scenePlugin.add(roomID, game, false); } } public goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin): void { - console.log("starting " + (this.currentGameSceneName || this.startRoom.id)); - scenePlugin.start(this.currentGameSceneName || this.startRoom.id); + console.log("starting " + (this.currentGameSceneName || this.startRoom.key)); + scenePlugin.start(this.currentGameSceneName || this.startRoom.key); scenePlugin.launch(MenuSceneName); if ( diff --git a/front/src/Phaser/Game/GameMap.ts b/front/src/Phaser/Game/GameMap.ts index a616cf4a..98583cba 100644 --- a/front/src/Phaser/Game/GameMap.ts +++ b/front/src/Phaser/Game/GameMap.ts @@ -1,4 +1,4 @@ -import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty } from "../Map/ITiledMap"; +import type { ITiledMap, ITiledMapLayer, ITiledMapProperty } from "../Map/ITiledMap"; import { flattenGroupLayersMap } from "../Map/LayersFlattener"; import TilemapLayer = Phaser.Tilemaps.TilemapLayer; import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes"; @@ -19,7 +19,7 @@ export class GameMap { private callbacks = new Map>(); private tileNameMap = new Map(); - private tileSetPropertyMap: { [tile_index: number]: Array } = {}; + private tileSetPropertyMap: { [tile_index: number]: Array } = {}; public readonly flatLayers: ITiledMapLayer[]; public readonly phaserLayers: TilemapLayer[] = []; @@ -61,7 +61,7 @@ export class GameMap { } } - public getPropertiesForIndex(index: number): Array { + public getPropertiesForIndex(index: number): Array { if (this.tileSetPropertyMap[index]) { return this.tileSetPropertyMap[index]; } @@ -151,8 +151,11 @@ export class GameMap { return this.map; } - private getTileProperty(index: number): Array { - return this.tileSetPropertyMap[index]; + private getTileProperty(index: number): Array { + if (this.tileSetPropertyMap[index]) { + return this.tileSetPropertyMap[index]; + } + return []; } private trigger( @@ -189,6 +192,10 @@ export class GameMap { return this.phaserLayers.find((layer) => layer.layer.name === layerName); } + public findPhaserLayers(groupName: string): TilemapLayer[] { + return this.phaserLayers.filter((l) => l.layer.name.includes(groupName)); + } + public addTerrain(terrain: Phaser.Tilemaps.Tileset): void { for (const phaserLayer of this.phaserLayers) { phaserLayer.tileset.push(terrain); @@ -198,37 +205,45 @@ export class GameMap { private putTileInFlatLayer(index: number, x: number, y: number, layer: string): void { const fLayer = this.findLayer(layer); if (fLayer == undefined) { - console.error("The layer that you want to change doesn't exist."); + console.error("The layer '" + layer + "' that you want to change doesn't exist."); return; } if (fLayer.type !== "tilelayer") { - console.error("The layer that you want to change is not a tilelayer. Tile can only be put in tilelayer."); + console.error( + "The layer '" + + layer + + "' that you want to change is not a tilelayer. Tile can only be put in tilelayer." + ); return; } if (typeof fLayer.data === "string") { - console.error("Data of the layer that you want to change is only readable."); + console.error("Data of the layer '" + layer + "' that you want to change is only readable."); return; } - fLayer.data[x + y * fLayer.height] = index; + fLayer.data[x + y * fLayer.width] = index; } - public putTile(tile: string | number, x: number, y: number, layer: string): void { + public putTile(tile: string | number | null, x: number, y: number, layer: string): void { const phaserLayer = this.findPhaserLayer(layer); if (phaserLayer) { + if (tile === null) { + phaserLayer.putTileAt(-1, x, y); + return; + } const tileIndex = this.getIndexForTileType(tile); if (tileIndex !== undefined) { this.putTileInFlatLayer(tileIndex, x, y, layer); const phaserTile = phaserLayer.putTileAt(tileIndex, x, y); for (const property of this.getTileProperty(tileIndex)) { - if (property.name === "collides" && property.value === "true") { + if (property.name === "collides" && property.value) { phaserTile.setCollision(true); } } } else { - console.error("The tile that you want to place doesn't exist."); + console.error("The tile '" + tile + "' that you want to place doesn't exist."); } } else { - console.error("The layer that you want to change is not a tilelayer. Tile can only be put in tilelayer."); + console.error("The layer '" + layer + "' does not exist (or is not a tilelaye)."); } } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 7d9d7175..2eb27930 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -47,13 +47,7 @@ import { RemotePlayer } from "../Entity/RemotePlayer"; import type { ActionableItem } from "../Items/ActionableItem"; import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface"; import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene"; -import type { - ITiledMap, - ITiledMapLayer, - ITiledMapLayerProperty, - ITiledMapObject, - ITiledTileSet, -} from "../Map/ITiledMap"; +import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap"; import { MenuScene, MenuSceneName } from "../Menu/MenuScene"; import { PlayerAnimationDirections } from "../Player/Animation"; import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player"; @@ -92,6 +86,9 @@ import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore"; import { videoFocusStore } from "../../Stores/VideoFocusStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; import { isMessageReferenceEvent, isTriggerMessageEvent } from "../../Api/Events/ui/TriggerMessageEvent"; +import { SharedVariablesManager } from "./SharedVariablesManager"; +import { playersStore } from "../../Stores/PlayersStore"; +import { chatVisibilityStore } from "../../Stores/ChatStore"; export interface GameSceneInitInterface { initPosition: PointInterface | null; @@ -169,9 +166,10 @@ export class GameScene extends DirtyScene { private createPromiseResolve!: (value?: void | PromiseLike) => void; private iframeSubscriptionList!: Array; private peerStoreUnsubscribe!: () => void; + private chatVisibilityUnsubscribe!: () => void; private biggestAvailableAreaStoreUnsubscribe!: () => void; MapUrlFile: string; - RoomId: string; + roomUrl: string; instance: string; currentTick!: number; @@ -200,18 +198,19 @@ export class GameScene extends DirtyScene { private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. private emoteManager!: EmoteManager; private preloading: boolean = true; - startPositionCalculator!: StartPositionCalculator; + private startPositionCalculator!: StartPositionCalculator; + private sharedVariablesManager!: SharedVariablesManager; constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ - key: customKey ?? room.id, + key: customKey ?? room.key, }); this.Terrains = []; this.groups = new Map(); this.instance = room.getInstance(); this.MapUrlFile = MapUrlFile; - this.RoomId = room.id; + this.roomUrl = room.key; this.createPromise = new Promise((resolve, reject): void => { this.createPromiseResolve = resolve; @@ -463,11 +462,13 @@ export class GameScene extends DirtyScene { if (layer.type === "tilelayer") { const exitSceneUrl = this.getExitSceneUrl(layer); if (exitSceneUrl !== undefined) { - this.loadNextGame(exitSceneUrl); + this.loadNextGame( + Room.getRoomPathFromExitSceneUrl(exitSceneUrl, window.location.toString(), this.MapUrlFile) + ); } const exitUrl = this.getExitUrl(layer); if (exitUrl !== undefined) { - this.loadNextGame(exitUrl); + this.loadNextGameFromExitUrl(exitUrl); } } if (layer.type === "objectgroup") { @@ -480,7 +481,7 @@ export class GameScene extends DirtyScene { } this.gameMap.exitUrls.forEach((exitUrl) => { - this.loadNextGame(exitUrl); + this.loadNextGameFromExitUrl(exitUrl); }); this.startPositionCalculator = new StartPositionCalculator( @@ -571,6 +572,10 @@ export class GameScene extends DirtyScene { } oldPeerNumber = newPeerNumber; }); + + this.chatVisibilityUnsubscribe = chatVisibilityStore.subscribe((v) => { + this.openChatIcon.setVisible(!v); + }); } /** @@ -581,7 +586,7 @@ export class GameScene extends DirtyScene { connectionManager .connectToRoomSocket( - this.RoomId, + this.roomUrl, this.playerName, this.characterLayers, { @@ -598,6 +603,8 @@ export class GameScene extends DirtyScene { .then((onConnect: OnConnectInterface) => { this.connection = onConnect.connection; + playersStore.connectToRoomConnection(this.connection); + this.connection.onUserJoins((message: MessageUserJoined) => { const userMessage: AddPlayerInterface = { userId: message.userId, @@ -606,6 +613,7 @@ export class GameScene extends DirtyScene { position: message.position, visitCardUrl: message.visitCardUrl, companion: message.companion, + userUuid: message.userUuid, }; this.addPlayer(userMessage); }); @@ -689,12 +697,12 @@ export class GameScene extends DirtyScene { const self = this; this.simplePeer.registerPeerConnectionListener({ onConnect(peer) { - self.openChatIcon.setVisible(true); + //self.openChatIcon.setVisible(true); audioManager.decreaseVolume(); }, onDisconnect(userId: number) { if (self.simplePeer.getNbConnections() === 0) { - self.openChatIcon.setVisible(false); + //self.openChatIcon.setVisible(false); audioManager.restoreVolume(); } }, @@ -707,6 +715,13 @@ export class GameScene extends DirtyScene { this.gameMap.setPosition(event.x, event.y); }); + // 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. @@ -766,10 +781,13 @@ export class GameScene extends DirtyScene { private triggerOnMapLayerPropertyChange() { this.gameMap.onPropertyChange("exitSceneUrl", (newValue, oldValue) => { - if (newValue) this.onMapExit(newValue as string); + if (newValue) + this.onMapExit( + Room.getRoomPathFromExitSceneUrl(newValue as string, window.location.toString(), this.MapUrlFile) + ); }); this.gameMap.onPropertyChange("exitUrl", (newValue, oldValue) => { - if (newValue) this.onMapExit(newValue as string); + if (newValue) this.onMapExit(Room.getRoomPathFromExitUrl(newValue as string, window.location.toString())); }); this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => { if (newValue === undefined) { @@ -994,9 +1012,9 @@ ${escapedMessage} ); this.iframeSubscriptionList.push( iframeListener.loadPageStream.subscribe((url: string) => { - this.loadNextGame(url).then(() => { + this.loadNextGameFromExitUrl(url).then(() => { this.events.once(EVENT_TYPE.POST_UPDATE, () => { - this.onMapExit(url); + this.onMapExit(Room.getRoomPathFromExitUrl(url, window.location.toString())); }); }); }) @@ -1039,20 +1057,24 @@ ${escapedMessage} }) ); - this.iframeSubscriptionList.push( - iframeListener.dataLayerChangeStream.subscribe(() => { - iframeListener.sendDataLayerEvent({ data: this.gameMap.getMap() }); - }) - ); + iframeListener.registerAnswerer("getMapData", () => { + return { + data: this.gameMap.getMap(), + }; + }); - iframeListener.registerAnswerer("getState", () => { + iframeListener.registerAnswerer("getState", async () => { + // The sharedVariablesManager is not instantiated before the connection is established. So we need to wait + // for the connection to send back the answer. + await this.connectionAnswerPromise; return { mapUrl: this.MapUrlFile, startLayerName: this.startPositionCalculator.startLayerName, uuid: localUserStore.getLocalUser()?.uuid, - nickname: localUserStore.getName(), - roomId: this.RoomId, + nickname: this.playerName, + roomId: this.roomUrl, tags: this.connection ? this.connection.getAllTags() : [], + variables: this.sharedVariablesManager.variables, }; }); this.iframeSubscriptionList.push( @@ -1076,17 +1098,12 @@ ${escapedMessage} }, this.userInputManager ); - }), - isTriggerMessageEvent + }) ); - iframeListener.registerAnswerer( - "removeTriggerMessage", - (message) => { - layoutManager.removeActionButton(message.uuid, this.userInputManager); - }, - isMessageReferenceEvent - ); + iframeListener.registerAnswerer("removeTriggerMessage", (message) => { + layoutManager.removeActionButton(message.uuid, this.userInputManager); + }); } private setPropertyLayer( @@ -1099,53 +1116,86 @@ ${escapedMessage} console.warn('Could not find layer "' + layerName + '" when calling setProperty'); return; } + if (propertyName === "exitUrl" && typeof propertyValue === "string") { + this.loadNextGameFromExitUrl(propertyValue); + } if (layer.properties === undefined) { layer.properties = []; } const property = layer.properties.find((property) => property.name === propertyName); if (property === undefined) { + if (propertyValue === undefined) { + return; + } layer.properties.push({ name: propertyName, type: typeof propertyValue, value: propertyValue }); return; } + if (propertyValue === undefined) { + const index = layer.properties.indexOf(property); + layer.properties.splice(index, 1); + } property.value = propertyValue; } private setLayerVisibility(layerName: string, visible: boolean): void { const phaserLayer = this.gameMap.findPhaserLayer(layerName); - if (phaserLayer === undefined) { - console.warn('Could not find layer "' + layerName + '" when calling WA.hideLayer / WA.showLayer'); - return; + if (phaserLayer != undefined) { + phaserLayer.setVisible(visible); + phaserLayer.setCollisionByProperty({ collides: true }, visible); + } else { + const phaserLayers = this.gameMap.findPhaserLayers(layerName + "/"); + if (phaserLayers === []) { + console.warn( + 'Could not find layer with name that contains "' + + layerName + + '" when calling WA.hideLayer / WA.showLayer' + ); + return; + } + for (let i = 0; i < phaserLayers.length; i++) { + phaserLayers[i].setVisible(visible); + phaserLayers[i].setCollisionByProperty({ collides: true }, visible); + } } - phaserLayer.setVisible(visible); - this.dirty = true; + this.markDirty(); } private getMapDirUrl(): string { return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); } - private onMapExit(exitKey: string) { + private async onMapExit(roomUrl: URL) { if (this.mapTransitioning) return; this.mapTransitioning = true; - const { roomId, hash } = Room.getIdFromIdentifier(exitKey, this.MapUrlFile, this.instance); - if (!roomId) throw new Error("Could not find the room from its exit key: " + exitKey); - if (hash) { - urlManager.pushStartLayerNameToUrl(hash); + + let targetRoom: Room; + try { + targetRoom = await Room.createRoom(roomUrl); + } catch (e: unknown) { + console.error('Error while fetching new room "' + roomUrl.toString() + '"', e); + this.mapTransitioning = false; + return; } + + if (roomUrl.hash) { + urlManager.pushStartLayerNameToUrl(roomUrl.hash); + } + const menuScene: MenuScene = this.scene.get(MenuSceneName) as MenuScene; menuScene.reset(); - if (roomId !== this.scene.key) { - if (this.scene.get(roomId) === null) { - console.error("next room not loaded", exitKey); + + if (!targetRoom.isEqual(this.room)) { + if (this.scene.get(targetRoom.key) === null) { + console.error("next room not loaded", targetRoom.key); return; } this.cleanupClosingScene(); this.scene.stop(); + this.scene.start(targetRoom.key); this.scene.remove(this.scene.key); - this.scene.start(roomId); } else { //if the exit points to the current map, we simply teleport the user back to the startLayer - this.startPositionCalculator.initPositionFromLayerName(hash, hash); + this.startPositionCalculator.initPositionFromLayerName(roomUrl.hash, roomUrl.hash); this.CurrentPlayer.x = this.startPositionCalculator.startPosition.x; this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y; setTimeout(() => (this.mapTransitioning = false), 500); @@ -1172,8 +1222,13 @@ ${escapedMessage} this.pinchManager?.destroy(); this.emoteManager.destroy(); this.peerStoreUnsubscribe(); + this.chatVisibilityUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe(); + iframeListener.unregisterAnswerer("getMapData"); iframeListener.unregisterAnswerer("getState"); + iframeListener.unregisterAnswerer("triggerMessage"); + iframeListener.unregisterAnswerer("removeTriggerMessage"); + this.sharedVariablesManager?.close(); mediaManager.hideGameOverlay(); @@ -1213,12 +1268,12 @@ ${escapedMessage} } private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { - const properties: ITiledMapLayerProperty[] | undefined = layer.properties; + const properties: ITiledMapProperty[] | undefined = layer.properties; if (!properties) { return undefined; } const obj = properties.find( - (property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() + (property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase() ); if (obj === undefined) { return undefined; @@ -1227,20 +1282,27 @@ ${escapedMessage} } private getProperties(layer: ITiledMapLayer | ITiledMap, name: string): (string | number | boolean | undefined)[] { - const properties: ITiledMapLayerProperty[] | undefined = layer.properties; + const properties: ITiledMapProperty[] | undefined = layer.properties; if (!properties) { return []; } return properties - .filter((property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase()) + .filter((property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase()) .map((property) => property.value); } + private loadNextGameFromExitUrl(exitUrl: string): Promise { + return this.loadNextGame(Room.getRoomPathFromExitUrl(exitUrl, window.location.toString())); + } + //todo: push that into the gameManager - private loadNextGame(exitSceneIdentifier: string): Promise { - const { roomId, hash } = Room.getIdFromIdentifier(exitSceneIdentifier, this.MapUrlFile, this.instance); - const room = new Room(roomId); - return gameManager.loadMap(room, this.scene).catch(() => {}); + private async loadNextGame(exitRoomPath: URL): Promise { + try { + const room = await Room.createRoom(exitRoomPath); + return gameManager.loadMap(room, this.scene); + } catch (e: unknown) { + console.warn('Error while pre-loading exit room "' + exitRoomPath.toString() + '"', e); + } } //todo: in a dedicated class/function? @@ -1683,7 +1745,7 @@ ${escapedMessage} this.scene.start(ErrorSceneName, { title: "Banned", subTitle: "You were banned from WorkAdventure", - message: "If you want more information, you may contact us at: workadventure@thecodingmachine.com", + message: "If you want more information, you may contact us at: hello@workadventu.re", }); } @@ -1698,14 +1760,14 @@ ${escapedMessage} this.scene.start(ErrorSceneName, { title: "Connection rejected", subTitle: "The world you are trying to join is full. Try again later.", - message: "If you want more information, you may contact us at: workadventure@thecodingmachine.com", + message: "If you want more information, you may contact us at: hello@workadventu.re", }); } else { this.scene.start(ErrorSceneName, { title: "Connection rejected", subTitle: "You cannot join the World. Try again later. \n\r \n\r Error: " + message + ".", message: - "If you want more information, you may contact administrator or contact us at: workadventure@thecodingmachine.com", + "If you want more information, you may contact administrator or contact us at: hello@workadventu.re", }); } } diff --git a/front/src/Phaser/Game/PlayerInterface.ts b/front/src/Phaser/Game/PlayerInterface.ts new file mode 100644 index 00000000..6ab439df --- /dev/null +++ b/front/src/Phaser/Game/PlayerInterface.ts @@ -0,0 +1,11 @@ +import type { BodyResourceDescriptionInterface } from "../Entity/PlayerTextures"; + +export interface PlayerInterface { + userId: number; + name: string; + characterLayers: BodyResourceDescriptionInterface[]; + visitCardUrl: string | null; + companion: string | null; + userUuid: string; + color?: string; +} diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts new file mode 100644 index 00000000..6a06d97e --- /dev/null +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -0,0 +1,167 @@ +import type { RoomConnection } from "../../Connexion/RoomConnection"; +import { iframeListener } from "../../Api/IframeListener"; +import type { Subscription } from "rxjs"; +import type { GameMap } from "./GameMap"; +import type { ITile, ITiledMapObject } from "../Map/ITiledMap"; +import type { Var } from "svelte/types/compiler/interfaces"; +import { init } from "svelte/internal"; + +interface Variable { + defaultValue: unknown; + readableBy?: string; + writableBy?: string; +} + +/** + * Stores variables and provides a bridge between scripts and the pusher server. + */ +export class SharedVariablesManager { + private _variables = new Map(); + private variableObjects: Map; + + constructor( + private roomConnection: RoomConnection, + private gameMap: GameMap, + serverVariables: Map + ) { + // We initialize the list of variable object at room start. The objects cannot be edited later + // (otherwise, this would cause a security issue if the scripting API can edit this list of objects) + this.variableObjects = SharedVariablesManager.findVariablesInMap(gameMap); + + // Let's initialize default values + for (const [name, variableObject] of this.variableObjects.entries()) { + if (variableObject.readableBy && !this.roomConnection.hasTag(variableObject.readableBy)) { + // Do not initialize default value for variables that are not readable + continue; + } + + this._variables.set(name, variableObject.defaultValue); + } + + // Override default values with the variables from the server: + for (const [name, value] of serverVariables) { + this._variables.set(name, value); + } + + roomConnection.onSetVariable((name, value) => { + this._variables.set(name, value); + + // On server change, let's notify the iframes + iframeListener.setVariable({ + key: name, + value: value, + }); + }); + + // When a variable is modified from an iFrame + iframeListener.registerAnswerer("setVariable", (event, source) => { + const key = event.key; + + const object = this.variableObjects.get(key); + + if (object === undefined) { + const errMsg = + 'A script is trying to modify variable "' + + key + + '" but this variable is not defined in the map.' + + 'There should be an object in the map whose name is "' + + key + + '" and whose type is "variable"'; + console.error(errMsg); + throw new Error(errMsg); + } + + if (object.writableBy && !this.roomConnection.hasTag(object.writableBy)) { + const errMsg = + 'A script is trying to modify variable "' + + key + + '" but this variable is only writable for users with tag "' + + object.writableBy + + '".'; + console.error(errMsg); + throw new Error(errMsg); + } + + // Let's stop any propagation of the value we set is the same as the existing value. + if (JSON.stringify(event.value) === JSON.stringify(this._variables.get(key))) { + return; + } + + this._variables.set(key, event.value); + + // Dispatch to the room connection. + this.roomConnection.emitSetVariableEvent(key, event.value); + + // Dispatch to other iframes + iframeListener.dispatchVariableToOtherIframes(key, event.value, source); + }); + } + + private static findVariablesInMap(gameMap: GameMap): Map { + const objects = new Map(); + for (const layer of gameMap.getMap().layers) { + if (layer.type === "objectgroup") { + for (const object of layer.objects) { + if (object.type === "variable") { + if (object.template) { + console.warn( + 'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.' + ); + } + + // We store a copy of the object (to make it immutable) + objects.set(object.name, this.iTiledObjectToVariable(object)); + } + } + } + } + return objects; + } + + private static iTiledObjectToVariable(object: ITiledMapObject): Variable { + const variable: Variable = { + defaultValue: undefined, + }; + + if (object.properties) { + for (const property of object.properties) { + const value = property.value; + switch (property.name) { + case "default": + variable.defaultValue = value; + break; + case "writableBy": + if (typeof value !== "string") { + throw new Error( + 'The writableBy property of variable "' + object.name + '" must be a string' + ); + } + if (value) { + variable.writableBy = value; + } + break; + case "readableBy": + if (typeof value !== "string") { + throw new Error( + 'The readableBy property of variable "' + object.name + '" must be a string' + ); + } + if (value) { + variable.readableBy = value; + } + break; + } + } + } + + return variable; + } + + public close(): void { + iframeListener.unregisterAnswerer("setVariable"); + } + + get variables(): Map { + return this._variables; + } +} diff --git a/front/src/Phaser/Game/StartPositionCalculator.ts b/front/src/Phaser/Game/StartPositionCalculator.ts index 7460c81c..a0184d2b 100644 --- a/front/src/Phaser/Game/StartPositionCalculator.ts +++ b/front/src/Phaser/Game/StartPositionCalculator.ts @@ -1,5 +1,5 @@ import type { PositionInterface } from "../../Connexion/ConnexionModels"; -import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty, ITiledMapTileLayer } from "../Map/ITiledMap"; +import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapTileLayer } from "../Map/ITiledMap"; import type { GameMap } from "./GameMap"; const defaultStartLayerName = "start"; @@ -112,12 +112,12 @@ export class StartPositionCalculator { } private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { - const properties: ITiledMapLayerProperty[] | undefined = layer.properties; + const properties: ITiledMapProperty[] | undefined = layer.properties; if (!properties) { return undefined; } const obj = properties.find( - (property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() + (property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase() ); if (obj === undefined) { return undefined; diff --git a/front/src/Phaser/Login/EntryScene.ts b/front/src/Phaser/Login/EntryScene.ts index b85b3f56..3180d0f6 100644 --- a/front/src/Phaser/Login/EntryScene.ts +++ b/front/src/Phaser/Login/EntryScene.ts @@ -1,8 +1,8 @@ -import {gameManager} from "../Game/GameManager"; -import {Scene} from "phaser"; -import {ErrorScene} from "../Reconnecting/ErrorScene"; -import {WAError} from "../Reconnecting/WAError"; -import {waScaleManager} from "../Services/WaScaleManager"; +import { gameManager } from "../Game/GameManager"; +import { Scene } from "phaser"; +import { ErrorScene } from "../Reconnecting/ErrorScene"; +import { WAError } from "../Reconnecting/WAError"; +import { waScaleManager } from "../Services/WaScaleManager"; export const EntrySceneName = "EntryScene"; @@ -13,26 +13,32 @@ export const EntrySceneName = "EntryScene"; export class EntryScene extends Scene { constructor() { super({ - key: EntrySceneName + key: EntrySceneName, }); } create() { - - gameManager.init(this.scene).then((nextSceneName) => { - // Let's rescale before starting the game - // We can do it at this stage. - waScaleManager.applyNewSize(); - this.scene.start(nextSceneName); - }).catch((err) => { - if (err.response && err.response.status == 404) { - ErrorScene.showError(new WAError( - 'Access link incorrect', - 'Could not find map. Please check your access link.', - 'If you want more information, you may contact administrator or contact us at: workadventure@thecodingmachine.com'), this.scene); - } else { - ErrorScene.showError(err, this.scene); - } - }); + gameManager + .init(this.scene) + .then((nextSceneName) => { + // Let's rescale before starting the game + // We can do it at this stage. + waScaleManager.applyNewSize(); + this.scene.start(nextSceneName); + }) + .catch((err) => { + if (err.response && err.response.status == 404) { + ErrorScene.showError( + new WAError( + "Access link incorrect", + "Could not find map. Please check your access link.", + "If you want more information, you may contact administrator or contact us at: hello@workadventu.re" + ), + this.scene + ); + } else { + ErrorScene.showError(err, this.scene); + } + }); } } diff --git a/front/src/Phaser/Login/SelectCharacterScene.ts b/front/src/Phaser/Login/SelectCharacterScene.ts index 0f590840..0d3bb431 100644 --- a/front/src/Phaser/Login/SelectCharacterScene.ts +++ b/front/src/Phaser/Login/SelectCharacterScene.ts @@ -1,25 +1,25 @@ -import {gameManager} from "../Game/GameManager"; +import { gameManager } from "../Game/GameManager"; import Rectangle = Phaser.GameObjects.Rectangle; -import {EnableCameraSceneName} from "./EnableCameraScene"; -import {CustomizeSceneName} from "./CustomizeScene"; -import {localUserStore} from "../../Connexion/LocalUserStore"; -import {loadAllDefaultModels} from "../Entity/PlayerTexturesLoadingManager"; -import {addLoader} from "../Components/Loader"; -import type {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures"; -import {AbstractCharacterScene} from "./AbstractCharacterScene"; -import {areCharacterLayersValid} from "../../Connexion/LocalUser"; -import {touchScreenManager} from "../../Touch/TouchScreenManager"; -import {PinchManager} from "../UserInput/PinchManager"; -import {selectCharacterSceneVisibleStore} from "../../Stores/SelectCharacterStore"; -import {waScaleManager} from "../Services/WaScaleManager"; -import {isMobile} from "../../Enum/EnvironmentVariable"; +import { EnableCameraSceneName } from "./EnableCameraScene"; +import { CustomizeSceneName } from "./CustomizeScene"; +import { localUserStore } from "../../Connexion/LocalUserStore"; +import { loadAllDefaultModels } from "../Entity/PlayerTexturesLoadingManager"; +import { addLoader } from "../Components/Loader"; +import type { BodyResourceDescriptionInterface } from "../Entity/PlayerTextures"; +import { AbstractCharacterScene } from "./AbstractCharacterScene"; +import { areCharacterLayersValid } from "../../Connexion/LocalUser"; +import { touchScreenManager } from "../../Touch/TouchScreenManager"; +import { PinchManager } from "../UserInput/PinchManager"; +import { selectCharacterSceneVisibleStore } from "../../Stores/SelectCharacterStore"; +import { waScaleManager } from "../Services/WaScaleManager"; +import { isMobile } from "../../Enum/EnvironmentVariable"; //todo: put this constants in a dedicated file export const SelectCharacterSceneName = "SelectCharacterScene"; export class SelectCharacterScene extends AbstractCharacterScene { protected readonly nbCharactersPerRow = 6; - protected selectedPlayer!: Phaser.Physics.Arcade.Sprite|null; // null if we are selecting the "customize" option + protected selectedPlayer!: Phaser.Physics.Arcade.Sprite | null; // null if we are selecting the "customize" option protected players: Array = new Array(); protected playerModels!: BodyResourceDescriptionInterface[]; @@ -38,7 +38,6 @@ export class SelectCharacterScene extends AbstractCharacterScene { } preload() { - this.loadSelectSceneCharacters().then((bodyResourceDescriptions) => { bodyResourceDescriptions.forEach((bodyResourceDescription) => { this.playerModels.push(bodyResourceDescription); @@ -54,7 +53,7 @@ export class SelectCharacterScene extends AbstractCharacterScene { create() { selectCharacterSceneVisibleStore.set(true); - this.events.addListener('wake', () => { + this.events.addListener("wake", () => { waScaleManager.saveZoom(); waScaleManager.zoomModifier = isMobile() ? 2 : 1; selectCharacterSceneVisibleStore.set(true); @@ -68,26 +67,26 @@ export class SelectCharacterScene extends AbstractCharacterScene { waScaleManager.zoomModifier = isMobile() ? 2 : 1; const rectangleXStart = this.game.renderer.width / 2 - (this.nbCharactersPerRow / 2) * 32 + 16; - this.selectedRectangle = this.add.rectangle(rectangleXStart, 90, 32, 32).setStrokeStyle(2, 0xFFFFFF); + this.selectedRectangle = this.add.rectangle(rectangleXStart, 90, 32, 32).setStrokeStyle(2, 0xffffff); this.selectedRectangle.setDepth(2); /*create user*/ this.createCurrentPlayer(); - this.input.keyboard.on('keyup-ENTER', () => { + this.input.keyboard.on("keyup-ENTER", () => { return this.nextSceneToCameraScene(); }); - this.input.keyboard.on('keydown-RIGHT', () => { + this.input.keyboard.on("keydown-RIGHT", () => { this.moveToRight(); }); - this.input.keyboard.on('keydown-LEFT', () => { + this.input.keyboard.on("keydown-LEFT", () => { this.moveToLeft(); }); - this.input.keyboard.on('keydown-UP', () => { + this.input.keyboard.on("keydown-UP", () => { this.moveToUp(); }); - this.input.keyboard.on('keydown-DOWN', () => { + this.input.keyboard.on("keydown-DOWN", () => { this.moveToDown(); }); } @@ -96,7 +95,7 @@ export class SelectCharacterScene extends AbstractCharacterScene { if (this.selectedPlayer !== null && !areCharacterLayersValid([this.selectedPlayer.texture.key])) { return; } - if(!this.selectedPlayer){ + if (!this.selectedPlayer) { return; } this.scene.stop(SelectCharacterSceneName); @@ -105,7 +104,7 @@ export class SelectCharacterScene extends AbstractCharacterScene { gameManager.tryResumingGame(this, EnableCameraSceneName); this.players = []; selectCharacterSceneVisibleStore.set(false); - this.events.removeListener('wake'); + this.events.removeListener("wake"); } public nextSceneToCustomizeScene(): void { @@ -119,11 +118,11 @@ export class SelectCharacterScene extends AbstractCharacterScene { } createCurrentPlayer(): void { - for (let i = 0; i c.texture.key === playerResource.name)){ + if (this.players.find((c) => c.texture.key === playerResource.name)) { continue; } @@ -132,9 +131,9 @@ export class SelectCharacterScene extends AbstractCharacterScene { this.setUpPlayer(player, i); this.anims.create({ key: playerResource.name, - frames: this.anims.generateFrameNumbers(playerResource.name, {start: 0, end: 11}), + frames: this.anims.generateFrameNumbers(playerResource.name, { start: 0, end: 11 }), frameRate: 8, - repeat: -1 + repeat: -1, }); player.setInteractive().on("pointerdown", () => { if (this.pointerClicked) { @@ -153,77 +152,79 @@ export class SelectCharacterScene extends AbstractCharacterScene { }); this.players.push(player); } + if (this.currentSelectUser >= this.players.length) { + this.currentSelectUser = 0; + } this.selectedPlayer = this.players[this.currentSelectUser]; this.selectedPlayer.play(this.playerModels[this.currentSelectUser].name); } - protected moveUser(){ - for(let i = 0; i < this.players.length; i++){ + protected moveUser() { + for (let i = 0; i < this.players.length; i++) { const player = this.players[i]; this.setUpPlayer(player, i); } this.updateSelectedPlayer(); } - public moveToLeft(){ - if(this.currentSelectUser === 0){ + public moveToLeft() { + if (this.currentSelectUser === 0) { return; } this.currentSelectUser -= 1; this.moveUser(); } - public moveToRight(){ - if(this.currentSelectUser === (this.players.length - 1)){ + public moveToRight() { + if (this.currentSelectUser === this.players.length - 1) { return; } this.currentSelectUser += 1; this.moveUser(); } - protected moveToUp(){ - if(this.currentSelectUser < this.nbCharactersPerRow){ + protected moveToUp() { + if (this.currentSelectUser < this.nbCharactersPerRow) { return; } this.currentSelectUser -= this.nbCharactersPerRow; this.moveUser(); } - protected moveToDown(){ - if((this.currentSelectUser + this.nbCharactersPerRow) > (this.players.length - 1)){ + protected moveToDown() { + if (this.currentSelectUser + this.nbCharactersPerRow > this.players.length - 1) { return; } this.currentSelectUser += this.nbCharactersPerRow; this.moveUser(); } - protected defineSetupPlayer(num: number){ + protected defineSetupPlayer(num: number) { const deltaX = 32; const deltaY = 32; let [playerX, playerY] = this.getCharacterPosition(); // player X and player y are middle of the - playerX = ( (playerX - (deltaX * 2.5)) + ((deltaX) * (num % this.nbCharactersPerRow)) ); // calcul position on line users - playerY = ( (playerY - (deltaY * 2)) + ((deltaY) * ( Math.floor(num / this.nbCharactersPerRow) )) ); // calcul position on column users + playerX = playerX - deltaX * 2.5 + deltaX * (num % this.nbCharactersPerRow); // calcul position on line users + playerY = playerY - deltaY * 2 + deltaY * Math.floor(num / this.nbCharactersPerRow); // calcul position on column users const playerVisible = true; const playerScale = 1; const playerOpacity = 1; // if selected - if( num === this.currentSelectUser ){ + if (num === this.currentSelectUser) { this.selectedRectangle.setX(playerX); this.selectedRectangle.setY(playerY); } - return {playerX, playerY, playerScale, playerOpacity, playerVisible} + return { playerX, playerY, playerScale, playerOpacity, playerVisible }; } - protected setUpPlayer(player: Phaser.Physics.Arcade.Sprite, num: number){ - - const {playerX, playerY, playerScale, playerOpacity, playerVisible} = this.defineSetupPlayer(num); + protected setUpPlayer(player: Phaser.Physics.Arcade.Sprite, num: number) { + const { playerX, playerY, playerScale, playerOpacity, playerVisible } = this.defineSetupPlayer(num); player.setBounce(0.2); player.setCollideWorldBounds(false); - player.setVisible( playerVisible ); + player.setVisible(playerVisible); player.setScale(playerScale, playerScale); player.setAlpha(playerOpacity); player.setX(playerX); @@ -234,10 +235,7 @@ export class SelectCharacterScene extends AbstractCharacterScene { * Returns pixel position by on column and row number */ protected getCharacterPosition(): [number, number] { - return [ - this.game.renderer.width / 2, - this.game.renderer.height / 2.5 - ]; + return [this.game.renderer.width / 2, this.game.renderer.height / 2.5]; } protected updateSelectedPlayer(): void { @@ -256,7 +254,7 @@ export class SelectCharacterScene extends AbstractCharacterScene { this.pointerClicked = false; } - if(this.lazyloadingAttempt){ + if (this.lazyloadingAttempt) { //re-render players list this.createCurrentPlayer(); this.moveUser(); diff --git a/front/src/Phaser/Map/ITiledMap.ts b/front/src/Phaser/Map/ITiledMap.ts index 0653e83a..57bb13c9 100644 --- a/front/src/Phaser/Map/ITiledMap.ts +++ b/front/src/Phaser/Map/ITiledMap.ts @@ -16,7 +16,7 @@ export interface ITiledMap { * Map orientation (orthogonal) */ orientation: string; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; /** * Render order (right-down) @@ -33,7 +33,7 @@ export interface ITiledMap { type?: string; } -export interface ITiledMapLayerProperty { +export interface ITiledMapProperty { name: string; type: string; value: string | boolean | number | undefined; @@ -51,7 +51,7 @@ export interface ITiledMapGroupLayer { id?: number; name: string; opacity: number; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; type: "group"; visible: boolean; @@ -69,7 +69,7 @@ export interface ITiledMapTileLayer { height: number; name: string; opacity: number; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; encoding?: string; compression?: string; @@ -91,7 +91,7 @@ export interface ITiledMapObjectLayer { height: number; name: string; opacity: number; - properties?: ITiledMapLayerProperty[]; + properties?: ITiledMapProperty[]; encoding?: string; compression?: string; @@ -117,7 +117,7 @@ export interface ITiledMapObject { gid: number; height: number; name: string; - properties: { [key: string]: string }; + properties?: ITiledMapProperty[]; rotation: number; type: string; visible: boolean; @@ -141,6 +141,7 @@ export interface ITiledMapObject { polyline: { x: number; y: number }[]; text?: ITiledText; + template?: string; } export interface ITiledText { @@ -163,7 +164,7 @@ export interface ITiledTileSet { imagewidth: number; margin: number; name: string; - properties: { [key: string]: string }; + properties?: ITiledMapProperty[]; spacing: number; tilecount: number; tileheight: number; @@ -182,7 +183,7 @@ export interface ITile { id: number; type?: string; - properties?: Array; + properties?: ITiledMapProperty[]; } export interface ITiledMapTerrain { diff --git a/front/src/Phaser/Menu/MenuScene.ts b/front/src/Phaser/Menu/MenuScene.ts index d0d6f982..4e9297b6 100644 --- a/front/src/Phaser/Menu/MenuScene.ts +++ b/front/src/Phaser/Menu/MenuScene.ts @@ -18,6 +18,9 @@ import { registerMenuCommandStream } from "../../Api/Events/ui/MenuItemRegisterE import { sendMenuClickedEvent } from "../../Api/iframe/Ui/MenuItem"; import { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore"; import { get } from "svelte/store"; +import { playersStore } from "../../Stores/PlayersStore"; +import { mediaManager } from "../../WebRtc/MediaManager"; +import { chatVisibilityStore } from "../../Stores/ChatStore"; export const MenuSceneName = "MenuScene"; const gameMenuKey = "gameMenu"; @@ -97,6 +100,10 @@ export class MenuScene extends Phaser.Scene { this.menuElement.setOrigin(0); MenuScene.revealMenusAfterInit(this.menuElement, "gameMenu"); + if (mediaManager.hasNotification()) { + HtmlUtils.getElementByIdOrFail("enableNotification").hidden = true; + } + const middleX = window.innerWidth / 3 - 298; this.gameQualityMenuElement = this.add.dom(middleX, -400).createFromCache(gameSettingsMenuKey); MenuScene.revealMenusAfterInit(this.gameQualityMenuElement, "gameQuality"); @@ -120,7 +127,11 @@ export class MenuScene extends Phaser.Scene { showReportScreenStore.subscribe((user) => { if (user !== null) { this.closeAll(); - this.gameReportElement.open(user.userId, user.userName); + const uuid = playersStore.getPlayerById(user.userId)?.userUuid; + if (uuid === undefined) { + throw new Error("Could not find UUID for user with ID " + user.userId); + } + this.gameReportElement.open(uuid, user.userName); } }); @@ -137,6 +148,9 @@ export class MenuScene extends Phaser.Scene { this.menuElement.on("click", this.onMenuClick.bind(this)); worldFullWarningStream.stream.subscribe(() => this.showWorldCapacityWarning()); + chatVisibilityStore.subscribe((v) => { + this.menuButton.setVisible(!v); + }); } //todo put this method in a parent menuElement class @@ -352,6 +366,9 @@ export class MenuScene extends Phaser.Scene { case "toggleFullscreen": this.toggleFullscreen(); break; + case "enableNotification": + this.enableNotification(); + break; case "adminConsoleButton": if (get(consoleGlobalMessageManagerVisibleStore)) { consoleGlobalMessageManagerVisibleStore.set(false); @@ -414,4 +431,12 @@ export class MenuScene extends Phaser.Scene { public isDirty(): boolean { return false; } + + private enableNotification() { + mediaManager.requestNotification().then(() => { + if (mediaManager.hasNotification()) { + HtmlUtils.getElementByIdOrFail("enableNotification").hidden = true; + } + }); + } } diff --git a/front/src/Phaser/Menu/ReportMenu.ts b/front/src/Phaser/Menu/ReportMenu.ts index e8b20531..effb92b2 100644 --- a/front/src/Phaser/Menu/ReportMenu.ts +++ b/front/src/Phaser/Menu/ReportMenu.ts @@ -1,15 +1,16 @@ -import {MenuScene} from "./MenuScene"; -import {gameManager} from "../Game/GameManager"; -import {blackListManager} from "../../WebRtc/BlackListManager"; +import { MenuScene } from "./MenuScene"; +import { gameManager } from "../Game/GameManager"; +import { blackListManager } from "../../WebRtc/BlackListManager"; +import { playersStore } from "../../Stores/PlayersStore"; -export const gameReportKey = 'gameReport'; -export const gameReportRessource = 'resources/html/gameReport.html'; +export const gameReportKey = "gameReport"; +export const gameReportRessource = "resources/html/gameReport.html"; export class ReportMenu extends Phaser.GameObjects.DOMElement { private opened: boolean = false; - private userId!: number; - private userName!: string|undefined; + private userUuid!: string; + private userName!: string | undefined; private anonymous: boolean; constructor(scene: Phaser.Scene, anonymous: boolean) { @@ -18,46 +19,46 @@ export class ReportMenu extends Phaser.GameObjects.DOMElement { this.createFromCache(gameReportKey); if (this.anonymous) { - const divToHide = this.getChildByID('reportSection') as HTMLElement; + const divToHide = this.getChildByID("reportSection") as HTMLElement; divToHide.hidden = true; - const textToHide = this.getChildByID('askActionP') as HTMLElement; + const textToHide = this.getChildByID("askActionP") as HTMLElement; textToHide.hidden = true; } scene.add.existing(this); MenuScene.revealMenusAfterInit(this, gameReportKey); - this.addListener('click'); - this.on('click', (event:MouseEvent) => { + this.addListener("click"); + this.on("click", (event: MouseEvent) => { event.preventDefault(); - if ((event?.target as HTMLInputElement).id === 'gameReportFormSubmit') { + if ((event?.target as HTMLInputElement).id === "gameReportFormSubmit") { this.submitReport(); - } else if((event?.target as HTMLInputElement).id === 'gameReportFormCancel') { + } else if ((event?.target as HTMLInputElement).id === "gameReportFormCancel") { this.close(); - } else if((event?.target as HTMLInputElement).id === 'toggleBlockButton') { + } else if ((event?.target as HTMLInputElement).id === "toggleBlockButton") { this.toggleBlock(); } }); } - public open(userId: number, userName: string|undefined): void { + public open(userUuid: string, userName: string | undefined): void { if (this.opened) { this.close(); return; } - this.userId = userId; + this.userUuid = userUuid; this.userName = userName; - const mainEl = this.getChildByID('gameReport') as HTMLElement; + const mainEl = this.getChildByID("gameReport") as HTMLElement; this.x = this.getCenteredX(mainEl); this.y = this.getHiddenY(mainEl); - const gameTitleReport = this.getChildByID('nameReported') as HTMLElement; - gameTitleReport.innerText = userName || ''; + const gameTitleReport = this.getChildByID("nameReported") as HTMLElement; + gameTitleReport.innerText = userName || ""; - const blockButton = this.getChildByID('toggleBlockButton') as HTMLElement; - blockButton.innerText = blackListManager.isBlackListed(this.userId) ? 'Unblock this user' : 'Block this user'; + const blockButton = this.getChildByID("toggleBlockButton") as HTMLElement; + blockButton.innerText = blackListManager.isBlackListed(this.userUuid) ? "Unblock this user" : "Block this user"; this.opened = true; @@ -67,19 +68,19 @@ export class ReportMenu extends Phaser.GameObjects.DOMElement { targets: this, y: this.getCenteredY(mainEl), duration: 1000, - ease: 'Power3' + ease: "Power3", }); } public close(): void { gameManager.getCurrentGameScene(this.scene).userInputManager.restoreControls(); this.opened = false; - const mainEl = this.getChildByID('gameReport') as HTMLElement; + const mainEl = this.getChildByID("gameReport") as HTMLElement; this.scene.tweens.add({ targets: this, y: this.getHiddenY(mainEl), duration: 1000, - ease: 'Power3' + ease: "Power3", }); } @@ -88,31 +89,32 @@ export class ReportMenu extends Phaser.GameObjects.DOMElement { return window.innerWidth / 4 - mainEl.clientWidth / 2; } private getHiddenY(mainEl: HTMLElement): number { - return - mainEl.clientHeight - 50; + return -mainEl.clientHeight - 50; } private getCenteredY(mainEl: HTMLElement): number { return window.innerHeight / 4 - mainEl.clientHeight / 2; } private toggleBlock(): void { - !blackListManager.isBlackListed(this.userId) ? blackListManager.blackList(this.userId) : blackListManager.cancelBlackList(this.userId); + !blackListManager.isBlackListed(this.userUuid) + ? blackListManager.blackList(this.userUuid) + : blackListManager.cancelBlackList(this.userUuid); this.close(); } - private submitReport(): void{ - const gamePError = this.getChildByID('gameReportErr') as HTMLParagraphElement; - gamePError.innerText = ''; - gamePError.style.display = 'none'; - const gameTextArea = this.getChildByID('gameReportInput') as HTMLInputElement; - if(!gameTextArea || !gameTextArea.value){ - gamePError.innerText = 'Report message cannot to be empty.'; - gamePError.style.display = 'block'; + private submitReport(): void { + const gamePError = this.getChildByID("gameReportErr") as HTMLParagraphElement; + gamePError.innerText = ""; + gamePError.style.display = "none"; + const gameTextArea = this.getChildByID("gameReportInput") as HTMLInputElement; + if (!gameTextArea || !gameTextArea.value) { + gamePError.innerText = "Report message cannot to be empty."; + gamePError.style.display = "block"; return; } - gameManager.getCurrentGameScene(this.scene).connection?.emitReportPlayerMessage( - this.userId, - gameTextArea.value - ); + gameManager + .getCurrentGameScene(this.scene) + .connection?.emitReportPlayerMessage(this.userUuid, gameTextArea.value); this.close(); } } diff --git a/front/src/Phaser/Reconnecting/ErrorScene.ts b/front/src/Phaser/Reconnecting/ErrorScene.ts index dbde2628..fb3d333a 100644 --- a/front/src/Phaser/Reconnecting/ErrorScene.ts +++ b/front/src/Phaser/Reconnecting/ErrorScene.ts @@ -1,14 +1,14 @@ -import {TextField} from "../Components/TextField"; +import { TextField } from "../Components/TextField"; import Image = Phaser.GameObjects.Image; import Sprite = Phaser.GameObjects.Sprite; import Text = Phaser.GameObjects.Text; import ScenePlugin = Phaser.Scenes.ScenePlugin; -import {WAError} from "./WAError"; +import { WAError } from "./WAError"; export const ErrorSceneName = "ErrorScene"; enum Textures { icon = "icon", - mainFont = "main_font" + mainFont = "main_font", } export class ErrorScene extends Phaser.Scene { @@ -23,25 +23,21 @@ export class ErrorScene extends Phaser.Scene { constructor() { super({ - key: ErrorSceneName + key: ErrorSceneName, }); } - init({title, subTitle, message}: { title?: string, subTitle?: string, message?: string }) { - this.title = title ? title : ''; - this.subTitle = subTitle ? subTitle : ''; - this.message = message ? message : ''; + init({ title, subTitle, message }: { title?: string; subTitle?: string; message?: string }) { + this.title = title ? title : ""; + this.subTitle = subTitle ? subTitle : ""; + this.message = message ? message : ""; } preload() { - this.load.image(Textures.icon, "resources/logos/tcm_full.png"); + this.load.image(Textures.icon, "static/images/favicons/favicon-32x32.png"); // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap - this.load.bitmapFont(Textures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); - this.load.spritesheet( - 'cat', - 'resources/characters/pipoya/Cat 01-1.png', - {frameWidth: 32, frameHeight: 32} - ); + this.load.bitmapFont(Textures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml"); + this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 }); } create() { @@ -50,15 +46,25 @@ export class ErrorScene extends Phaser.Scene { this.titleField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2, this.title); - this.subTitleField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2 + 24, this.subTitle); + this.subTitleField = new TextField( + this, + this.game.renderer.width / 2, + this.game.renderer.height / 2 + 24, + this.subTitle + ); - this.messageField = this.add.text(this.game.renderer.width / 2, this.game.renderer.height / 2 + 48, this.message, { - fontFamily: 'Georgia, "Goudy Bookletter 1911", Times, serif', - fontSize: '10px' - }); + this.messageField = this.add.text( + this.game.renderer.width / 2, + this.game.renderer.height / 2 + 48, + this.message, + { + fontFamily: 'Georgia, "Goudy Bookletter 1911", Times, serif', + fontSize: "10px", + } + ); this.messageField.setOrigin(0.5, 0.5); - this.cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, 'cat', 6); + this.cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, "cat", 6); this.cat.flipY = true; } @@ -69,38 +75,38 @@ export class ErrorScene extends Phaser.Scene { public static showError(error: any, scene: ScenePlugin): void { console.error(error); - if (typeof error === 'string' || error instanceof String) { + if (typeof error === "string" || error instanceof String) { scene.start(ErrorSceneName, { - title: 'An error occurred', - subTitle: error + title: "An error occurred", + subTitle: error, }); } else if (error instanceof WAError) { scene.start(ErrorSceneName, { title: error.title, subTitle: error.subTitle, - message: error.details + message: error.details, }); } else if (error.response) { // Axios HTTP error // client received an error response (5xx, 4xx) scene.start(ErrorSceneName, { - title: 'HTTP ' + error.response.status + ' - ' + error.response.statusText, - subTitle: 'An error occurred while accessing URL:', - message: error.response.config.url + title: "HTTP " + error.response.status + " - " + error.response.statusText, + subTitle: "An error occurred while accessing URL:", + message: error.response.config.url, }); } else if (error.request) { // Axios HTTP error // client never received a response, or request never left scene.start(ErrorSceneName, { - title: 'Network error', - subTitle: error.message + title: "Network error", + subTitle: error.message, }); } else if (error instanceof Error) { // Error scene.start(ErrorSceneName, { - title: 'An error occurred', + title: "An error occurred", subTitle: error.name, - message: error.message + message: error.message, }); } else { throw error; @@ -114,7 +120,7 @@ export class ErrorScene extends Phaser.Scene { scene.start(ErrorSceneName, { title, subTitle, - message + message, }); } } diff --git a/front/src/Phaser/Reconnecting/ReconnectingScene.ts b/front/src/Phaser/Reconnecting/ReconnectingScene.ts index 9b56dd63..3c8a966c 100644 --- a/front/src/Phaser/Reconnecting/ReconnectingScene.ts +++ b/front/src/Phaser/Reconnecting/ReconnectingScene.ts @@ -1,11 +1,11 @@ -import {TextField} from "../Components/TextField"; +import { TextField } from "../Components/TextField"; import Image = Phaser.GameObjects.Image; import Sprite = Phaser.GameObjects.Sprite; export const ReconnectingSceneName = "ReconnectingScene"; enum ReconnectingTextures { icon = "icon", - mainFont = "main_font" + mainFont = "main_font", } export class ReconnectingScene extends Phaser.Scene { @@ -14,35 +14,40 @@ export class ReconnectingScene extends Phaser.Scene { constructor() { super({ - key: ReconnectingSceneName + key: ReconnectingSceneName, }); } preload() { - this.load.image(ReconnectingTextures.icon, "resources/logos/tcm_full.png"); + this.load.image(ReconnectingTextures.icon, "static/images/favicons/favicon-32x32.png"); // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap - this.load.bitmapFont(ReconnectingTextures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); - this.load.spritesheet( - 'cat', - 'resources/characters/pipoya/Cat 01-1.png', - {frameWidth: 32, frameHeight: 32} - ); + this.load.bitmapFont(ReconnectingTextures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml"); + this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 }); } create() { - this.logo = new Image(this, this.game.renderer.width - 30, this.game.renderer.height - 20, ReconnectingTextures.icon); + this.logo = new Image( + this, + this.game.renderer.width - 30, + this.game.renderer.height - 20, + ReconnectingTextures.icon + ); this.add.existing(this.logo); - this.reconnectingField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2, "Connection lost. Reconnecting..."); + this.reconnectingField = new TextField( + this, + this.game.renderer.width / 2, + this.game.renderer.height / 2, + "Connection lost. Reconnecting..." + ); - const cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, 'cat'); + const cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, "cat"); this.anims.create({ - key: 'right', - frames: this.anims.generateFrameNumbers('cat', { start: 6, end: 8 }), + key: "right", + frames: this.anims.generateFrameNumbers("cat", { start: 6, end: 8 }), frameRate: 10, - repeat: -1 + repeat: -1, }); - cat.play('right'); - + cat.play("right"); } } diff --git a/front/src/Stores/ChatStore.ts b/front/src/Stores/ChatStore.ts new file mode 100644 index 00000000..feb1f3ec --- /dev/null +++ b/front/src/Stores/ChatStore.ts @@ -0,0 +1,119 @@ +import { writable } from "svelte/store"; +import { playersStore } from "./PlayersStore"; +import type { PlayerInterface } from "../Phaser/Game/PlayerInterface"; + +export const chatVisibilityStore = writable(false); +export const chatInputFocusStore = writable(false); + +export const newChatMessageStore = writable(null); + +export enum ChatMessageTypes { + text = 1, + me, + userIncoming, + userOutcoming, +} + +export interface ChatMessage { + type: ChatMessageTypes; + date: Date; + author?: PlayerInterface; + targets?: PlayerInterface[]; + text?: string[]; +} + +function getAuthor(authorId: number): PlayerInterface { + const author = playersStore.getPlayerById(authorId); + if (!author) { + throw "Could not find data for author " + authorId; + } + return author; +} + +function createChatMessagesStore() { + const { subscribe, update } = writable([]); + + return { + subscribe, + addIncomingUser(authorId: number) { + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.userIncoming && lastMessage.targets) { + lastMessage.targets.push(getAuthor(authorId)); + } else { + list.push({ + type: ChatMessageTypes.userIncoming, + targets: [getAuthor(authorId)], + date: new Date(), + }); + } + return list; + }); + }, + addOutcomingUser(authorId: number) { + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.userOutcoming && lastMessage.targets) { + lastMessage.targets.push(getAuthor(authorId)); + } else { + list.push({ + type: ChatMessageTypes.userOutcoming, + targets: [getAuthor(authorId)], + date: new Date(), + }); + } + return list; + }); + }, + addPersonnalMessage(text: string) { + newChatMessageStore.set(text); + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.me && lastMessage.text) { + lastMessage.text.push(text); + } else { + list.push({ + type: ChatMessageTypes.me, + text: [text], + date: new Date(), + }); + } + return list; + }); + }, + addExternalMessage(authorId: number, text: string) { + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.text && lastMessage.text) { + lastMessage.text.push(text); + } else { + list.push({ + type: ChatMessageTypes.text, + text: [text], + author: getAuthor(authorId), + date: new Date(), + }); + } + return list; + }); + chatVisibilityStore.set(true); + }, + }; +} +export const chatMessagesStore = createChatMessagesStore(); + +function createChatSubMenuVisibilityStore() { + const { subscribe, update } = writable(""); + + return { + subscribe, + openSubMenu(playerName: string, index: number) { + const id = playerName + index; + update((oldValue) => { + return oldValue === id ? "" : id; + }); + }, + }; +} + +export const chatSubMenuVisbilityStore = createChatSubMenuVisibilityStore(); diff --git a/front/src/Stores/PlayersStore.ts b/front/src/Stores/PlayersStore.ts new file mode 100644 index 00000000..86ab136f --- /dev/null +++ b/front/src/Stores/PlayersStore.ts @@ -0,0 +1,69 @@ +import { writable } from "svelte/store"; +import type { PlayerInterface } from "../Phaser/Game/PlayerInterface"; +import type { RoomConnection } from "../Connexion/RoomConnection"; +import { getRandomColor } from "../WebRtc/ColorGenerator"; + +let idCount = 0; + +/** + * A store that contains the list of players currently known. + */ +function createPlayersStore() { + let players = new Map(); + + const { subscribe, set, update } = writable(players); + + return { + subscribe, + connectToRoomConnection: (roomConnection: RoomConnection) => { + players = new Map(); + set(players); + roomConnection.onUserJoins((message) => { + update((users) => { + users.set(message.userId, { + userId: message.userId, + name: message.name, + characterLayers: message.characterLayers, + visitCardUrl: message.visitCardUrl, + companion: message.companion, + userUuid: message.userUuid, + color: getRandomColor(), + }); + return users; + }); + }); + roomConnection.onUserLeft((userId) => { + update((users) => { + users.delete(userId); + return users; + }); + }); + }, + getPlayerById(userId: number): PlayerInterface | undefined { + return players.get(userId); + }, + addFacticePlayer(name: string): number { + let userId: number | null = null; + players.forEach((p) => { + if (p.name === name) userId = p.userId; + }); + if (userId) return userId; + const newUserId = idCount--; + update((users) => { + users.set(newUserId, { + userId: newUserId, + name, + characterLayers: [], + visitCardUrl: null, + companion: null, + userUuid: "dummy", + color: getRandomColor(), + }); + return users; + }); + return newUserId; + }, + }; +} + +export const playersStore = createPlayersStore(); diff --git a/front/src/Stores/UserInputStore.ts b/front/src/Stores/UserInputStore.ts index cbb7f0c3..993d8795 100644 --- a/front/src/Stores/UserInputStore.ts +++ b/front/src/Stores/UserInputStore.ts @@ -1,10 +1,11 @@ -import {derived} from "svelte/store"; -import {consoleGlobalMessageManagerFocusStore} from "./ConsoleGlobalMessageManagerStore"; +import { derived } from "svelte/store"; +import { consoleGlobalMessageManagerFocusStore } from "./ConsoleGlobalMessageManagerStore"; +import { chatInputFocusStore } from "./ChatStore"; //derived from the focus on Menu, ConsoleGlobal, Chat and ... export const enableUserInputsStore = derived( - consoleGlobalMessageManagerFocusStore, - ($consoleGlobalMessageManagerFocusStore) => { - return !$consoleGlobalMessageManagerFocusStore; + [consoleGlobalMessageManagerFocusStore, chatInputFocusStore], + ([$consoleGlobalMessageManagerFocusStore, $chatInputFocusStore]) => { + return !$consoleGlobalMessageManagerFocusStore && !$chatInputFocusStore; } -); \ No newline at end of file +); diff --git a/front/src/WebRtc/BlackListManager.ts b/front/src/WebRtc/BlackListManager.ts index 65efef3a..d2e7c390 100644 --- a/front/src/WebRtc/BlackListManager.ts +++ b/front/src/WebRtc/BlackListManager.ts @@ -1,24 +1,27 @@ -import {Subject} from 'rxjs'; +import { Subject } from "rxjs"; class BlackListManager { - private list: number[] = []; - public onBlockStream: Subject = new Subject(); - public onUnBlockStream: Subject = new Subject(); - - isBlackListed(userId: number): boolean { - return this.list.find((data) => data === userId) !== undefined; - } - - blackList(userId: number): void { - if (this.isBlackListed(userId)) return; - this.list.push(userId); - this.onBlockStream.next(userId); + private list: string[] = []; + public onBlockStream: Subject = new Subject(); + public onUnBlockStream: Subject = new Subject(); + + isBlackListed(userUuid: string): boolean { + return this.list.find((data) => data === userUuid) !== undefined; } - cancelBlackList(userId: number): void { - this.list.splice(this.list.findIndex(data => data === userId), 1); - this.onUnBlockStream.next(userId); + blackList(userUuid: string): void { + if (this.isBlackListed(userUuid)) return; + this.list.push(userUuid); + this.onBlockStream.next(userUuid); + } + + cancelBlackList(userUuid: string): void { + this.list.splice( + this.list.findIndex((data) => data === userUuid), + 1 + ); + this.onUnBlockStream.next(userUuid); } } -export const blackListManager = new BlackListManager(); \ No newline at end of file +export const blackListManager = new BlackListManager(); diff --git a/front/src/WebRtc/ColorGenerator.ts b/front/src/WebRtc/ColorGenerator.ts new file mode 100644 index 00000000..be192f9f --- /dev/null +++ b/front/src/WebRtc/ColorGenerator.ts @@ -0,0 +1,52 @@ +export function getRandomColor(): string { + const golden_ratio_conjugate = 0.618033988749895; + let hue = Math.random(); + hue += golden_ratio_conjugate; + hue %= 1; + return hsv_to_rgb(hue, 0.5, 0.95); +} + +//todo: test this. +function hsv_to_rgb(hue: number, saturation: number, brightness: number): string { + const h_i = Math.floor(hue * 6); + const f = hue * 6 - h_i; + const p = brightness * (1 - saturation); + const q = brightness * (1 - f * saturation); + const t = brightness * (1 - (1 - f) * saturation); + let r: number, g: number, b: number; + switch (h_i) { + case 0: + r = brightness; + g = t; + b = p; + break; + case 1: + r = q; + g = brightness; + b = p; + break; + case 2: + r = p; + g = brightness; + b = t; + break; + case 3: + r = p; + g = q; + b = brightness; + break; + case 4: + r = t; + g = p; + b = brightness; + break; + case 5: + r = brightness; + g = p; + b = q; + break; + default: + throw "h_i cannot be " + h_i; + } + return "#" + Math.floor(r * 256).toString(16) + Math.floor(g * 256).toString(16) + Math.floor(b * 256).toString(16); +} diff --git a/front/src/WebRtc/DiscussionManager.ts b/front/src/WebRtc/DiscussionManager.ts index ae351f76..fcf04ef1 100644 --- a/front/src/WebRtc/DiscussionManager.ts +++ b/front/src/WebRtc/DiscussionManager.ts @@ -1,232 +1,13 @@ -import { HtmlUtils } from "./HtmlUtils"; -import type { UserInputManager } from "../Phaser/UserInput/UserInputManager"; -import { connectionManager } from "../Connexion/ConnectionManager"; -import { GameConnexionTypes } from "../Url/UrlManager"; import { iframeListener } from "../Api/IframeListener"; -import { showReportScreenStore } from "../Stores/ShowReportScreenStore"; - -export type SendMessageCallback = (message: string) => void; +import { chatMessagesStore } from "../Stores/ChatStore"; +import { playersStore } from "../Stores/PlayersStore"; export class DiscussionManager { - private mainContainer: HTMLDivElement; - - private divDiscuss?: HTMLDivElement; - private divParticipants?: HTMLDivElement; - private nbpParticipants?: HTMLParagraphElement; - private divMessages?: HTMLParagraphElement; - - private participants: Map = new Map(); - - private activeDiscussion: boolean = false; - - private sendMessageCallBack: Map = new Map< - number | string, - SendMessageCallback - >(); - - private userInputManager?: UserInputManager; - constructor() { - this.mainContainer = HtmlUtils.getElementByIdOrFail("main-container"); - this.createDiscussPart(""); //todo: why do we always use empty string? - iframeListener.chatStream.subscribe((chatEvent) => { - this.addMessage(chatEvent.author, chatEvent.message, false); - this.showDiscussion(); + const userId = playersStore.addFacticePlayer(chatEvent.author); + chatMessagesStore.addExternalMessage(userId, chatEvent.message); }); - this.onSendMessageCallback("iframe_listener", (message) => { - iframeListener.sendUserInputChat(message); - }); - } - - private createDiscussPart(name: string) { - this.divDiscuss = document.createElement("div"); - this.divDiscuss.classList.add("discussion"); - - const buttonCloseDiscussion: HTMLButtonElement = document.createElement("button"); - buttonCloseDiscussion.classList.add("close-btn"); - buttonCloseDiscussion.innerHTML = ``; - buttonCloseDiscussion.addEventListener("click", () => { - this.hideDiscussion(); - }); - this.divDiscuss.appendChild(buttonCloseDiscussion); - - const myName: HTMLParagraphElement = document.createElement("p"); - myName.innerText = name.toUpperCase(); - this.nbpParticipants = document.createElement("p"); - this.nbpParticipants.innerText = "PARTICIPANTS (1)"; - - this.divParticipants = document.createElement("div"); - this.divParticipants.classList.add("participants"); - - this.divMessages = document.createElement("div"); - this.divMessages.classList.add("messages"); - this.divMessages.innerHTML = "

Local messages

"; - - this.divDiscuss.appendChild(myName); - this.divDiscuss.appendChild(this.nbpParticipants); - this.divDiscuss.appendChild(this.divParticipants); - this.divDiscuss.appendChild(this.divMessages); - - const sendDivMessage: HTMLDivElement = document.createElement("div"); - sendDivMessage.classList.add("send-message"); - const inputMessage: HTMLInputElement = document.createElement("input"); - inputMessage.onfocus = () => { - if (this.userInputManager) { - this.userInputManager.disableControls(); - } - }; - inputMessage.onblur = () => { - if (this.userInputManager) { - this.userInputManager.restoreControls(); - } - }; - inputMessage.type = "text"; - inputMessage.addEventListener("keyup", (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - if (inputMessage.value === null || inputMessage.value === "" || inputMessage.value === undefined) { - return; - } - this.addMessage(name, inputMessage.value, true); - for (const callback of this.sendMessageCallBack.values()) { - callback(inputMessage.value); - } - inputMessage.value = ""; - } - }); - sendDivMessage.appendChild(inputMessage); - this.divDiscuss.appendChild(sendDivMessage); - - //append in main container - this.mainContainer.appendChild(this.divDiscuss); - - this.addParticipant("me", "Moi", undefined, true); - } - - public addParticipant( - userId: number | "me", - name: string | undefined, - img?: string | undefined, - isMe: boolean = false - ) { - const divParticipant: HTMLDivElement = document.createElement("div"); - divParticipant.classList.add("participant"); - divParticipant.id = `participant-${userId}`; - - const divImgParticipant: HTMLImageElement = document.createElement("img"); - divImgParticipant.src = "resources/logos/boy.svg"; - if (img !== undefined) { - divImgParticipant.src = img; - } - const divPParticipant: HTMLParagraphElement = document.createElement("p"); - if (!name) { - name = "Anonymous"; - } - divPParticipant.innerText = name; - - divParticipant.appendChild(divImgParticipant); - divParticipant.appendChild(divPParticipant); - - if ( - !isMe && - connectionManager.getConnexionType && - connectionManager.getConnexionType !== GameConnexionTypes.anonymous && - userId !== "me" - ) { - const reportBanUserAction: HTMLButtonElement = document.createElement("button"); - reportBanUserAction.classList.add("report-btn"); - reportBanUserAction.innerText = "Report"; - reportBanUserAction.addEventListener("click", () => { - showReportScreenStore.set({ userId: userId, userName: name ? name : "" }); - }); - divParticipant.appendChild(reportBanUserAction); - } - - this.divParticipants?.appendChild(divParticipant); - - this.participants.set(userId, divParticipant); - - this.updateParticipant(this.participants.size); - } - - public updateParticipant(nb: number) { - if (!this.nbpParticipants) { - return; - } - this.nbpParticipants.innerText = `PARTICIPANTS (${nb})`; - } - - public addMessage(name: string, message: string, isMe: boolean = false) { - const divMessage: HTMLDivElement = document.createElement("div"); - divMessage.classList.add("message"); - if (isMe) { - divMessage.classList.add("me"); - } - - const pMessage: HTMLParagraphElement = document.createElement("p"); - const date = new Date(); - if (isMe) { - name = "Me"; - } else { - name = HtmlUtils.escapeHtml(name); - } - pMessage.innerHTML = `${name} - - ${date.getHours()}:${date.getMinutes()} - `; - divMessage.appendChild(pMessage); - - const userMessage: HTMLParagraphElement = document.createElement("p"); - userMessage.innerHTML = HtmlUtils.urlify(message); - userMessage.classList.add("body"); - divMessage.appendChild(userMessage); - this.divMessages?.appendChild(divMessage); - - //automatic scroll when there are new message - setTimeout(() => { - this.divMessages?.scroll({ - top: this.divMessages?.scrollTop + divMessage.getBoundingClientRect().y, - behavior: "smooth", - }); - }, 200); - } - - public removeParticipant(userId: number | string) { - const element = this.participants.get(userId); - if (element) { - element.remove(); - this.participants.delete(userId); - } - //if all participant leave, hide discussion button - - this.sendMessageCallBack.delete(userId); - } - - public onSendMessageCallback(userId: string | number, callback: SendMessageCallback): void { - this.sendMessageCallBack.set(userId, callback); - } - - get activatedDiscussion() { - return this.activeDiscussion; - } - - private showDiscussion() { - this.activeDiscussion = true; - this.divDiscuss?.classList.add("active"); - } - - private hideDiscussion() { - this.activeDiscussion = false; - this.divDiscuss?.classList.remove("active"); - } - - public setUserInputManager(userInputManager: UserInputManager) { - this.userInputManager = userInputManager; - } - - public showDiscussionPart() { - this.showDiscussion(); } } diff --git a/front/src/WebRtc/HtmlUtils.ts b/front/src/WebRtc/HtmlUtils.ts index 942e553f..530eca19 100644 --- a/front/src/WebRtc/HtmlUtils.ts +++ b/front/src/WebRtc/HtmlUtils.ts @@ -2,9 +2,9 @@ export class HtmlUtils { public static getElementByIdOrFail(id: string): T { const elem = document.getElementById(id); if (HtmlUtils.isHtmlElement(elem)) { - return elem; + return elem; } - throw new Error("Cannot find HTML element with id '"+id+"'"); + throw new Error("Cannot find HTML element with id '" + id + "'"); } public static querySelectorOrFail(selector: string): T { @@ -12,7 +12,7 @@ export class HtmlUtils { if (HtmlUtils.isHtmlElement(elem)) { return elem; } - throw new Error("Cannot find HTML element with selector '"+selector+"'"); + throw new Error("Cannot find HTML element with selector '" + selector + "'"); } public static removeElementByIdOrFail(id: string): T { @@ -21,12 +21,12 @@ export class HtmlUtils { elem.remove(); return elem; } - throw new Error("Cannot find HTML element with id '"+id+"'"); + throw new Error("Cannot find HTML element with id '" + id + "'"); } public static escapeHtml(html: string): string { - const text = document.createTextNode(html); - const p = document.createElement('p'); + const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g, "
")); + const p = document.createElement("p"); p.appendChild(text); return p.innerHTML; } @@ -35,7 +35,7 @@ export class HtmlUtils { const urlRegex = /(https?:\/\/[^\s]+)/g; text = HtmlUtils.escapeHtml(text); return text.replace(urlRegex, (url: string) => { - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; link.target = "_blank"; const text = document.createTextNode(url); diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index d9847f44..d7e9f514 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -1,16 +1,10 @@ -import { DivImportance, layoutManager } from "./LayoutManager"; +import { layoutManager } from "./LayoutManager"; import { HtmlUtils } from "./HtmlUtils"; -import { discussionManager, SendMessageCallback } from "./DiscussionManager"; import type { UserInputManager } from "../Phaser/UserInput/UserInputManager"; -import { localUserStore } from "../Connexion/LocalUserStore"; -import type { UserSimplePeerInterface } from "./SimplePeer"; -import { SoundMeter } from "../Phaser/Components/SoundMeter"; -import { DISABLE_NOTIFICATIONS } from "../Enum/EnvironmentVariable"; import { localStreamStore } from "../Stores/MediaStore"; import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore"; import { helpCameraSettingsVisibleStore } from "../Stores/HelpCameraSettingsStore"; -export type UpdatedLocalStreamCallback = (media: MediaStream | null) => void; export type StartScreenSharingCallback = (media: MediaStream) => void; export type StopScreenSharingCallback = (media: MediaStream) => void; @@ -21,16 +15,11 @@ export class MediaManager { startScreenSharingCallBacks: Set = new Set(); stopScreenSharingCallBacks: Set = new Set(); - private focused: boolean = true; - private triggerCloseJistiFrame: Map = new Map(); private userInputManager?: UserInputManager; constructor() { - //Check of ask notification navigator permission - this.getNotification(); - localStreamStore.subscribe((result) => { if (result.type === "error") { console.error(result.error); @@ -182,67 +171,35 @@ export class MediaManager { } } - public addNewMessage(name: string, message: string, isMe: boolean = false) { - discussionManager.addMessage(name, message, isMe); - - //when there are new message, show discussion - if (!discussionManager.activatedDiscussion) { - discussionManager.showDiscussionPart(); - } - } - - public addSendMessageCallback(userId: string | number, callback: SendMessageCallback) { - discussionManager.onSendMessageCallback(userId, callback); - } - public setUserInputManager(userInputManager: UserInputManager) { this.userInputManager = userInputManager; - discussionManager.setUserInputManager(userInputManager); } - public getNotification() { - //Get notification - if (!DISABLE_NOTIFICATIONS && window.Notification && Notification.permission !== "granted") { - if (this.checkNotificationPromise()) { - Notification.requestPermission().catch((err) => { - console.error(`Notification permission error`, err); - }); - } else { - Notification.requestPermission(); - } - } + public hasNotification(): boolean { + return Notification.permission === "granted"; } - /** - * Return true if the browser supports the modern version of the Notification API (which is Promise based) or false - * if we are on Safari... - * - * See https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API - */ - private checkNotificationPromise(): boolean { - try { - Notification.requestPermission().then(); - } catch (e) { - return false; + public requestNotification() { + if (window.Notification && Notification.permission !== "granted") { + return Notification.requestPermission(); + } else { + return Promise.reject(); } - - return true; } public createNotification(userName: string) { - if (this.focused) { + if (document.hasFocus()) { return; } - if (window.Notification && Notification.permission === "granted") { - const title = "WorkAdventure"; + + if (this.hasNotification()) { + const title = `${userName} wants to discuss with you`; const options = { - body: `Hi! ${userName} wants to discuss with you, don't be afraid!`, icon: "/resources/logos/logo-WA-min.png", image: "/resources/logos/logo-WA-min.png", badge: "/resources/logos/logo-WA-min.png", }; new Notification(title, options); - //new Notification(`Hi! ${userName} wants to discuss with you, don't be afraid!`); } } } diff --git a/front/src/WebRtc/ScreenSharingPeer.ts b/front/src/WebRtc/ScreenSharingPeer.ts index 9beab732..18810182 100644 --- a/front/src/WebRtc/ScreenSharingPeer.ts +++ b/front/src/WebRtc/ScreenSharingPeer.ts @@ -1,11 +1,10 @@ import type * as SimplePeerNamespace from "simple-peer"; -import { mediaManager } from "./MediaManager"; -import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable"; import type { RoomConnection } from "../Connexion/RoomConnection"; import { MESSAGE_TYPE_CONSTRAINT, PeerStatus } from "./VideoPeer"; import type { UserSimplePeerInterface } from "./SimplePeer"; -import { Readable, readable, writable, Writable } from "svelte/store"; +import { Readable, readable } from "svelte/store"; import { videoFocusStore } from "../Stores/VideoFocusStore"; +import { getIceServersConfig } from "../Components/Video/utils"; const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); @@ -32,21 +31,9 @@ export class ScreenSharingPeer extends Peer { stream: MediaStream | null ) { super({ - initiator: initiator ? initiator : false, - //reconnectTimer: 10000, + initiator, config: { - iceServers: [ - { - urls: STUN_SERVER.split(","), - }, - TURN_SERVER !== "" - ? { - urls: TURN_SERVER.split(","), - username: user.webRtcUser || TURN_USER, - credential: user.webRtcPassword || TURN_PASSWORD, - } - : undefined, - ].filter((value) => value !== undefined), + iceServers: getIceServersConfig(user), }, }); diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index affcacd7..e30f1b1f 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -11,10 +11,11 @@ import { get } from "svelte/store"; import { localStreamStore, LocalStreamStoreValue, obtainedMediaConstraintStore } from "../Stores/MediaStore"; import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore"; import { discussionManager } from "./DiscussionManager"; +import { playersStore } from "../Stores/PlayersStore"; +import { newChatMessageStore } from "../Stores/ChatStore"; export interface UserSimplePeerInterface { userId: number; - name?: string; initiator?: boolean; webRtcUser?: string | undefined; webRtcPassword?: string | undefined; @@ -153,32 +154,13 @@ export class SimplePeer { } } - let name = user.name; - if (!name) { - name = this.getName(user.userId); - } - - discussionManager.removeParticipant(user.userId); + const name = this.getName(user.userId); this.lastWebrtcUserName = user.webRtcUser; this.lastWebrtcPassword = user.webRtcPassword; const peer = new VideoPeer(user, user.initiator ? user.initiator : false, name, this.Connection, localStream); - //permit to send message - mediaManager.addSendMessageCallback(user.userId, (message: string) => { - peer.write( - new Buffer( - JSON.stringify({ - type: MESSAGE_TYPE_MESSAGE, - name: this.myName.toUpperCase(), - userId: this.userId, - message: message, - }) - ) - ); - }); - peer.toClose = false; // When a connection is established to a video stream, and if a screen sharing is taking place, // the user sharing screen should also initiate a connection to the remote user! @@ -191,7 +173,7 @@ export class SimplePeer { //Create a notification for first user in circle discussion if (this.PeerConnectionArray.size === 0) { - mediaManager.createNotification(user.name ?? ""); + mediaManager.createNotification(name); } this.PeerConnectionArray.set(user.userId, peer); @@ -202,12 +184,7 @@ export class SimplePeer { } private getName(userId: number): string { - const userSearch = this.Users.find((userSearch: UserSimplePeerInterface) => userSearch.userId === userId); - if (userSearch) { - return userSearch.name || ""; - } else { - return ""; - } + return playersStore.getPlayerById(userId)?.name || ""; } /** @@ -372,7 +349,8 @@ export class SimplePeer { } private receiveWebrtcScreenSharingSignal(data: WebRtcSignalReceivedMessageInterface) { - if (blackListManager.isBlackListed(data.userId)) return; + const uuid = playersStore.getPlayerById(data.userId)?.userUuid || ""; + if (blackListManager.isBlackListed(uuid)) return; console.log("receiveWebrtcScreenSharingSignal", data); const streamResult = get(screenSharingLocalStreamStore); let stream: MediaStream | null = null; @@ -473,7 +451,8 @@ export class SimplePeer { } private sendLocalScreenSharingStreamToUser(userId: number, localScreenCapture: MediaStream): void { - if (blackListManager.isBlackListed(userId)) return; + const uuid = playersStore.getPlayerById(userId)?.userUuid || ""; + if (blackListManager.isBlackListed(uuid)) return; // If a connection already exists with user (because it is already sharing a screen with us... let's use this connection) if (this.PeerScreenSharingConnectionArray.has(userId)) { this.pushScreenSharingToRemoteUser(userId, localScreenCapture); diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index 30328c75..aee3f735 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -1,13 +1,14 @@ import type * as SimplePeerNamespace from "simple-peer"; import { mediaManager } from "./MediaManager"; -import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable"; import type { RoomConnection } from "../Connexion/RoomConnection"; import { blackListManager } from "./BlackListManager"; import type { Subscription } from "rxjs"; import type { UserSimplePeerInterface } from "./SimplePeer"; -import { get, readable, Readable } from "svelte/store"; +import { get, readable, Readable, Unsubscriber } from "svelte/store"; import { obtainedMediaConstraintStore } from "../Stores/MediaStore"; -import { discussionManager } from "./DiscussionManager"; +import { playersStore } from "../Stores/PlayersStore"; +import { chatMessagesStore, chatVisibilityStore, newChatMessageStore } from "../Stores/ChatStore"; +import { getIceServersConfig } from "../Components/Video/utils"; const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); @@ -26,12 +27,15 @@ export class VideoPeer extends Peer { private remoteStream!: MediaStream; private blocked: boolean = false; public readonly userId: number; + public readonly userUuid: string; public readonly uniqueId: string; private onBlockSubscribe: Subscription; private onUnBlockSubscribe: Subscription; public readonly streamStore: Readable; public readonly statusStore: Readable; public readonly constraintsStore: Readable; + private newMessageunsubscriber: Unsubscriber | null = null; + private closing: Boolean = false; //this is used to prevent destroy() from being called twice constructor( public user: UserSimplePeerInterface, @@ -41,25 +45,14 @@ export class VideoPeer extends Peer { localStream: MediaStream | null ) { super({ - initiator: initiator ? initiator : false, - //reconnectTimer: 10000, + initiator, config: { - iceServers: [ - { - urls: STUN_SERVER.split(","), - }, - TURN_SERVER !== "" - ? { - urls: TURN_SERVER.split(","), - username: user.webRtcUser || TURN_USER, - credential: user.webRtcPassword || TURN_PASSWORD, - } - : undefined, - ].filter((value) => value !== undefined), + iceServers: getIceServersConfig(user), }, }); this.userId = user.userId; + this.userUuid = playersStore.getPlayerById(this.userId)?.userUuid || ""; this.uniqueId = "video_" + this.userId; this.streamStore = readable(null, (set) => { @@ -144,6 +137,20 @@ export class VideoPeer extends Peer { this.on("connect", () => { this._connected = true; + chatMessagesStore.addIncomingUser(this.userId); + + this.newMessageunsubscriber = newChatMessageStore.subscribe((newMessage) => { + if (!newMessage) return; + this.write( + new Buffer( + JSON.stringify({ + type: MESSAGE_TYPE_MESSAGE, + message: newMessage, + }) + ) + ); //send more data + newChatMessageStore.set(null); //This is to prevent a newly created SimplePeer to send an old message a 2nd time. Is there a better way? + }); }); this.on("data", (chunk: Buffer) => { @@ -161,8 +168,8 @@ export class VideoPeer extends Peer { mediaManager.disabledVideoByUserId(this.userId); } } else if (message.type === MESSAGE_TYPE_MESSAGE) { - if (!blackListManager.isBlackListed(message.userId)) { - mediaManager.addNewMessage(message.name, message.message); + if (!blackListManager.isBlackListed(this.userUuid)) { + chatMessagesStore.addExternalMessage(this.userId, message.message); } } else if (message.type === MESSAGE_TYPE_BLOCKED) { //FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream. @@ -181,20 +188,20 @@ export class VideoPeer extends Peer { }); this.pushVideoToRemoteUser(localStream); - this.onBlockSubscribe = blackListManager.onBlockStream.subscribe((userId) => { - if (userId === this.userId) { + this.onBlockSubscribe = blackListManager.onBlockStream.subscribe((userUuid) => { + if (userUuid === this.userUuid) { this.toggleRemoteStream(false); this.sendBlockMessage(true); } }); - this.onUnBlockSubscribe = blackListManager.onUnBlockStream.subscribe((userId) => { - if (userId === this.userId) { + this.onUnBlockSubscribe = blackListManager.onUnBlockStream.subscribe((userUuid) => { + if (userUuid === this.userUuid) { this.toggleRemoteStream(true); this.sendBlockMessage(false); } }); - if (blackListManager.isBlackListed(this.userId)) { + if (blackListManager.isBlackListed(this.userUuid)) { this.sendBlockMessage(true); } } @@ -231,7 +238,7 @@ export class VideoPeer extends Peer { private stream(stream: MediaStream) { try { this.remoteStream = stream; - if (blackListManager.isBlackListed(this.userId) || this.blocked) { + if (blackListManager.isBlackListed(this.userUuid) || this.blocked) { this.toggleRemoteStream(false); } } catch (err) { @@ -242,18 +249,18 @@ export class VideoPeer extends Peer { /** * This is triggered twice. Once by the server, and once by a remote client disconnecting */ - public destroy(error?: Error): void { + public destroy(): void { try { this._connected = false; - if (!this.toClose) { + if (!this.toClose || this.closing) { return; } + this.closing = true; this.onBlockSubscribe.unsubscribe(); this.onUnBlockSubscribe.unsubscribe(); - discussionManager.removeParticipant(this.userId); - // FIXME: I don't understand why "Closing connection with" message is displayed TWICE before "Nb users in peerConnectionArray" - // I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel. - super.destroy(error); + if (this.newMessageunsubscriber) this.newMessageunsubscriber(); + chatMessagesStore.addOutcomingUser(this.userId); + super.destroy(); } catch (err) { console.error("VideoPeer::destroy", err); } diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index 1cd94d13..dcd10fdc 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -13,12 +13,26 @@ import nav from "./Api/iframe/nav"; import controls from "./Api/iframe/controls"; import ui from "./Api/iframe/ui"; import sound from "./Api/iframe/sound"; -import room from "./Api/iframe/room"; -import player from "./Api/iframe/player"; +import room, { setMapURL, setRoomId } from "./Api/iframe/room"; +import state, { initVariables } from "./Api/iframe/state"; +import player, { setPlayerName, setTags, setUuid } from "./Api/iframe/player"; import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor"; import type { Popup } from "./Api/iframe/Ui/Popup"; import type { Sound } from "./Api/iframe/Sound/Sound"; -import { answerPromises, sendToWorkadventure } from "./Api/iframe/IframeApiContribution"; +import { answerPromises, queryWorkadventure, sendToWorkadventure } from "./Api/iframe/IframeApiContribution"; + +// Notify WorkAdventure that we are ready to receive data +const initPromise = queryWorkadventure({ + type: "getState", + data: undefined, +}).then((state) => { + setPlayerName(state.nickname); + setRoomId(state.roomId); + setMapURL(state.mapUrl); + setTags(state.tags); + setUuid(state.uuid); + initVariables(state.variables as Map); +}); const wa = { ui, @@ -28,6 +42,11 @@ const wa = { sound, room, player, + state, + + onInit(): Promise { + return initPromise; + }, // All methods below are deprecated and should not be used anymore. // They are kept here for backward compatibility. @@ -125,7 +144,7 @@ const wa = { }, /** - * @deprecated Use WA.controls.restorePlayerControls instead + * @deprecated Use WA.ui.openPopup instead */ openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup { console.warn("Method WA.openPopup is deprecated. Please use WA.ui.openPopup instead"); @@ -173,9 +192,20 @@ window.addEventListener( } const payload = message.data; - console.debug(payload); + //console.debug(payload); - if (isIframeAnswerEvent(payload)) { + if (isIframeErrorAnswerEvent(payload)) { + const queryId = payload.id; + const payloadError = payload.error; + + const resolver = answerPromises.get(queryId); + if (resolver === undefined) { + throw new Error("In Iframe API, got an error answer for a question that we have no track of."); + } + resolver.reject(new Error(payloadError)); + + answerPromises.delete(queryId); + } else if (isIframeAnswerEvent(payload)) { const queryId = payload.id; const payloadData = payload.data; @@ -185,17 +215,6 @@ window.addEventListener( } resolver.resolve(payloadData); - answerPromises.delete(queryId); - } else if (isIframeErrorAnswerEvent(payload)) { - const queryId = payload.id; - const payloadError = payload.error; - - const resolver = answerPromises.get(queryId); - if (resolver === undefined) { - throw new Error("In Iframe API, got an error answer for a question that we have no track of."); - } - resolver.reject(payloadError); - answerPromises.delete(queryId); } else if (isIframeResponseEventWrapper(payload)) { const payloadData = payload.data; diff --git a/front/src/index.ts b/front/src/index.ts index 59e748b4..da243bde 100644 --- a/front/src/index.ts +++ b/front/src/index.ts @@ -1,35 +1,34 @@ -import 'phaser'; +import "phaser"; import GameConfig = Phaser.Types.Core.GameConfig; import "../style/index.scss"; -import {DEBUG_MODE, isMobile} from "./Enum/EnvironmentVariable"; -import {LoginScene} from "./Phaser/Login/LoginScene"; -import {ReconnectingScene} from "./Phaser/Reconnecting/ReconnectingScene"; -import {SelectCharacterScene} from "./Phaser/Login/SelectCharacterScene"; -import {SelectCompanionScene} from "./Phaser/Login/SelectCompanionScene"; -import {EnableCameraScene} from "./Phaser/Login/EnableCameraScene"; -import {CustomizeScene} from "./Phaser/Login/CustomizeScene"; -import WebFontLoaderPlugin from 'phaser3-rex-plugins/plugins/webfontloader-plugin.js'; -import OutlinePipelinePlugin from 'phaser3-rex-plugins/plugins/outlinepipeline-plugin.js'; -import {EntryScene} from "./Phaser/Login/EntryScene"; -import {coWebsiteManager} from "./WebRtc/CoWebsiteManager"; -import {MenuScene} from "./Phaser/Menu/MenuScene"; -import {localUserStore} from "./Connexion/LocalUserStore"; -import {ErrorScene} from "./Phaser/Reconnecting/ErrorScene"; -import {iframeListener} from "./Api/IframeListener"; -import { SelectCharacterMobileScene } from './Phaser/Login/SelectCharacterMobileScene'; -import {HdpiManager} from "./Phaser/Services/HdpiManager"; -import {waScaleManager} from "./Phaser/Services/WaScaleManager"; -import {Game} from "./Phaser/Game/Game"; -import App from './Components/App.svelte'; -import {HtmlUtils} from "./WebRtc/HtmlUtils"; +import { DEBUG_MODE, isMobile } from "./Enum/EnvironmentVariable"; +import { LoginScene } from "./Phaser/Login/LoginScene"; +import { ReconnectingScene } from "./Phaser/Reconnecting/ReconnectingScene"; +import { SelectCharacterScene } from "./Phaser/Login/SelectCharacterScene"; +import { SelectCompanionScene } from "./Phaser/Login/SelectCompanionScene"; +import { EnableCameraScene } from "./Phaser/Login/EnableCameraScene"; +import { CustomizeScene } from "./Phaser/Login/CustomizeScene"; +import WebFontLoaderPlugin from "phaser3-rex-plugins/plugins/webfontloader-plugin.js"; +import OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js"; +import { EntryScene } from "./Phaser/Login/EntryScene"; +import { coWebsiteManager } from "./WebRtc/CoWebsiteManager"; +import { MenuScene } from "./Phaser/Menu/MenuScene"; +import { localUserStore } from "./Connexion/LocalUserStore"; +import { ErrorScene } from "./Phaser/Reconnecting/ErrorScene"; +import { iframeListener } from "./Api/IframeListener"; +import { SelectCharacterMobileScene } from "./Phaser/Login/SelectCharacterMobileScene"; +import { HdpiManager } from "./Phaser/Services/HdpiManager"; +import { waScaleManager } from "./Phaser/Services/WaScaleManager"; +import { Game } from "./Phaser/Game/Game"; +import App from "./Components/App.svelte"; +import { HtmlUtils } from "./WebRtc/HtmlUtils"; import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer; - -const {width, height} = coWebsiteManager.getGameSize(); +const { width, height } = coWebsiteManager.getGameSize(); const valueGameQuality = localUserStore.getGameQualityValue(); -const fps : Phaser.Types.Core.FPSConfig = { +const fps: Phaser.Types.Core.FPSConfig = { /** * The minimum acceptable rendering rate, in frames per second. */ @@ -53,30 +52,30 @@ const fps : Phaser.Types.Core.FPSConfig = { /** * Apply delta smoothing during the game update to help avoid spikes? */ - smoothStep: false -} + smoothStep: false, +}; // the ?phaserMode=canvas parameter can be used to force Canvas usage const params = new URLSearchParams(document.location.search.substring(1)); const phaserMode = params.get("phaserMode"); let mode: number; switch (phaserMode) { - case 'auto': + case "auto": case null: mode = Phaser.AUTO; break; - case 'canvas': + case "canvas": mode = Phaser.CANVAS; break; - case 'webgl': + case "webgl": mode = Phaser.WEBGL; break; default: throw new Error('phaserMode parameter must be one of "auto", "canvas" or "webgl"'); } -const hdpiManager = new HdpiManager(640*480, 196*196); -const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({width, height}); +const hdpiManager = new HdpiManager(640 * 480, 196 * 196); +const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({ width, height }); const config: GameConfig = { type: mode, @@ -87,9 +86,10 @@ const config: GameConfig = { height: gameSize.height, zoom: realSize.width / gameSize.width, autoRound: true, - resizeInterval: 999999999999 + resizeInterval: 999999999999, }, - scene: [EntryScene, + scene: [ + EntryScene, LoginScene, isMobile() ? SelectCharacterMobileScene : SelectCharacterScene, SelectCompanionScene, @@ -102,37 +102,39 @@ const config: GameConfig = { //resolution: window.devicePixelRatio / 2, fps: fps, dom: { - createContainer: true + createContainer: true, }, render: { pixelArt: true, roundPixels: true, - antialias: false + antialias: false, }, plugins: { - global: [{ - key: 'rexWebFontLoader', - plugin: WebFontLoaderPlugin, - start: true - }] + global: [ + { + key: "rexWebFontLoader", + plugin: WebFontLoaderPlugin, + start: true, + }, + ], }, physics: { default: "arcade", arcade: { debug: DEBUG_MODE, - } + }, }, // Instruct systems with 2 GPU to choose the low power one. We don't need that extra power and we want to save battery powerPreference: "low-power", callbacks: { - postBoot: game => { + postBoot: (game) => { // Install rexOutlinePipeline only if the renderer is WebGL. const renderer = game.renderer; if (renderer instanceof WebGLRenderer) { - game.plugins.install('rexOutlinePipeline', OutlinePipelinePlugin, true); + game.plugins.install("rexOutlinePipeline", OutlinePipelinePlugin, true); } - } - } + }, + }, }; //const game = new Phaser.Game(config); @@ -140,7 +142,7 @@ const game = new Game(config); waScaleManager.setGame(game); -window.addEventListener('resize', function (event) { +window.addEventListener("resize", function (event) { coWebsiteManager.resetStyle(); waScaleManager.applyNewSize(); @@ -153,10 +155,23 @@ coWebsiteManager.onResize.subscribe(() => { iframeListener.init(); const app = new App({ - target: HtmlUtils.getElementByIdOrFail('svelte-overlay'), + target: HtmlUtils.getElementByIdOrFail("svelte-overlay"), props: { - game: game + game: game, }, -}) +}); -export default app +export default app; + +if ("serviceWorker" in navigator) { + window.addEventListener("load", function () { + navigator.serviceWorker + .register("/resources/service-worker.js") + .then((serviceWorker) => { + console.log("Service Worker registered: ", serviceWorker); + }) + .catch((error) => { + console.error("Error registering the Service Worker: ", error); + }); + }); +} diff --git a/front/style/fonts.scss b/front/style/fonts.scss index a49d3967..526f6615 100644 --- a/front/style/fonts.scss +++ b/front/style/fonts.scss @@ -1,9 +1,5 @@ @import "~@fontsource/press-start-2p/index.css"; -*{ - font-family: PixelFont-7,monospace; -} - .nes-btn { font-family: "Press Start 2P"; } diff --git a/front/style/style.scss b/front/style/style.scss index eb34287a..24da5a96 100644 --- a/front/style/style.scss +++ b/front/style/style.scss @@ -1,5 +1,5 @@ *{ - font-family: 'Open Sans', sans-serif; + font-family: Lato; cursor: url('./images/cursor_normal.png'), auto; } * a, button, select{ diff --git a/front/tests/Phaser/Game/RoomTest.ts b/front/tests/Phaser/Game/RoomTest.ts deleted file mode 100644 index 3a5ccb52..00000000 --- a/front/tests/Phaser/Game/RoomTest.ts +++ /dev/null @@ -1,89 +0,0 @@ -import "jasmine"; -import { Room } from "../../../src/Connexion/Room"; - -describe("Room getIdFromIdentifier()", () => { - it("should work with an absolute room id and no hash as parameter", () => { - const { roomId, hash } = Room.getIdFromIdentifier("/_/global/maps.workadventu.re/test2.json", "", ""); - expect(roomId).toEqual("_/global/maps.workadventu.re/test2.json"); - expect(hash).toEqual(null); - }); - it("should work with an absolute room id and a hash as parameters", () => { - const { roomId, hash } = Room.getIdFromIdentifier("/_/global/maps.workadventu.re/test2.json#start", "", ""); - expect(roomId).toEqual("_/global/maps.workadventu.re/test2.json"); - expect(hash).toEqual("start"); - }); - it("should work with an absolute room id, regardless of baseUrl or instance", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "/_/global/maps.workadventu.re/test2.json", - "https://another.domain/_/global/test.json", - "lol" - ); - expect(roomId).toEqual("_/global/maps.workadventu.re/test2.json"); - expect(hash).toEqual(null); - }); - - it("should work with a relative file link and no hash as parameters", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "./test2.json", - "https://maps.workadventu.re/test.json", - "global" - ); - expect(roomId).toEqual("_/global/maps.workadventu.re/test2.json"); - expect(hash).toEqual(null); - }); - it("should work with a relative file link with no dot", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "test2.json", - "https://maps.workadventu.re/test.json", - "global" - ); - expect(roomId).toEqual("_/global/maps.workadventu.re/test2.json"); - expect(hash).toEqual(null); - }); - it("should work with a relative file link two levels deep", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "../floor1/Floor1.json", - "https://maps.workadventu.re/floor0/Floor0.json", - "global" - ); - expect(roomId).toEqual("_/global/maps.workadventu.re/floor1/Floor1.json"); - expect(hash).toEqual(null); - }); - it("should work with a relative file link that rewrite the map domain", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "../../maps.workadventure.localhost/Floor1/floor1.json", - "https://maps.workadventu.re/floor0/Floor0.json", - "global" - ); - expect(roomId).toEqual("_/global/maps.workadventure.localhost/Floor1/floor1.json"); - expect(hash).toEqual(null); - }); - it("should work with a relative file link that rewrite the map instance", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "../../../notglobal/maps.workadventu.re/Floor1/floor1.json", - "https://maps.workadventu.re/floor0/Floor0.json", - "global" - ); - expect(roomId).toEqual("_/notglobal/maps.workadventu.re/Floor1/floor1.json"); - expect(hash).toEqual(null); - }); - it("should work with a relative file link that change the map type", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "../../../../@/tcm/is/great", - "https://maps.workadventu.re/floor0/Floor0.json", - "global" - ); - expect(roomId).toEqual("@/tcm/is/great"); - expect(hash).toEqual(null); - }); - - it("should work with a relative file link and a hash as parameters", () => { - const { roomId, hash } = Room.getIdFromIdentifier( - "./test2.json#start", - "https://maps.workadventu.re/test.json", - "global" - ); - expect(roomId).toEqual("_/global/maps.workadventu.re/test2.json"); - expect(hash).toEqual("start"); - }); -}); diff --git a/front/webpack.config.ts b/front/webpack.config.ts index b6efb389..37362baf 100644 --- a/front/webpack.config.ts +++ b/front/webpack.config.ts @@ -7,7 +7,6 @@ import MiniCssExtractPlugin from "mini-css-extract-plugin"; import sveltePreprocess from "svelte-preprocess"; import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; import NodePolyfillPlugin from "node-polyfill-webpack-plugin"; -import { DISPLAY_TERMS_OF_USE } from "./src/Enum/EnvironmentVariable"; const mode = process.env.NODE_ENV ?? "development"; const buildNpmTypingsForApi = !!process.env.BUILD_TYPINGS; diff --git a/front/yarn.lock b/front/yarn.lock index fec87661..6ee607d3 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -262,10 +262,10 @@ "@types/mime" "^1" "@types/node" "*" -"@types/simple-peer@^9.6.0": - version "9.6.3" - resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.6.3.tgz#aa118a57e036f4ce2059a7e25367526a4764206d" - integrity sha512-zrXEBch9tF4NgkZDsGR3c1D0kq99M1bBCjzEyL0PVfEWzCIXrK64TuxRz3XKOx1B0KoEQ9kTs+AhMDuQaHy5RQ== +"@types/simple-peer@^9.11.1": + version "9.11.1" + resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.11.1.tgz#bef6ff1e75178d83438e33aa6a4df2fd98fded1d" + integrity sha512-Pzqbau/WlivSXdRC0He2Wz/ANj2wbi4gzJrtysZz93jvOyI2jo/ibMjUe6AvPllFl/UO6QXT/A0Rcp44bDQB5A== dependencies: "@types/node" "*" @@ -5008,7 +5008,7 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== -simple-peer@^9.6.2: +simple-peer@^9.11.0: version "9.11.0" resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571" integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg== diff --git a/maps/Floor1/floor1.json b/maps/Floor1/floor1.json index 1894ed42..fec5937e 100644 --- a/maps/Floor1/floor1.json +++ b/maps/Floor1/floor1.json @@ -1,11 +1,4 @@ { "compressionlevel":-1, - "editorsettings": - { - "export": - { - "target":"." - } - }, "height":26, "infinite":false, "layers":[ @@ -101,7 +94,7 @@ "opacity":1, "properties":[ { - "name":"exitSceneUrl", + "name":"exitUrl", "type":"string", "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs" }], @@ -119,7 +112,7 @@ "opacity":1, "properties":[ { - "name":"exitSceneUrl", + "name":"exitUrl", "type":"string", "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours" }], @@ -264,7 +257,7 @@ "nextobjectid":1, "orientation":"orthogonal", "renderorder":"right-down", - "tiledversion":"1.3.3", + "tiledversion":"2021.03.23", "tileheight":32, "tilesets":[ { @@ -1959,6 +1952,6 @@ }], "tilewidth":32, "type":"map", - "version":1.2, + "version":1.5, "width":46 } \ No newline at end of file diff --git a/maps/Tuto/Attribution-tilesets.txt b/maps/Tuto/Attribution-tilesets.txt index a0e4224a..7139391e 100644 --- a/maps/Tuto/Attribution-tilesets.txt +++ b/maps/Tuto/Attribution-tilesets.txt @@ -8,7 +8,7 @@ GNU GPL 3.0: - http://www.gnu.org/licenses/gpl-3.0.html - See the file: gpl-3.0.txt -Assets from: workadventure@thecodingmachine.com +Assets from: hello@workadventu.re BASE assets: ------------ diff --git a/maps/tests/Attribution-tilesets.txt b/maps/tests/Attribution-tilesets.txt index a0e4224a..7139391e 100644 --- a/maps/tests/Attribution-tilesets.txt +++ b/maps/tests/Attribution-tilesets.txt @@ -8,7 +8,7 @@ GNU GPL 3.0: - http://www.gnu.org/licenses/gpl-3.0.html - See the file: gpl-3.0.txt -Assets from: workadventure@thecodingmachine.com +Assets from: hello@workadventu.re BASE assets: ------------ diff --git a/maps/tests/Metadata/customMenu.html b/maps/tests/Metadata/customMenu.html index a80dca08..404673f3 100644 --- a/maps/tests/Metadata/customMenu.html +++ b/maps/tests/Metadata/customMenu.html @@ -1,13 +1,20 @@ - - - - + + +

Add a custom menu

\ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentRoom.html b/maps/tests/Metadata/getCurrentRoom.html deleted file mode 100644 index 7429b2a8..00000000 --- a/maps/tests/Metadata/getCurrentRoom.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentRoom.js b/maps/tests/Metadata/getCurrentRoom.js new file mode 100644 index 00000000..df3a995c --- /dev/null +++ b/maps/tests/Metadata/getCurrentRoom.js @@ -0,0 +1,11 @@ +WA.onInit().then(() => { + console.log('id: ', WA.room.id); + console.log('Map URL: ', WA.room.mapURL); + console.log('Player name: ', WA.player.name); + console.log('Player id: ', WA.player.id); + console.log('Player tags: ', WA.player.tags); +}); + +WA.room.getTiledMap().then((data) => { + console.log('Map data', data); +}) diff --git a/maps/tests/Metadata/getCurrentRoom.json b/maps/tests/Metadata/getCurrentRoom.json index c14bb946..05591521 100644 --- a/maps/tests/Metadata/getCurrentRoom.json +++ b/maps/tests/Metadata/getCurrentRoom.json @@ -1,11 +1,4 @@ { "compressionlevel":-1, - "editorsettings": - { - "export": - { - "target":"." - } - }, "height":10, "infinite":false, "layers":[ @@ -51,29 +44,6 @@ "x":0, "y":0 }, - { - "data":[0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":4, - "name":"metadata", - "opacity":1, - "properties":[ - { - "name":"openWebsite", - "type":"string", - "value":"getCurrentRoom.html" - }, - { - "name":"openWebsiteAllowApi", - "type":"bool", - "value":true - }], - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, { "draworder":"topdown", "id":5, @@ -88,7 +58,7 @@ { "fontfamily":"Sans Serif", "pixelsize":9, - "text":"Test : \nWalk on the grass and open the console.\n\nResult : \nYou should see a console.log() of the following attributes : \n\t- id : ID of the current room\n\t- map : data of the JSON file of the map\n\t- mapUrl : url of the JSON file of the map\n\t- startLayer : Name of the layer where the current user started (HereYouAppered)\n\n\n", + "text":"Test : \nOpen the console.\n\nResult : \nYou should see a console.log() of the following attributes : \n\t- id : ID of the current room\n\t- mapUrl : url of the JSON file of the map\n\t- Player name\n - Player ID\n - Player tags\n\nAnd also:\n\t- map : data of the JSON file of the map\n\n", "wrap":true }, "type":"", @@ -106,8 +76,14 @@ "nextlayerid":11, "nextobjectid":2, "orientation":"orthogonal", + "properties":[ + { + "name":"script", + "type":"string", + "value":"getCurrentRoom.js" + }], "renderorder":"right-down", - "tiledversion":"1.4.3", + "tiledversion":"2021.03.23", "tileheight":32, "tilesets":[ { @@ -274,6 +250,6 @@ }], "tilewidth":32, "type":"map", - "version":1.4, + "version":1.5, "width":10 } \ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentUser.html b/maps/tests/Metadata/getCurrentUser.html deleted file mode 100644 index 02be24f7..00000000 --- a/maps/tests/Metadata/getCurrentUser.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentUser.json b/maps/tests/Metadata/getCurrentUser.json deleted file mode 100644 index 9efd0d09..00000000 --- a/maps/tests/Metadata/getCurrentUser.json +++ /dev/null @@ -1,296 +0,0 @@ -{ "compressionlevel":-1, - "editorsettings": - { - "export": - { - "target":"." - } - }, - "height":10, - "infinite":false, - "layers":[ - { - "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":1, - "name":"start", - "opacity":1, - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "data":[33, 34, 34, 34, 34, 34, 34, 34, 34, 35, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 49, 50, 50, 50, 50, 50, 50, 50, 50, 51], - "height":10, - "id":2, - "name":"bottom", - "opacity":1, - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":9, - "name":"exit", - "opacity":1, - "properties":[ - { - "name":"exitUrl", - "type":"string", - "value":"getCurrentRoom.json#HereYouAppered" - }], - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "data":[0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - "height":10, - "id":4, - "name":"metadata", - "opacity":1, - "properties":[ - { - "name":"openWebsite", - "type":"string", - "value":"getCurrentUser.html" - }, - { - "name":"openWebsiteAllowApi", - "type":"bool", - "value":true - }], - "type":"tilelayer", - "visible":true, - "width":10, - "x":0, - "y":0 - }, - { - "draworder":"topdown", - "id":5, - "name":"floorLayer", - "objects":[ - { - "height":151.839293303871, - "id":1, - "name":"", - "rotation":0, - "text": - { - "fontfamily":"Sans Serif", - "pixelsize":9, - "text":"Test : \nWalk on the grass, open the console.\n\nResut : \nYou should see a console.log() of the following attributes :\n\t- id : ID of the current user\n\t- nickName : Name of the current user\n\t- tags : List of tags of the current user\n\nFinally : \nWalk on the red tile and continue the test in an another room.", - "wrap":true - }, - "type":"", - "visible":true, - "width":305.097705765524, - "x":14.750638909983, - "y":159.621625296353 - }], - "opacity":1, - "type":"objectgroup", - "visible":true, - "x":0, - "y":0 - }], - "nextlayerid":10, - "nextobjectid":2, - "orientation":"orthogonal", - "renderorder":"right-down", - "tiledversion":"1.4.3", - "tileheight":32, - "tilesets":[ - { - "columns":8, - "firstgid":1, - "image":"tileset_dungeon.png", - "imageheight":256, - "imagewidth":256, - "margin":0, - "name":"TDungeon", - "spacing":0, - "tilecount":64, - "tileheight":32, - "tiles":[ - { - "id":0, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":1, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":2, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":3, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":4, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":8, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":9, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":10, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":11, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":12, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":16, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":17, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":18, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":19, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }, - { - "id":20, - "properties":[ - { - "name":"collides", - "type":"bool", - "value":true - }] - }], - "tilewidth":32 - }, - { - "columns":8, - "firstgid":65, - "image":"floortileset.png", - "imageheight":288, - "imagewidth":256, - "margin":0, - "name":"Floor", - "spacing":0, - "tilecount":72, - "tileheight":32, - "tiles":[ - { - "animation":[ - { - "duration":100, - "tileid":9 - }, - { - "duration":100, - "tileid":64 - }, - { - "duration":100, - "tileid":55 - }], - "id":0 - }], - "tilewidth":32 - }], - "tilewidth":32, - "type":"map", - "version":1.4, - "width":10 -} \ No newline at end of file diff --git a/maps/tests/Metadata/playerMove.html b/maps/tests/Metadata/playerMove.html index 339a3fd2..46a36845 100644 --- a/maps/tests/Metadata/playerMove.html +++ b/maps/tests/Metadata/playerMove.html @@ -1,12 +1,18 @@ - + -
- +

Log in the console the movement of the current player in the zone of the iframe

\ No newline at end of file diff --git a/maps/tests/Metadata/setProperty.html b/maps/tests/Metadata/setProperty.html index 5259ec0a..c61aa5fa 100644 --- a/maps/tests/Metadata/setProperty.html +++ b/maps/tests/Metadata/setProperty.html @@ -1,12 +1,19 @@ - + - +

Change the url of this iframe and add the 'openWebsite' property to the red tile layer

\ No newline at end of file diff --git a/maps/tests/Metadata/showHideLayer.html b/maps/tests/Metadata/showHideLayer.html index 4677f9e5..c6103722 100644 --- a/maps/tests/Metadata/showHideLayer.html +++ b/maps/tests/Metadata/showHideLayer.html @@ -1,21 +1,27 @@ - +
- \ No newline at end of file diff --git a/maps/tests/Variables/script.js b/maps/tests/Variables/script.js new file mode 100644 index 00000000..1ab1b2e5 --- /dev/null +++ b/maps/tests/Variables/script.js @@ -0,0 +1,33 @@ +WA.onInit().then(() => { + console.log('Trying to read variable "doorOpened" whose default property is true. This should display "true".'); + console.log('doorOpened', WA.state.loadVariable('doorOpened')); + + console.log('Trying to set variable "not_exists". This should display an error in the console, followed by a log saying the error was caught.') + WA.state.saveVariable('not_exists', 'foo').catch((e) => { + console.log('Successfully caught error: ', e); + }); + + console.log('Trying to set variable "myvar". This should work.'); + WA.state.saveVariable('myvar', {'foo': 'bar'}); + + console.log('Trying to read variable "myvar". This should display a {"foo": "bar"} object.'); + console.log(WA.state.loadVariable('myvar')); + + console.log('Trying to set variable "myvar" using proxy. This should work.'); + WA.state.myvar = {'baz': 42}; + + console.log('Trying to read variable "myvar" using proxy. This should display a {"baz": 42} object.'); + console.log(WA.state.myvar); + + console.log('Trying to set variable "config". This should not work because we are not logged as admin.'); + WA.state.saveVariable('config', {'foo': 'bar'}).catch(e => { + console.log('Successfully caught error because variable "config" is not writable: ', e); + }); + + console.log('Trying to read variable "readableByAdmin" that can only be read by "admin". We are not admin so we should not get the default value.'); + if (WA.state.readableByAdmin === true) { + console.error('Failed test: readableByAdmin can be read.'); + } else { + console.log('Success test: readableByAdmin was not read.'); + } +}); diff --git a/maps/tests/Variables/shared_variables.html b/maps/tests/Variables/shared_variables.html new file mode 100644 index 00000000..21e0b998 --- /dev/null +++ b/maps/tests/Variables/shared_variables.html @@ -0,0 +1,48 @@ + + + + + + + + + + + +
+ + diff --git a/maps/tests/Variables/shared_variables.json b/maps/tests/Variables/shared_variables.json new file mode 100644 index 00000000..2de5e4c0 --- /dev/null +++ b/maps/tests/Variables/shared_variables.json @@ -0,0 +1,131 @@ +{ "compressionlevel":-1, + "height":10, + "infinite":false, + "layers":[ + { + "data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + "height":10, + "id":1, + "name":"floor", + "opacity":1, + "properties":[ + { + "name":"openWebsite", + "type":"string", + "value":"shared_variables.html" + }, + { + "name":"openWebsiteAllowApi", + "type":"bool", + "value":true + }], + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":10, + "id":2, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":3, + "name":"floorLayer", + "objects":[ + { + "height":67, + "id":3, + "name":"", + "rotation":0, + "text": + { + "fontfamily":"Sans Serif", + "pixelsize":11, + "text":"Test:\nChange the form\nConnect with another user\n\nResult:\nThe form should open in the same state for the other user\nAlso, a change on one user is directly propagated to the other user", + "wrap":true + }, + "type":"", + "visible":true, + "width":252.4375, + "x":2.78125, + "y":2.5 + }, + { + "height":0, + "id":5, + "name":"textField", + "point":true, + "properties":[ + { + "name":"default", + "type":"string", + "value":"default value" + }, + { + "name":"jsonSchema", + "type":"string", + "value":"{}" + }, + { + "name":"persist", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"" + }, + { + "name":"writableBy", + "type":"string", + "value":"" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":57.5, + "y":111 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":8, + "nextobjectid":10, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"2021.03.23", + "tileheight":32, + "tilesets":[ + { + "columns":11, + "firstgid":1, + "image":"..\/tileset1.png", + "imageheight":352, + "imagewidth":352, + "margin":0, + "name":"tileset1", + "spacing":0, + "tilecount":121, + "tileheight":32, + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":1.5, + "width":10 +} \ No newline at end of file diff --git a/maps/tests/Variables/variables.json b/maps/tests/Variables/variables.json new file mode 100644 index 00000000..b0f5b5b0 --- /dev/null +++ b/maps/tests/Variables/variables.json @@ -0,0 +1,205 @@ +{ "compressionlevel":-1, + "height":10, + "infinite":false, + "layers":[ + { + "data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + "height":10, + "id":1, + "name":"floor", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":10, + "id":2, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":3, + "name":"floorLayer", + "objects":[ + { + "height":67, + "id":3, + "name":"", + "rotation":0, + "text": + { + "fontfamily":"Sans Serif", + "pixelsize":11, + "text":"Test:\nOpen your console\n\nResult:\nYou should see a list of tests performed and results associated.", + "wrap":true + }, + "type":"", + "visible":true, + "width":252.4375, + "x":2.78125, + "y":2.5 + }, + { + "height":0, + "id":5, + "name":"config", + "point":true, + "properties":[ + { + "name":"default", + "type":"string", + "value":"{}" + }, + { + "name":"jsonSchema", + "type":"string", + "value":"{}" + }, + { + "name":"persist", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"" + }, + { + "name":"writableBy", + "type":"string", + "value":"admin" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":57.5, + "y":111 + }, + { + "height":0, + "id":6, + "name":"doorOpened", + "point":true, + "properties":[ + { + "name":"default", + "type":"bool", + "value":true + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":131.38069962269, + "y":106.004988169086 + }, + { + "height":0, + "id":9, + "name":"myvar", + "point":true, + "properties":[ + { + "name":"default", + "type":"string", + "value":"{}" + }, + { + "name":"jsonSchema", + "type":"string", + "value":"{}" + }, + { + "name":"persist", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"" + }, + { + "name":"writableBy", + "type":"string", + "value":"" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":88.8149900876127, + "y":147.75212636695 + }, + { + "height":0, + "id":10, + "name":"readableByAdmin", + "point":true, + "properties":[ + { + "name":"default", + "type":"bool", + "value":true + }, + { + "name":"readableBy", + "type":"string", + "value":"admin" + }], + "rotation":0, + "type":"variable", + "visible":true, + "width":0, + "x":182.132122529897, + "y":157.984268082113 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":8, + "nextobjectid":11, + "orientation":"orthogonal", + "properties":[ + { + "name":"script", + "type":"string", + "value":"script.js" + }], + "renderorder":"right-down", + "tiledversion":"2021.03.23", + "tileheight":32, + "tilesets":[ + { + "columns":11, + "firstgid":1, + "image":"..\/tileset1.png", + "imageheight":352, + "imagewidth":352, + "margin":0, + "name":"tileset1", + "spacing":0, + "tilecount":121, + "tileheight":32, + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":1.5, + "width":10 +} \ No newline at end of file diff --git a/maps/tests/index.html b/maps/tests/index.html index 38ee51ef..fbff09e5 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -122,20 +122,12 @@
Testing add a custom menu by scripting API - - - Success Failure Pending - - - Testing return current room attributes by Scripting API (Need to test from current user) - - Success Failure Pending - Testing return current user attributes by Scripting API + Testing return current player attributes in Scripting API + WA.onInit @@ -186,14 +178,6 @@ Test start tile (S2) - - - Success Failure Pending - - - Test cowebsite opened by script is allowed to use IFrame API - - Success Failure Pending @@ -202,6 +186,22 @@ Test set tiles + + + Success Failure Pending + + + Testing scripting variables locally + + + + + Success Failure Pending + + + Testing shared scripting variables + +