diff --git a/.gitignore b/.gitignore index 2cbf66b1..8fa69985 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ docker-compose.override.yaml maps/yarn.lock maps/dist/computer.js maps/dist/computer.js.map -/node_modules/ +node_modules +_ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3dd293..46034e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,23 @@ - New scripting API features : - 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.player.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.ui.registerMenuCommand(): void` to add a custom menu - - Use `WA.room.setTiles(): void` to change an array of tiles + - Use `WA.room.setTiles(): void` to add, delete or change an array of tiles +- 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/CONTRIBUTING.md b/CONTRIBUTING.md index b85d0a98..8bbbc93e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ Before committing, be sure to install the "Prettier" precommit hook that will re In order to enable the "Prettier" precommit hook, at the root of the project, run: ```console -$ yarn run install +$ yarn install $ yarn run prepare ``` 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/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 020f4c29..f2b736c6 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -5,8 +5,6 @@ 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 { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ZoneSocket } from "src/RoomManager"; @@ -15,12 +13,6 @@ import { Admin } from "../Model/Admin"; 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; @@ -37,15 +29,12 @@ export class GameRoom { private itemsState: Map = new Map(); private readonly positionNotifier: PositionNotifier; - public readonly roomId: string; - public readonly roomSlug: string; - public readonly worldSlug: string = ""; - public readonly organizationSlug: string = ""; + public readonly roomUrl: string; private versionNumber: number = 1; private nextUserId: number = 1; constructor( - roomId: string, + roomUrl: string, connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback, minDistance: number, @@ -55,16 +44,7 @@ export class GameRoom { onLeaves: LeavesCallback, onEmote: EmoteCallback ) { - this.roomId = roomId; - - 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; - } + this.roomUrl = roomUrl; this.users = new Map(); this.usersByUuid = new Map(); @@ -183,7 +163,7 @@ export class GameRoom { } else { const closestUser: User = closestItem; const group: Group = new Group( - this.roomId, + this.roomUrl, [user, closestUser], this.connectCallback, this.disconnectCallback, 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/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index e61763cd..5e45c975 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -250,12 +250,12 @@ export class SocketManager { //user leave previous world room.leave(user); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.rooms.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"); } } @@ -308,6 +308,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 +426,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 +436,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 +450,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) - //} } } @@ -614,6 +607,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())); @@ -664,9 +658,9 @@ export class SocketManager { public leaveAdminRoom(room: GameRoom, admin: Admin) { room.adminLeave(admin); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.rooms.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); - debug('Room is empty. Deleting room "%s"', room.roomId); + debug('Room is empty. Deleting room "%s"', room.roomUrl); } } 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/docs/maps/api-player.md b/docs/maps/api-player.md index f483731e..0efe2941 100644 --- a/docs/maps/api-player.md +++ b/docs/maps/api-player.md @@ -1,6 +1,7 @@ {.section-title.accent.text-primary} # API Player functions Reference + ### Listen to player movement ``` WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void; diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index 17e3d48e..8bc2b3d2 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,14 @@ 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 setted 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 - -Example : -```javascript -WA.room.getCurrentRoom((room) => { - if (room.id === '42') { - console.log(room.map); - window.open(room.mapUrl, '_blank'); - } -}) -``` - -### 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 - -Example : -```javascript -WA.room.getCurrentUser().then((user) => { - if (user.nickName === 'ABC') { - console.log(user.tags); - } -}) -``` - ### Changing tiles ``` WA.room.setTiles(tiles: TileDescriptor[]): void @@ -134,6 +100,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/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 3ea27990..00000000 Binary files a/front/dist/resources/logos/tcm_full.png and /dev/null differ diff --git a/front/dist/resources/logos/tcm_short.png b/front/dist/resources/logos/tcm_short.png deleted file mode 100644 index ed55c836..00000000 Binary files a/front/dist/resources/logos/tcm_short.png and /dev/null differ 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 00000000..86cb7477 Binary files /dev/null and b/front/dist/static/images/favicons/icon-512x512.png differ diff --git a/front/dist/static/images/favicons/manifest.json b/front/dist/static/images/favicons/manifest.json index 47ad9377..30d08769 100644 --- a/front/dist/static/images/favicons/manifest.json +++ b/front/dist/static/images/favicons/manifest.json @@ -119,7 +119,13 @@ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192", "type": "image\/png", - "density": "4.0" + "density": "4.0", + "purpose": "any maskable" + }, + { + "src": "/static/images/favicons/icon-512x512.png", + "sizes": "512x512", + "type": "image\/png" } ], "start_url": "/", @@ -127,6 +133,7 @@ "display_override": ["window-control-overlay", "minimal-ui"], "display": "standalone", "scope": "/", + "lang": "en", "theme_color": "#000000", "shortcuts": [ { @@ -134,7 +141,7 @@ "short_name": "WA", "description": "WorkAdventure application", "url": "/", - "icons": [{ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192" }] + "icons": [{ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192", "type": "image/png" }] } ], "description": "WorkAdventure application", diff --git a/front/dist/static/images/send.png b/front/dist/static/images/send.png new file mode 100644 index 00000000..1f75634a Binary files /dev/null and b/front/dist/static/images/send.png differ diff --git a/front/package.json b/front/package.json index 0e0439ed..4e4d66c9 100644 --- a/front/package.json +++ b/front/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@fontsource/press-start-2p": "^4.3.0", - "@types/simple-peer": "^9.6.0", + "@types/simple-peer": "^9.11.1", "@types/socket.io-client": "^1.4.32", "axios": "^0.21.1", "cross-env": "^7.0.3", @@ -51,7 +51,7 @@ "queue-typescript": "^1.0.1", "quill": "1.3.6", "rxjs": "^6.6.3", - "simple-peer": "^9.6.2", + "simple-peer": "^9.11.0", "socket.io-client": "^2.3.0", "standardized-audio-context": "^25.2.4" }, @@ -61,7 +61,7 @@ "serve": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" webpack serve --open", "build": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" NODE_ENV=production webpack", "build-typings": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" NODE_ENV=production BUILD_TYPINGS=1 webpack", - "test": "TS_NODE_PROJECT=\"tsconfig-for-jasmine.json\" ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json", + "test": "cross-env TS_NODE_PROJECT=\"tsconfig-for-jasmine.json\" ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json", "lint": "node_modules/.bin/eslint src/ . --ext .ts", "fix": "node_modules/.bin/eslint --fix src/ . --ext .ts", "precommit": "lint-staged", diff --git a/front/src/Api/Events/DataLayerEvent.ts b/front/src/Api/Events/DataLayerEvent.ts index 096d6ef5..3062c1bc 100644 --- a/front/src/Api/Events/DataLayerEvent.ts +++ b/front/src/Api/Events/DataLayerEvent.ts @@ -1,13 +1,12 @@ import * as tg from "generic-type-guard"; - - -export const isDataLayerEvent = - new tg.IsInterface().withProperties({ - data: tg.isObject - }).get(); +export const isDataLayerEvent = new tg.IsInterface() + .withProperties({ + data: tg.isObject, + }) + .get(); /** * 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; \ No newline at end of file +export type DataLayerEvent = tg.GuardedType; diff --git a/front/src/Api/Events/GameStateEvent.ts b/front/src/Api/Events/GameStateEvent.ts index 85fb37e9..edeeef80 100644 --- a/front/src/Api/Events/GameStateEvent.ts +++ b/front/src/Api/Events/GameStateEvent.ts @@ -1,14 +1,15 @@ import * as tg from "generic-type-guard"; -export const isGameStateEvent = - new tg.IsInterface().withProperties({ - roomId: tg.isString, - mapUrl: tg.isString, - nickname: tg.isUnion(tg.isString, tg.isNull), - uuid: tg.isUnion(tg.isString, tg.isUndefined), - startLayerName: tg.isUnion(tg.isString, tg.isNull), - tags : tg.isArray(tg.isString), - }).get(); +export const isGameStateEvent = new tg.IsInterface() + .withProperties({ + roomId: tg.isString, + mapUrl: tg.isString, + nickname: tg.isUnion(tg.isString, tg.isNull), + uuid: tg.isUnion(tg.isString, tg.isUndefined), + startLayerName: tg.isUnion(tg.isString, tg.isNull), + tags: tg.isArray(tg.isString), + }) + .get(); /** * A message sent from the game to the iFrame when the gameState is received by the script */ diff --git a/front/src/Api/Events/HasPlayerMovedEvent.ts b/front/src/Api/Events/HasPlayerMovedEvent.ts index 50f017df..87b45482 100644 --- a/front/src/Api/Events/HasPlayerMovedEvent.ts +++ b/front/src/Api/Events/HasPlayerMovedEvent.ts @@ -1,19 +1,17 @@ import * as tg from "generic-type-guard"; - - -export const isHasPlayerMovedEvent = - new tg.IsInterface().withProperties({ - direction: tg.isElementOf('right', 'left', 'up', 'down'), +export const isHasPlayerMovedEvent = new tg.IsInterface() + .withProperties({ + direction: tg.isElementOf("right", "left", "up", "down"), moving: tg.isBoolean, x: tg.isNumber, - y: tg.isNumber - }).get(); + y: tg.isNumber, + }) + .get(); /** * A message sent from the game to the iFrame to notify a movement from the current player. */ export type HasPlayerMovedEvent = tg.GuardedType; - -export type HasPlayerMovedEventCallback = (event: HasPlayerMovedEvent) => void +export type HasPlayerMovedEventCallback = (event: HasPlayerMovedEvent) => void; diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 137eccad..fc3384f8 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -1,73 +1,73 @@ - -import type { GameStateEvent } from './GameStateEvent'; -import type { ButtonClickedEvent } from './ButtonClickedEvent'; -import type { ChatEvent } from './ChatEvent'; -import type { ClosePopupEvent } from './ClosePopupEvent'; -import type { EnterLeaveEvent } from './EnterLeaveEvent'; -import type { GoToPageEvent } from './GoToPageEvent'; -import type { LoadPageEvent } from './LoadPageEvent'; -import type { OpenCoWebSiteEvent } from './OpenCoWebSiteEvent'; -import type { OpenPopupEvent } from './OpenPopupEvent'; -import type { OpenTabEvent } from './OpenTabEvent'; -import type { UserInputChatEvent } from './UserInputChatEvent'; +import type { GameStateEvent } from "./GameStateEvent"; +import type { ButtonClickedEvent } from "./ButtonClickedEvent"; +import type { ChatEvent } from "./ChatEvent"; +import type { ClosePopupEvent } from "./ClosePopupEvent"; +import type { EnterLeaveEvent } from "./EnterLeaveEvent"; +import type { GoToPageEvent } from "./GoToPageEvent"; +import type { LoadPageEvent } from "./LoadPageEvent"; +import type { OpenCoWebSiteEvent } from "./OpenCoWebSiteEvent"; +import type { OpenPopupEvent } from "./OpenPopupEvent"; +import type { OpenTabEvent } from "./OpenTabEvent"; +import type { UserInputChatEvent } from "./UserInputChatEvent"; import type { DataLayerEvent } from "./DataLayerEvent"; -import type { LayerEvent } from './LayerEvent'; +import type { LayerEvent } from "./LayerEvent"; import type { SetPropertyEvent } from "./setPropertyEvent"; import type { LoadSoundEvent } from "./LoadSoundEvent"; import type { PlaySoundEvent } from "./PlaySoundEvent"; import type { MenuItemClickedEvent } from "./ui/MenuItemClickedEvent"; -import type { MenuItemRegisterEvent } from './ui/MenuItemRegisterEvent'; +import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent"; import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent"; import type { SetTilesEvent } from "./SetTilesEvent"; export interface TypedMessageEvent extends MessageEvent { - data: T + data: T; } +/** + * List event types sent from an iFrame to WorkAdventure + */ export type IframeEventMap = { - //getState: GameStateEvent, - loadPage: LoadPageEvent - chat: ChatEvent, - openPopup: OpenPopupEvent - closePopup: ClosePopupEvent - openTab: OpenTabEvent - goToPage: GoToPageEvent - openCoWebSite: OpenCoWebSiteEvent - closeCoWebSite: null - disablePlayerControls: null - restorePlayerControls: null - displayBubble: null - removeBubble: null - onPlayerMove: undefined - showLayer: LayerEvent - hideLayer: LayerEvent - setProperty: SetPropertyEvent - getDataLayer: undefined - loadSound: LoadSoundEvent - playSound: PlaySoundEvent - stopSound: null - setTiles: SetTilesEvent - getState: undefined, - registerMenuCommand: MenuItemRegisterEvent -} + loadPage: LoadPageEvent; + chat: ChatEvent; + openPopup: OpenPopupEvent; + closePopup: ClosePopupEvent; + openTab: OpenTabEvent; + goToPage: GoToPageEvent; + openCoWebSite: OpenCoWebSiteEvent; + closeCoWebSite: null; + disablePlayerControls: null; + restorePlayerControls: null; + displayBubble: null; + removeBubble: null; + onPlayerMove: undefined; + showLayer: LayerEvent; + hideLayer: LayerEvent; + setProperty: SetPropertyEvent; + getDataLayer: undefined; + loadSound: LoadSoundEvent; + playSound: PlaySoundEvent; + stopSound: null; + getState: undefined; + registerMenuCommand: MenuItemRegisterEvent; + setTiles: SetTilesEvent; +}; export interface IframeEvent { type: T; data: IframeEventMap[T]; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isIframeEventWrapper = (event: any): event is IframeEvent => typeof event.type === 'string'; +export const isIframeEventWrapper = (event: any): event is IframeEvent => + typeof event.type === "string"; export interface IframeResponseEventMap { - userInputChat: UserInputChatEvent - enterEvent: EnterLeaveEvent - leaveEvent: EnterLeaveEvent - buttonClickedEvent: ButtonClickedEvent - gameState: GameStateEvent - hasPlayerMoved: HasPlayerMovedEvent - dataLayer: DataLayerEvent - menuItemClicked: MenuItemClickedEvent + userInputChat: UserInputChatEvent; + enterEvent: EnterLeaveEvent; + leaveEvent: EnterLeaveEvent; + buttonClickedEvent: ButtonClickedEvent; + hasPlayerMoved: HasPlayerMovedEvent; + dataLayer: DataLayerEvent; + menuItemClicked: MenuItemClickedEvent; } export interface IframeResponseEvent { type: T; @@ -75,4 +75,49 @@ export interface IframeResponseEvent { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isIframeResponseEventWrapper = (event: { type?: string }): event is IframeResponseEvent => typeof event.type === 'string'; +export const isIframeResponseEventWrapper = (event: { + type?: string; +}): 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 + */ +export type IframeQueryMap = { + getState: { + query: undefined, + answer: GameStateEvent + }, +} + +export interface IframeQuery { + type: T; + data: IframeQueryMap[T]['query']; +} + +export interface IframeQueryWrapper { + id: number; + query: IframeQuery; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isIframeQuery = (event: any): event is IframeQuery => typeof event.type === 'string'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper => typeof event.id === 'number' && isIframeQuery(event.query); + +export interface IframeAnswerEvent { + id: number; + type: T; + data: IframeQueryMap[T]['answer']; +} + +export const isIframeAnswerEvent = (event: { type?: string, id?: number }): event is IframeAnswerEvent => typeof event.type === 'string' && typeof event.id === 'number'; + +export interface IframeErrorAnswerEvent { + id: number; + type: keyof IframeQueryMap; + error: string; +} + +export const isIframeErrorAnswerEvent = (event: { type?: string, id?: number, error?: string }): event is IframeErrorAnswerEvent => typeof event.type === 'string' && typeof event.id === 'number' && typeof event.error === 'string'; diff --git a/front/src/Api/Events/LayerEvent.ts b/front/src/Api/Events/LayerEvent.ts index f854248b..b56c3163 100644 --- a/front/src/Api/Events/LayerEvent.ts +++ b/front/src/Api/Events/LayerEvent.ts @@ -1,9 +1,10 @@ import * as tg from "generic-type-guard"; -export const isLayerEvent = - new tg.IsInterface().withProperties({ +export const isLayerEvent = new tg.IsInterface() + .withProperties({ name: tg.isString, - }).get(); + }) + .get(); /** * A message sent from the iFrame to the game to show/hide a layer. */ diff --git a/front/src/Api/Events/LoadPageEvent.ts b/front/src/Api/Events/LoadPageEvent.ts index 9bc7f32a..63600a28 100644 --- a/front/src/Api/Events/LoadPageEvent.ts +++ b/front/src/Api/Events/LoadPageEvent.ts @@ -1,13 +1,12 @@ import * as tg from "generic-type-guard"; - - -export const isLoadPageEvent = - new tg.IsInterface().withProperties({ +export const isLoadPageEvent = new tg.IsInterface() + .withProperties({ url: tg.isString, - }).get(); + }) + .get(); /** * A message sent from the iFrame to the game to add a message in the chat. */ -export type LoadPageEvent = tg.GuardedType; \ No newline at end of file +export type LoadPageEvent = tg.GuardedType; diff --git a/front/src/Api/Events/OpenCoWebSiteEvent.ts b/front/src/Api/Events/OpenCoWebSiteEvent.ts index d2937405..7b5e6070 100644 --- a/front/src/Api/Events/OpenCoWebSiteEvent.ts +++ b/front/src/Api/Events/OpenCoWebSiteEvent.ts @@ -1,13 +1,12 @@ import * as tg from "generic-type-guard"; - - -export const isOpenCoWebsite = - new tg.IsInterface().withProperties({ +export const isOpenCoWebsite = new tg.IsInterface() + .withProperties({ url: tg.isString, allowApi: tg.isBoolean, allowPolicy: tg.isString, - }).get(); + }) + .get(); /** * A message sent from the iFrame to the game to add a message in the chat. diff --git a/front/src/Api/Events/SetTilesEvent.ts b/front/src/Api/Events/SetTilesEvent.ts index 24dd2e35..371f0884 100644 --- a/front/src/Api/Events/SetTilesEvent.ts +++ b/front/src/Api/Events/SetTilesEvent.ts @@ -1,14 +1,15 @@ import * as tg from "generic-type-guard"; -export const isSetTilesEvent = - tg.isArray( - new tg.IsInterface().withProperties({ +export const isSetTilesEvent = tg.isArray( + new tg.IsInterface() + .withProperties({ x: tg.isNumber, y: tg.isNumber, - tile: tg.isUnion(tg.isNumber, tg.isString), - layer: tg.isString - }).get() - ); + tile: tg.isUnion(tg.isUnion(tg.isNumber, tg.isString), tg.isNull), + layer: tg.isString, + }) + .get() +); /** * A message sent from the iFrame to the game to set one or many tiles. */ diff --git a/front/src/Api/Events/setPropertyEvent.ts b/front/src/Api/Events/setPropertyEvent.ts index 39785bc6..7335f781 100644 --- a/front/src/Api/Events/setPropertyEvent.ts +++ b/front/src/Api/Events/setPropertyEvent.ts @@ -1,12 +1,13 @@ import * as tg from "generic-type-guard"; -export const isSetPropertyEvent = - new tg.IsInterface().withProperties({ +export const isSetPropertyEvent = new tg.IsInterface() + .withProperties({ layerName: tg.isString, propertyName: tg.isString, - propertyValue: tg.isUnion(tg.isString, tg.isUnion(tg.isNumber, tg.isUnion(tg.isBoolean, tg.isUndefined))) - }).get(); + propertyValue: tg.isUnion(tg.isString, tg.isUnion(tg.isNumber, tg.isUnion(tg.isBoolean, tg.isUndefined))), + }) + .get(); /** * A message sent from the iFrame to the game to change the value of the property of the layer */ -export type SetPropertyEvent = tg.GuardedType; \ No newline at end of file +export type SetPropertyEvent = tg.GuardedType; diff --git a/front/src/Api/Events/ui/MenuItemClickedEvent.ts b/front/src/Api/Events/ui/MenuItemClickedEvent.ts index fad2944f..a8c8d0ed 100644 --- a/front/src/Api/Events/ui/MenuItemClickedEvent.ts +++ b/front/src/Api/Events/ui/MenuItemClickedEvent.ts @@ -1,12 +1,11 @@ import * as tg from "generic-type-guard"; -export const isMenuItemClickedEvent = - new tg.IsInterface().withProperties({ - menuItem: tg.isString - }).get(); +export const isMenuItemClickedEvent = new tg.IsInterface() + .withProperties({ + menuItem: tg.isString, + }) + .get(); /** * A message sent from the game to the iFrame when a menu item is clicked. */ export type MenuItemClickedEvent = tg.GuardedType; - - diff --git a/front/src/Api/Events/ui/MenuItemRegisterEvent.ts b/front/src/Api/Events/ui/MenuItemRegisterEvent.ts index 4a56d8a0..404bdb13 100644 --- a/front/src/Api/Events/ui/MenuItemRegisterEvent.ts +++ b/front/src/Api/Events/ui/MenuItemRegisterEvent.ts @@ -1,25 +1,26 @@ import * as tg from "generic-type-guard"; -import { Subject } from 'rxjs'; +import { Subject } from "rxjs"; -export const isMenuItemRegisterEvent = - new tg.IsInterface().withProperties({ - menutItem: tg.isString - }).get(); +export const isMenuItemRegisterEvent = new tg.IsInterface() + .withProperties({ + menutItem: tg.isString, + }) + .get(); /** * A message sent from the iFrame to the game to add a new menu item. */ export type MenuItemRegisterEvent = tg.GuardedType; -export const isMenuItemRegisterIframeEvent = - new tg.IsInterface().withProperties({ +export const isMenuItemRegisterIframeEvent = new tg.IsInterface() + .withProperties({ type: tg.isSingletonString("registerMenuCommand"), - data: isMenuItemRegisterEvent - }).get(); - + data: isMenuItemRegisterEvent, + }) + .get(); const _registerMenuCommandStream: Subject = new Subject(); export const registerMenuCommandStream = _registerMenuCommandStream.asObservable(); export function handleMenuItemRegistrationEvent(event: MenuItemRegisterEvent) { - _registerMenuCommandStream.next(event.menutItem) -} \ No newline at end of file + _registerMenuCommandStream.next(event.menutItem); +} diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index 3143238d..314d5d2e 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -1,42 +1,45 @@ -import {Subject} from "rxjs"; -import {ChatEvent, isChatEvent} from "./Events/ChatEvent"; -import {HtmlUtils} from "../WebRtc/HtmlUtils"; -import type {EnterLeaveEvent} from "./Events/EnterLeaveEvent"; -import {isOpenPopupEvent, OpenPopupEvent} from "./Events/OpenPopupEvent"; -import {isOpenTabEvent, OpenTabEvent} from "./Events/OpenTabEvent"; -import type {ButtonClickedEvent} from "./Events/ButtonClickedEvent"; -import {ClosePopupEvent, isClosePopupEvent} from "./Events/ClosePopupEvent"; -import {scriptUtils} from "./ScriptUtils"; -import {GoToPageEvent, isGoToPageEvent} from "./Events/GoToPageEvent"; -import {isOpenCoWebsite, OpenCoWebSiteEvent} from "./Events/OpenCoWebSiteEvent"; +import { Subject } from "rxjs"; +import { ChatEvent, isChatEvent } from "./Events/ChatEvent"; +import { HtmlUtils } from "../WebRtc/HtmlUtils"; +import type { EnterLeaveEvent } from "./Events/EnterLeaveEvent"; +import { isOpenPopupEvent, OpenPopupEvent } from "./Events/OpenPopupEvent"; +import { isOpenTabEvent, OpenTabEvent } from "./Events/OpenTabEvent"; +import type { ButtonClickedEvent } from "./Events/ButtonClickedEvent"; +import { ClosePopupEvent, isClosePopupEvent } from "./Events/ClosePopupEvent"; +import { scriptUtils } from "./ScriptUtils"; +import { GoToPageEvent, isGoToPageEvent } from "./Events/GoToPageEvent"; +import { isOpenCoWebsite, OpenCoWebSiteEvent } from "./Events/OpenCoWebSiteEvent"; import { + IframeErrorAnswerEvent, IframeEvent, - IframeEventMap, + IframeEventMap, IframeQueryMap, IframeResponseEvent, IframeResponseEventMap, isIframeEventWrapper, - TypedMessageEvent + isIframeQueryWrapper, + TypedMessageEvent, } from "./Events/IframeEvent"; -import type {UserInputChatEvent} from "./Events/UserInputChatEvent"; -import {isPlaySoundEvent, PlaySoundEvent} from "./Events/PlaySoundEvent"; -import {isStopSoundEvent, StopSoundEvent} from "./Events/StopSoundEvent"; -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 {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 { UserInputChatEvent } from "./Events/UserInputChatEvent"; +import { isPlaySoundEvent, PlaySoundEvent } from "./Events/PlaySoundEvent"; +import { isStopSoundEvent, StopSoundEvent } from "./Events/StopSoundEvent"; +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 { 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"; + +type AnswererCallback = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise; /** * 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 _chatStream: Subject = new Subject(); public readonly chatStream = this._chatStream.asObservable(); @@ -82,9 +85,6 @@ class IframeListener { private readonly _setPropertyStream: Subject = new Subject(); public readonly setPropertyStream = this._setPropertyStream.asObservable(); - private readonly _gameStateStream: Subject = new Subject(); - public readonly gameStateStream = this._gameStateStream.asObservable(); - private readonly _dataLayerChangeStream: Subject = new Subject(); public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable(); @@ -111,117 +111,147 @@ class IframeListener { private readonly scripts = new Map(); private sendPlayerMove: boolean = false; + private answerers: { + [key in keyof IframeQueryMap]?: AnswererCallback + } = {}; + init() { - window.addEventListener("message", (message: TypedMessageEvent>) => { - // 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). - let foundSrc: string | undefined; + window.addEventListener( + "message", + (message: TypedMessageEvent>) => { + // 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). + let foundSrc: string | undefined; - let iframe: HTMLIFrameElement; - for (iframe of this.iframes) { - if (iframe.contentWindow === message.source) { - foundSrc = iframe.src; - break; - } - } - - const payload = message.data; - - if (foundSrc === undefined) { - if (isIframeEventWrapper(payload)) { - console.warn('It seems an iFrame is trying to communicate with WorkAdventure but was not explicitly granted the permission to do so. ' + - 'If you are looking to use the WorkAdventure Scripting API inside an iFrame, you should allow the ' + - 'iFrame to communicate with WorkAdventure by using the "openWebsiteAllowApi" property in your map (or passing "true" as a second' + - 'parameter to WA.nav.openCoWebSite())'); - } - return; - } - - foundSrc = this.getBaseUrl(foundSrc, message.source); - - if (isIframeEventWrapper(payload)) { - if (payload.type === 'showLayer' && isLayerEvent(payload.data)) { - this._showLayerStream.next(payload.data); - } else if (payload.type === 'hideLayer' && isLayerEvent(payload.data)) { - this._hideLayerStream.next(payload.data); - } else if (payload.type === 'setProperty' && isSetPropertyEvent(payload.data)) { - this._setPropertyStream.next(payload.data); - } else if (payload.type === 'chat' && isChatEvent(payload.data)) { - this._chatStream.next(payload.data); - } else if (payload.type === 'openPopup' && isOpenPopupEvent(payload.data)) { - this._openPopupStream.next(payload.data); - } else if (payload.type === 'closePopup' && isClosePopupEvent(payload.data)) { - this._closePopupStream.next(payload.data); - } - else if (payload.type === 'openTab' && isOpenTabEvent(payload.data)) { - scriptUtils.openTab(payload.data.url); - } - else if (payload.type === 'goToPage' && isGoToPageEvent(payload.data)) { - scriptUtils.goToPage(payload.data.url); - } - else if (payload.type === 'loadPage' && isLoadPageEvent(payload.data)) { - this._loadPageStream.next(payload.data.url); - } - else if (payload.type === 'playSound' && isPlaySoundEvent(payload.data)) { - this._playSoundStream.next(payload.data); - } - else if (payload.type === 'stopSound' && isStopSoundEvent(payload.data)) { - this._stopSoundStream.next(payload.data); - } - else if (payload.type === 'loadSound' && isLoadSoundEvent(payload.data)) { - this._loadSoundStream.next(payload.data); - } - else if (payload.type === 'openCoWebSite' && isOpenCoWebsite(payload.data)) { - scriptUtils.openCoWebsite(payload.data.url, foundSrc, payload.data.allowApi, payload.data.allowPolicy); + let iframe: HTMLIFrameElement | undefined; + for (iframe of this.iframes) { + if (iframe.contentWindow === message.source) { + foundSrc = iframe.src; + break; + } } - else if (payload.type === 'closeCoWebSite') { - scriptUtils.closeCoWebSite(); + const payload = message.data; + + if (foundSrc === undefined || iframe === undefined) { + if (isIframeEventWrapper(payload)) { + console.warn( + "It seems an iFrame is trying to communicate with WorkAdventure but was not explicitly granted the permission to do so. " + + "If you are looking to use the WorkAdventure Scripting API inside an iFrame, you should allow the " + + 'iFrame to communicate with WorkAdventure by using the "openWebsiteAllowApi" property in your map (or passing "true" as a second' + + "parameter to WA.nav.openCoWebSite())" + ); + } + return; } - else if (payload.type === 'disablePlayerControls') { - this._disablePlayerControlStream.next(); - } - else if (payload.type === 'restorePlayerControls') { - this._enablePlayerControlStream.next(); - } else if (payload.type === 'displayBubble') { - this._displayBubbleStream.next(); - } else if (payload.type === 'removeBubble') { - this._removeBubbleStream.next(); - } else if (payload.type == "getState") { - this._gameStateStream.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 - this.iframeCloseCallbacks.get(iframe).push(() => { - this._unregisterMenuCommandStream.next(data); - }) - handleMenuItemRegistrationEvent(payload.data) - } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { - this._setTilesStream.next(payload.data); - } - } - }, false); + foundSrc = this.getBaseUrl(foundSrc, message.source); + if (isIframeQueryWrapper(payload)) { + const queryId = payload.id; + const query = payload.query; + + const answerer = this.answerers[query.type]; + if (answerer === undefined) { + const errorMsg = 'The iFrame sent a message of type "'+query.type+'" but there is no service configured to answer these messages.'; + console.error(errorMsg); + iframe.contentWindow?.postMessage({ + id: queryId, + type: query.type, + error: errorMsg + } as IframeErrorAnswerEvent, '*'); + 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(); + } + + iframe?.contentWindow?.postMessage({ + id: queryId, + type: query.type, + error: reasonMsg + } as IframeErrorAnswerEvent, '*'); + }); + + } else if (isIframeEventWrapper(payload)) { + if (payload.type === "showLayer" && isLayerEvent(payload.data)) { + this._showLayerStream.next(payload.data); + } else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) { + this._hideLayerStream.next(payload.data); + } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { + this._setPropertyStream.next(payload.data); + } else if (payload.type === "chat" && isChatEvent(payload.data)) { + this._chatStream.next(payload.data); + } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { + this._openPopupStream.next(payload.data); + } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { + this._closePopupStream.next(payload.data); + } else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) { + scriptUtils.openTab(payload.data.url); + } else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) { + scriptUtils.goToPage(payload.data.url); + } else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) { + this._loadPageStream.next(payload.data.url); + } else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) { + this._playSoundStream.next(payload.data); + } else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) { + this._stopSoundStream.next(payload.data); + } else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) { + this._loadSoundStream.next(payload.data); + } else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) { + scriptUtils.openCoWebsite( + payload.data.url, + foundSrc, + payload.data.allowApi, + payload.data.allowPolicy + ); + } else if (payload.type === "closeCoWebSite") { + scriptUtils.closeCoWebSite(); + } else if (payload.type === "disablePlayerControls") { + this._disablePlayerControlStream.next(); + } else if (payload.type === "restorePlayerControls") { + this._enablePlayerControlStream.next(); + } else if (payload.type === "displayBubble") { + this._displayBubbleStream.next(); + } else if (payload.type === "removeBubble") { + 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 + this.iframeCloseCallbacks.get(iframe).push(() => { + this._unregisterMenuCommandStream.next(data); + }); + handleMenuItemRegistrationEvent(payload.data); + } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { + this._setTilesStream.next(payload.data); + } + } + }, + false + ); } sendDataLayerEvent(dataLayerEvent: DataLayerEvent) { this.postMessage({ - 'type' : 'dataLayer', - 'data' : dataLayerEvent - }) - } - - - sendGameStateEvent(gameStateEvent: GameStateEvent) { - this.postMessage({ - 'type': 'gameState', - 'data': gameStateEvent + type: "dataLayer", + data: dataLayerEvent, }); } @@ -234,25 +264,25 @@ class IframeListener { } unregisterIframe(iframe: HTMLIFrameElement): void { - this.iframeCloseCallbacks.get(iframe)?.forEach(callback => { + this.iframeCloseCallbacks.get(iframe)?.forEach((callback) => { callback(); }); this.iframes.delete(iframe); } registerScript(scriptUrl: string): void { - console.log('Loading map related script at ', scriptUrl) + console.log("Loading map related script at ", scriptUrl); - if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') { + if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") { // Using external iframe mode ( - const iframe = document.createElement('iframe'); + const iframe = document.createElement("iframe"); iframe.id = IframeListener.getIFrameId(scriptUrl); - iframe.style.display = 'none'; - iframe.src = '/iframe.html?script=' + encodeURIComponent(scriptUrl); + iframe.style.display = "none"; + iframe.src = "/iframe.html?script=" + encodeURIComponent(scriptUrl); // We are putting a sandbox on this script because it will run in the same domain as the main website. - iframe.sandbox.add('allow-scripts'); - iframe.sandbox.add('allow-top-navigation-by-user-activation'); + iframe.sandbox.add("allow-scripts"); + iframe.sandbox.add("allow-top-navigation-by-user-activation"); document.body.prepend(iframe); @@ -260,45 +290,50 @@ class IframeListener { this.registerIframe(iframe); } else { // production code - const iframe = document.createElement('iframe'); + const iframe = document.createElement("iframe"); iframe.id = IframeListener.getIFrameId(scriptUrl); - iframe.style.display = 'none'; + iframe.style.display = "none"; // We are putting a sandbox on this script because it will run in the same domain as the main website. - iframe.sandbox.add('allow-scripts'); - iframe.sandbox.add('allow-top-navigation-by-user-activation'); + iframe.sandbox.add("allow-scripts"); + iframe.sandbox.add("allow-top-navigation-by-user-activation"); //iframe.src = "data:text/html;charset=utf-8," + escape(html); - iframe.srcdoc = '\n' + - '\n' + + iframe.srcdoc = + "\n" + + "\n" + '\n' + - '\n' + - '\n' + - '\n' + - '\n' + - '\n' + - '\n'; + "\n" + + '\n' + + '\n' + + "\n" + + "\n" + + "\n"; document.body.prepend(iframe); this.scripts.set(scriptUrl, iframe); this.registerIframe(iframe); } - - } - private getBaseUrl(src: string, source: MessageEventSource | null): string{ - for (const script of this.scripts) { - if (script[1].contentWindow === source) { - return script[0]; - } - } - return src; + private getBaseUrl(src: string, source: MessageEventSource | null): string { + for (const script of this.scripts) { + if (script[1].contentWindow === source) { + return script[0]; + } + } + return src; } private static getIFrameId(scriptUrl: string): string { - return 'script' + btoa(scriptUrl); + return "script" + btoa(scriptUrl); } unregisterScript(scriptUrl: string): void { @@ -315,47 +350,47 @@ class IframeListener { sendUserInputChat(message: string) { this.postMessage({ - 'type': 'userInputChat', - 'data': { - 'message': message, - } as UserInputChatEvent + type: "userInputChat", + data: { + message: message, + } as UserInputChatEvent, }); } sendEnterEvent(name: string) { this.postMessage({ - 'type': 'enterEvent', - 'data': { - "name": name - } as EnterLeaveEvent + type: "enterEvent", + data: { + name: name, + } as EnterLeaveEvent, }); } sendLeaveEvent(name: string) { this.postMessage({ - 'type': 'leaveEvent', - 'data': { - "name": name - } as EnterLeaveEvent + type: "leaveEvent", + data: { + name: name, + } as EnterLeaveEvent, }); } hasPlayerMoved(event: HasPlayerMovedEvent) { if (this.sendPlayerMove) { this.postMessage({ - 'type': 'hasPlayerMoved', - 'data': event + type: "hasPlayerMoved", + data: event, }); } } sendButtonClickedEvent(popupId: number, buttonId: number): void { this.postMessage({ - 'type': 'buttonClickedEvent', - 'data': { + type: "buttonClickedEvent", + data: { popupId, - buttonId - } as ButtonClickedEvent + buttonId, + } as ButtonClickedEvent, }); } @@ -364,10 +399,25 @@ class IframeListener { */ public postMessage(message: IframeResponseEvent) { for (const iframe of this.iframes) { - iframe.contentWindow?.postMessage(message, '*'); + iframe.contentWindow?.postMessage(message, "*"); } } + /** + * Registers a callback that can be used to respond to some query (as defined in the IframeQueryMap type). + * + * Important! There can be only one "answerer" so registering a new one will unregister the old one. + * + * @param key The "type" of the query we are answering + * @param callback + */ + public registerAnswerer(key: T, callback: (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise ): void { + this.answerers[key] = callback; + } + + public unregisterAnswerer(key: keyof IframeQueryMap): void { + delete this.answerers[key]; + } } export const iframeListener = new IframeListener(); diff --git a/front/src/Api/ScriptUtils.ts b/front/src/Api/ScriptUtils.ts index 75a18dc0..0dbe40fe 100644 --- a/front/src/Api/ScriptUtils.ts +++ b/front/src/Api/ScriptUtils.ts @@ -1,21 +1,19 @@ -import {coWebsiteManager} from "../WebRtc/CoWebsiteManager"; +import { coWebsiteManager } from "../WebRtc/CoWebsiteManager"; class ScriptUtils { - - public openTab(url : string){ + public openTab(url: string) { window.open(url); } - public goToPage(url : string){ - window.location.href = url; - + public goToPage(url: string) { + window.location.href = url; } public openCoWebsite(url: string, base: string, api: boolean, policy: string) { - coWebsiteManager.loadCoWebsite(url, base, api, policy); + coWebsiteManager.loadCoWebsite(url, base, api, policy); } - public closeCoWebSite(){ + public closeCoWebSite() { coWebsiteManager.closeCoWebsite(); } } diff --git a/front/src/Api/iframe/IframeApiContribution.ts b/front/src/Api/iframe/IframeApiContribution.ts index f3b25999..e4ba089e 100644 --- a/front/src/Api/iframe/IframeApiContribution.ts +++ b/front/src/Api/iframe/IframeApiContribution.ts @@ -1,9 +1,40 @@ import type * as tg from "generic-type-guard"; -import type { IframeEvent, IframeEventMap, IframeResponseEventMap } from '../Events/IframeEvent'; +import type { + IframeEvent, + IframeEventMap, IframeQuery, + IframeQueryMap, + IframeResponseEventMap +} from '../Events/IframeEvent'; +import type {IframeQueryWrapper} from "../Events/IframeEvent"; export function sendToWorkadventure(content: IframeEvent) { window.parent.postMessage(content, "*") } + +let queryNumber = 0; + +export const answerPromises = new Map)) => void, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reject: (reason?: any) => void +}>(); + +export function queryWorkadventure(content: IframeQuery): Promise { + return new Promise((resolve, reject) => { + window.parent.postMessage({ + id: queryNumber, + query: content + } as IframeQueryWrapper, "*"); + + answerPromises.set(queryNumber, { + resolve, + reject + }); + + queryNumber++; + }); +} + type GuardedType> = Guard extends tg.TypeGuard ? T : never export interface IframeCallback> { diff --git a/front/src/Api/iframe/Ui/MenuItem.ts b/front/src/Api/iframe/Ui/MenuItem.ts index 9782ea7a..aa61d749 100644 --- a/front/src/Api/iframe/Ui/MenuItem.ts +++ b/front/src/Api/iframe/Ui/MenuItem.ts @@ -1,11 +1,11 @@ -import type { MenuItemClickedEvent } from '../../Events/ui/MenuItemClickedEvent'; -import { iframeListener } from '../../IframeListener'; +import type { MenuItemClickedEvent } from "../../Events/ui/MenuItemClickedEvent"; +import { iframeListener } from "../../IframeListener"; export function sendMenuClickedEvent(menuItem: string) { iframeListener.postMessage({ - 'type': 'menuItemClicked', - 'data': { + type: "menuItemClicked", + data: { menuItem: menuItem, - } as MenuItemClickedEvent + } as MenuItemClickedEvent, }); -} \ No newline at end of file +} diff --git a/front/src/Api/iframe/player.ts b/front/src/Api/iframe/player.ts index e130d3f2..cad66a36 100644 --- a/front/src/Api/iframe/player.ts +++ b/front/src/Api/iframe/player.ts @@ -2,8 +2,15 @@ import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribut import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events/HasPlayerMovedEvent"; import { Subject } from "rxjs"; import { apiCallback } from "./registeredCallbacks"; +import { getGameState } from "./room"; import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent"; +interface User { + id: string | undefined; + nickName: string | null; + tags: string[]; +} + const moveStream = new Subject(); export class WorkadventurePlayerCommands extends IframeApiContribution { @@ -24,6 +31,11 @@ export class WorkadventurePlayerCommands extends IframeApiContribution { + return getGameState().then((gameState) => { + return { id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags }; + }); + } } export default new WorkadventurePlayerCommands(); diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index 84e9d6ca..0256dfc8 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -4,7 +4,7 @@ import { isDataLayerEvent } from "../Events/DataLayerEvent"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; import { isGameStateEvent } from "../Events/GameStateEvent"; -import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution"; +import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; @@ -14,7 +14,6 @@ import type { GameStateEvent } from "../Events/GameStateEvent"; const enterStreams: Map> = new Map>(); const leaveStreams: Map> = new Map>(); const dataLayerResolver = new Subject(); -const stateResolvers = new Subject(); let immutableDataPromise: Promise | undefined = undefined; @@ -25,26 +24,16 @@ interface Room { startLayer: string | null; } -interface User { - id: string | undefined; - nickName: string | null; - tags: string[]; -} - interface TileDescriptor { - x: number - y: number - tile: number | string - layer: string + x: number; + y: number; + tile: number | string | null; + layer: string; } - -function getGameState(): Promise { +export function getGameState(): Promise { if (immutableDataPromise === undefined) { - immutableDataPromise = new Promise((resolver, thrower) => { - stateResolvers.subscribe(resolver); - sendToWorkadventure({ type: "getState", data: null }); - }); + immutableDataPromise = queryWorkadventure({ type: "getState", data: undefined }); } return immutableDataPromise; } @@ -72,13 +61,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - stateResolvers.next(payloadData); - }, - }), apiCallback({ type: "dataLayer", typeChecker: isDataLayerEvent, @@ -132,18 +114,12 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - return getGameState().then((gameState) => { - return { id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags }; - }); - } setTiles(tiles: TileDescriptor[]) { sendToWorkadventure({ - type: 'setTiles', - data: tiles - }) + type: "setTiles", + data: tiles, + }); } - } export default new WorkadventureRoomCommands(); 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 ab7a63be..06bfcfa7 100644 --- a/front/src/Components/Video/utils.ts +++ b/front/src/Components/Video/utils.ts @@ -1,4 +1,7 @@ -export function getColorByString(str: string) : string|null { +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) { return null; @@ -7,21 +10,37 @@ export function getColorByString(str: string) : string|null { hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; } - let color = '#'; + let color = "#"; for (let i = 0; i < 3; i++) { const value = (hash >> (i * 8)) & 255; - color += ('00' + value.toString(16)).substr(-2); + color += ("00" + value.toString(16)).substr(-2); } return color; } -export function srcObject(node: HTMLVideoElement, stream: MediaStream) { +export function srcObject(node: HTMLVideoElement, stream: MediaStream | null) { node.srcObject = stream; return { update(newStream: MediaStream) { if (node.srcObject != newStream) { - node.srcObject = newStream + node.srcObject = newStream; } - } - } + }, + }; +} + +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..379b161f 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -38,11 +38,9 @@ class ConnectionManager { 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) { @@ -66,22 +64,21 @@ class ConnectionManager { 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; + localUser.textures = room.textures; }else{ - mapDetail.textures.forEach((newTexture) => { + room.textures.forEach((newTexture) => { const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ return; @@ -114,9 +111,9 @@ class ConnectionManager { 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'); reject(error); @@ -137,7 +134,7 @@ class ConnectionManager { 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)); + this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection)); }, 4000 + Math.floor(Math.random() * 2000) ); }); }); diff --git a/front/src/Connexion/ConnexionModels.ts b/front/src/Connexion/ConnexionModels.ts index b5a66296..189aea7c 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", @@ -36,7 +36,7 @@ export enum EventMessage{ export interface PointInterface { x: number; y: number; - direction : string; + direction: string; moving: boolean; } @@ -45,8 +45,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 +61,59 @@ 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 }; } 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 3ae8d2ed..2053911d 100644 --- a/front/src/Connexion/Room.ts +++ b/front/src/Connexion/Room.ts @@ -1,90 +1,105 @@ import Axios from "axios"; -import {PUSHER_URL} from "../Enum/EnvironmentVariable"; -import type {CharacterTexture} from "./LocalUser"; +import { PUSHER_URL } from "../Enum/EnvironmentVariable"; +import type { CharacterTexture } from "./LocalUser"; -export class MapDetail{ - constructor(public readonly mapUrl: string, public readonly textures : CharacterTexture[]|undefined) { - } +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 instance: string|undefined; - private _search: URLSearchParams; + private _mapUrl: string | undefined; + private _textures: CharacterTexture[] | undefined; + private instance: string | undefined; + private readonly _search: URLSearchParams; - constructor(id: string) { - const url = new URL(id, 'https://example.com'); + private constructor(private roomUrl: URL) { + this.id = roomUrl.pathname; - this.id = url.pathname; - - if (this.id.startsWith('/')) { + if (this.id.startsWith("/")) { this.id = this.id.substr(1); } - if (this.id.startsWith('_/')) { + if (this.id.startsWith("_/")) { this.isPublic = true; - } else if (this.id.startsWith('@/')) { + } else if (this.id.startsWith("@/")) { this.isPublic = false; } else { - throw new Error('Invalid room ID'); + 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} { - let roomId = ''; - let hash = ''; - 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 - } 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); } /** @@ -99,37 +114,39 @@ export class Room { if (this.isPublic) { const match = /_\/([^/]+)\/.+/.exec(this.id); - if (!match) throw new Error('Could not extract instance from "'+this.id+'"'); + if (!match) throw new Error('Could not extract instance from "' + this.id + '"'); this.instance = match[1]; return this.instance; } else { const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id); - if (!match) throw new Error('Could not extract instance from "'+this.id+'"'); - this.instance = match[1]+'/'+match[2]; + if (!match) throw new Error('Could not extract instance from "' + this.id + '"'); + this.instance = match[1] + "/" + match[2]; return this.instance; } } - private parsePrivateUrl(url: string): { organizationSlug: string, worldSlug: string, roomSlug?: string } { + /** + * @deprecated + */ + private parsePrivateUrl(url: string): { organizationSlug: string; worldSlug: string; roomSlug?: string } { const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm; const match = regex.exec(url); if (!match) { - throw new Error('Invalid URL '+url); + throw new Error("Invalid URL " + url); } - const results: { organizationSlug: string, worldSlug: string, roomSlug?: string } = { + const results: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { organizationSlug: match[1], worldSlug: match[2], - } + }; if (match[3] !== undefined) { results.roomSlug = match[3]; } return results; } - public isDisconnected(): boolean - { - const alone = this._search.get('alone'); - if (alone && alone !== '0' && alone.toLowerCase() !== 'false') { + public isDisconnected(): boolean { + const alone = this._search.get("alone"); + if (alone && alone !== "0" && alone.toLowerCase() !== "false") { return true; } return false; @@ -138,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 dc9830c3..1d3c3702 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -11,7 +11,8 @@ import { RoomJoinedMessage, ServerToClientMessage, SetPlayerDetailsMessage, - SilentMessage, StopGlobalMessage, + SilentMessage, + StopGlobalMessage, UserJoinedMessage, UserLeftMessage, UserMovedMessage, @@ -31,17 +32,22 @@ import { EmotePromptMessage, SendUserMessage, BanUserMessage, -} from "../Messages/generated/messages_pb" +} from "../Messages/generated/messages_pb"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; import Direction = PositionMessage.Direction; import { ProtobufClientUtils } from "../Network/ProtobufClientUtils"; import { EventMessage, - GroupCreatedUpdatedMessageInterface, ItemEventMessageInterface, - MessageUserJoined, OnConnectInterface, PlayGlobalMessageInterface, PositionInterface, + GroupCreatedUpdatedMessageInterface, + ItemEventMessageInterface, + MessageUserJoined, + OnConnectInterface, + PlayGlobalMessageInterface, + PositionInterface, RoomJoinedMessageInterface, - ViewportInterface, WebRtcDisconnectMessageInterface, + ViewportInterface, + WebRtcDisconnectMessageInterface, WebRtcSignalReceivedMessageInterface, } from "./ConnexionModels"; import type { BodyResourceDescriptionInterface } from "../Phaser/Entity/PlayerTextures"; @@ -61,36 +67,45 @@ export class RoomConnection implements RoomConnection { private closed: boolean = false; private tags: string[] = []; - public static setWebsocketFactory(websocketFactory: (url: string) => any): void { // eslint-disable-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static setWebsocketFactory(websocketFactory: (url: string) => any): void { RoomConnection.websocketFactory = websocketFactory; } /** * * @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, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string | null) { + public constructor( + token: string | null, + roomUrl: string, + name: string, + characterLayers: string[], + position: PositionInterface, + viewport: ViewportInterface, + companion: string | null + ) { let url = new URL(PUSHER_URL, window.location.toString()).toString(); - url = url.replace('http://', 'ws://').replace('https://', 'wss://'); - if (!url.endsWith('/')) { - url += '/'; + url = url.replace("http://", "ws://").replace("https://", "wss://"); + if (!url.endsWith("/")) { + url += "/"; } - url += 'room'; - url += '?roomId=' + (roomId ? encodeURIComponent(roomId) : ''); - url += '&token=' + (token ? encodeURIComponent(token) : ''); - url += '&name=' + encodeURIComponent(name); + url += "room"; + url += "?roomId=" + encodeURIComponent(roomUrl); + url += "&token=" + (token ? encodeURIComponent(token) : ""); + url += "&name=" + encodeURIComponent(name); for (const layer of characterLayers) { - url += '&characterLayers=' + encodeURIComponent(layer); + url += "&characterLayers=" + encodeURIComponent(layer); } - url += '&x=' + Math.floor(position.x); - url += '&y=' + Math.floor(position.y); - url += '&top=' + Math.floor(viewport.top); - url += '&bottom=' + Math.floor(viewport.bottom); - url += '&left=' + Math.floor(viewport.left); - url += '&right=' + Math.floor(viewport.right); - if (typeof companion === 'string') { - url += '&companion=' + encodeURIComponent(companion); + url += "&x=" + Math.floor(position.x); + url += "&y=" + Math.floor(position.y); + url += "&top=" + Math.floor(viewport.top); + url += "&bottom=" + Math.floor(viewport.bottom); + url += "&left=" + Math.floor(viewport.left); + url += "&right=" + Math.floor(viewport.right); + if (typeof companion === "string") { + url += "&companion=" + encodeURIComponent(companion); } if (RoomConnection.websocketFactory) { @@ -99,7 +114,7 @@ export class RoomConnection implements RoomConnection { this.socket = new WebSocket(url); } - this.socket.binaryType = 'arraybuffer'; + this.socket.binaryType = "arraybuffer"; let interval: ReturnType | undefined = undefined; @@ -109,7 +124,7 @@ export class RoomConnection implements RoomConnection { interval = setInterval(() => this.socket.send(pingMessage.serializeBinary().buffer), manualPingDelay); }; - this.socket.addEventListener('close', (event) => { + this.socket.addEventListener("close", (event) => { if (interval) { clearInterval(interval); } @@ -126,7 +141,7 @@ export class RoomConnection implements RoomConnection { if (message.hasBatchmessage()) { for (const subMessage of (message.getBatchmessage() as BatchMessage).getPayloadList()) { - let event: string|null = null; + let event: string | null = null; let payload; if (subMessage.hasUsermovedmessage()) { event = EventMessage.USER_MOVED; @@ -150,7 +165,7 @@ export class RoomConnection implements RoomConnection { const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage; emoteEventStream.fire(emoteMessage.getActoruserid(), emoteMessage.getEmote()); } else { - throw new Error('Unexpected batch message type'); + throw new Error("Unexpected batch message type"); } if (event) { @@ -171,8 +186,8 @@ export class RoomConnection implements RoomConnection { this.dispatch(EventMessage.CONNECT, { connection: this, room: { - items - } as RoomJoinedMessageInterface + items, + } as RoomJoinedMessageInterface, }); } else if (message.hasWorldfullmessage()) { worldFullMessageStream.onMessage(); @@ -183,7 +198,10 @@ export class RoomConnection implements RoomConnection { } else if (message.hasWebrtcsignaltoclientmessage()) { this.dispatch(EventMessage.WEBRTC_SIGNAL, message.getWebrtcsignaltoclientmessage()); } else if (message.hasWebrtcscreensharingsignaltoclientmessage()) { - this.dispatch(EventMessage.WEBRTC_SCREEN_SHARING_SIGNAL, message.getWebrtcscreensharingsignaltoclientmessage()); + this.dispatch( + EventMessage.WEBRTC_SCREEN_SHARING_SIGNAL, + message.getWebrtcscreensharingsignaltoclientmessage() + ); } else if (message.hasWebrtcstartmessage()) { this.dispatch(EventMessage.WEBRTC_START, message.getWebrtcstartmessage()); } else if (message.hasWebrtcdisconnectmessage()) { @@ -205,10 +223,9 @@ export class RoomConnection implements RoomConnection { } else if (message.hasRefreshroommessage()) { //todo: implement a way to notify the user the room was refreshed. } else { - throw new Error('Unknown message received'); + throw new Error("Unknown message received"); } - - } + }; } private dispatch(event: string, payload: unknown): void { @@ -243,16 +260,16 @@ export class RoomConnection implements RoomConnection { positionMessage.setY(Math.floor(y)); let directionEnum: Direction; switch (direction) { - case 'up': + case "up": directionEnum = Direction.UP; break; - case 'down': + case "down": directionEnum = Direction.DOWN; break; - case 'left': + case "left": directionEnum = Direction.LEFT; break; - case 'right': + case "right": directionEnum = Direction.RIGHT; break; default: @@ -327,15 +344,17 @@ export class RoomConnection implements RoomConnection { private toMessageUserJoined(message: UserJoinedMessage): MessageUserJoined { const position = message.getPosition(); if (position === undefined) { - throw new Error('Invalid JOIN_ROOM message'); + throw new Error("Invalid JOIN_ROOM message"); } - const characterLayers = message.getCharacterlayersList().map((characterLayer: CharacterLayerMessage): BodyResourceDescriptionInterface => { - return { - name: characterLayer.getName(), - img: characterLayer.getUrl() - } - }) + const characterLayers = message + .getCharacterlayersList() + .map((characterLayer: CharacterLayerMessage): BodyResourceDescriptionInterface => { + return { + name: characterLayer.getName(), + img: characterLayer.getUrl(), + }; + }); const companion = message.getCompanion(); @@ -345,8 +364,9 @@ export class RoomConnection implements RoomConnection { characterLayers, visitCardUrl: message.getVisitcardurl(), position: ProtobufClientUtils.toPointInterface(position), - companion: companion ? companion.getName() : null - } + companion: companion ? companion.getName() : null, + userUuid: message.getUseruuid(), + }; } public onUserMoved(callback: (message: UserMovedMessage) => void): void { @@ -372,7 +392,9 @@ export class RoomConnection implements RoomConnection { }); } - public onGroupUpdatedOrCreated(callback: (groupCreateUpdateMessage: GroupCreatedUpdatedMessageInterface) => void): void { + public onGroupUpdatedOrCreated( + callback: (groupCreateUpdateMessage: GroupCreatedUpdatedMessageInterface) => void + ): void { this.onMessage(EventMessage.GROUP_CREATE_UPDATE, (message: GroupUpdateMessage) => { callback(this.toGroupCreatedUpdatedMessage(message)); }); @@ -381,14 +403,14 @@ export class RoomConnection implements RoomConnection { private toGroupCreatedUpdatedMessage(message: GroupUpdateMessage): GroupCreatedUpdatedMessageInterface { const position = message.getPosition(); if (position === undefined) { - throw new Error('Missing position in GROUP_CREATE_UPDATE'); + throw new Error("Missing position in GROUP_CREATE_UPDATE"); } return { groupId: message.getGroupid(), position: position.toObject(), - groupSize: message.getGroupsize() - } + groupSize: message.getGroupsize(), + }; } public onGroupDeleted(callback: (groupId: number) => void): void { @@ -404,7 +426,7 @@ export class RoomConnection implements RoomConnection { } public onConnectError(callback: (error: Event) => void): void { - this.socket.addEventListener('error', callback) + this.socket.addEventListener("error", callback); } public onConnect(callback: (roomConnection: OnConnectInterface) => void): void { @@ -445,7 +467,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, @@ -476,11 +497,11 @@ export class RoomConnection implements RoomConnection { } public onServerDisconnected(callback: () => void): void { - this.socket.addEventListener('close', (event) => { + this.socket.addEventListener("close", (event) => { if (this.closed === true || connectionManager.unloading) { return; } - console.log('Socket closed with code ' + event.code + ". Reason: " + event.reason); + console.log("Socket closed with code " + event.code + ". Reason: " + event.reason); if (event.code === 1000) { // Normal closure case return; @@ -490,14 +511,14 @@ export class RoomConnection implements RoomConnection { } public getUserId(): number { - if (this.userId === null) throw 'UserId cannot be null!' + if (this.userId === null) throw "UserId cannot be null!"; return this.userId; } disconnectMessage(callback: (message: WebRtcDisconnectMessageInterface) => void): void { this.onMessage(EventMessage.WEBRTC_DISCONNECT, (message: WebRtcDisconnectMessage) => { callback({ - userId: message.getUserid() + userId: message.getUserid(), }); }); } @@ -521,21 +542,22 @@ export class RoomConnection implements RoomConnection { itemId: message.getItemid(), event: message.getEvent(), parameters: JSON.parse(message.getParametersjson()), - state: JSON.parse(message.getStatejson()) + state: JSON.parse(message.getStatejson()), }); }); } public uploadAudio(file: FormData) { - return Axios.post(`${UPLOADER_URL}/upload-audio-message`, file).then((res: { data: {} }) => { - return res.data; - }).catch((err) => { - console.error(err); - throw err; - }); + return Axios.post(`${UPLOADER_URL}/upload-audio-message`, file) + .then((res: { data: {} }) => { + return res.data; + }) + .catch((err) => { + console.error(err); + throw err; + }); } - public receivePlayGlobalMessage(callback: (message: PlayGlobalMessageInterface) => void) { return this.onMessage(EventMessage.PLAY_GLOBAL_MESSAGE, (message: PlayGlobalMessage) => { callback({ @@ -570,9 +592,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(); @@ -605,12 +627,12 @@ export class RoomConnection implements RoomConnection { } public isAdmin(): boolean { - return this.hasTag('admin'); + return this.hasTag("admin"); } public emitEmoteEvent(emoteName: string): void { const emoteMessage = new EmotePromptMessage(); - emoteMessage.setEmote(emoteName) + emoteMessage.setEmote(emoteName); const clientToServerMessage = new ClientToServerMessage(); clientToServerMessage.setEmotepromptmessage(emoteMessage); @@ -618,7 +640,7 @@ export class RoomConnection implements RoomConnection { this.socket.send(clientToServerMessage.serializeBinary().buffer); } - public getAllTags() : string[] { + public getAllTags(): string[] { return this.tags; } } 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/Components/TextUtils.ts b/front/src/Phaser/Components/TextUtils.ts index db9a97fb..972c50c7 100644 --- a/front/src/Phaser/Components/TextUtils.ts +++ b/front/src/Phaser/Components/TextUtils.ts @@ -44,7 +44,6 @@ export class TextUtils { options.align = object.text.halign; } - console.warn(options); const textElem = scene.add.text(object.x, object.y, object.text.text, options); textElem.setAngle(object.rotation); } 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..d2f12013 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 {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/DirtyScene.ts b/front/src/Phaser/Game/DirtyScene.ts index 20602cca..13b0fa14 100644 --- a/front/src/Phaser/Game/DirtyScene.ts +++ b/front/src/Phaser/Game/DirtyScene.ts @@ -1,17 +1,17 @@ -import {ResizableScene} from "../Login/ResizableScene"; +import { ResizableScene } from "../Login/ResizableScene"; import GameObject = Phaser.GameObjects.GameObject; import Events = Phaser.Scenes.Events; import AnimationEvents = Phaser.Animations.Events; import StructEvents = Phaser.Structs.Events; -import {SKIP_RENDER_OPTIMIZATIONS} from "../../Enum/EnvironmentVariable"; +import { SKIP_RENDER_OPTIMIZATIONS } from "../../Enum/EnvironmentVariable"; /** * A scene that can track its dirty/pristine state. */ export abstract class DirtyScene extends ResizableScene { private isAlreadyTracking: boolean = false; - protected dirty:boolean = true; - private objectListChanged:boolean = true; + protected dirty: boolean = true; + private objectListChanged: boolean = true; private physicsEnabled: boolean = false; /** @@ -59,7 +59,6 @@ export abstract class DirtyScene extends ResizableScene { this.physicsEnabled = false; } }); - } private trackAnimation(): void { @@ -71,7 +70,7 @@ export abstract class DirtyScene extends ResizableScene { } public markDirty(): void { - this.events.once(Phaser.Scenes.Events.POST_UPDATE, () => this.dirty = true); + this.events.once(Phaser.Scenes.Events.POST_UPDATE, () => (this.dirty = true)); } public onResize(): void { diff --git a/front/src/Phaser/Game/GameManager.ts b/front/src/Phaser/Game/GameManager.ts index a694b32e..7f0b2061 100644 --- a/front/src/Phaser/Game/GameManager.ts +++ b/front/src/Phaser/Game/GameManager.ts @@ -1,26 +1,24 @@ -import {GameScene} from "./GameScene"; -import {connectionManager} from "../../Connexion/ConnectionManager"; -import type {Room} from "../../Connexion/Room"; -import {MenuScene, MenuSceneName} from "../Menu/MenuScene"; -import {LoginSceneName} from "../Login/LoginScene"; -import {SelectCharacterSceneName} from "../Login/SelectCharacterScene"; -import {EnableCameraSceneName} from "../Login/EnableCameraScene"; -import {localUserStore} from "../../Connexion/LocalUserStore"; -import {get} from "svelte/store"; -import {requestedCameraState, requestedMicrophoneState} from "../../Stores/MediaStore"; -import {helpCameraSettingsVisibleStore} from "../../Stores/HelpCameraSettingsStore"; - - +import { GameScene } from "./GameScene"; +import { connectionManager } from "../../Connexion/ConnectionManager"; +import type { Room } from "../../Connexion/Room"; +import { MenuScene, MenuSceneName } from "../Menu/MenuScene"; +import { LoginSceneName } from "../Login/LoginScene"; +import { SelectCharacterSceneName } from "../Login/SelectCharacterScene"; +import { EnableCameraSceneName } from "../Login/EnableCameraScene"; +import { localUserStore } from "../../Connexion/LocalUserStore"; +import { get } from "svelte/store"; +import { requestedCameraState, requestedMicrophoneState } from "../../Stores/MediaStore"; +import { helpCameraSettingsVisibleStore } from "../../Stores/HelpCameraSettingsStore"; /** * This class should be responsible for any scene starting/stopping */ export class GameManager { - private playerName: string|null; - private characterLayers: string[]|null; - private companion: string|null; - private startRoom!:Room; - currentGameSceneName: string|null = null; + private playerName: string | null; + private characterLayers: string[] | null; + private companion: string | null; + private startRoom!: Room; + currentGameSceneName: string | null = null; constructor() { this.playerName = localUserStore.getName(); @@ -30,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; @@ -51,43 +49,44 @@ export class GameManager { localUserStore.setCharacterLayers(layers); } - getPlayerName(): string|null { + getPlayerName(): string | null { return this.playerName; } getCharacterLayers(): string[] { if (!this.characterLayers) { - throw 'characterLayers are not set'; + throw "characterLayers are not set"; } return this.characterLayers; } - - setCompanion(companion: string|null): void { + setCompanion(companion: string | null): void { this.companion = companion; } - getCompanion(): string|null { + getCompanion(): string | null { 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); + if (gameIndex === -1) { + 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(!localUserStore.getHelpCameraSettingsShown() && (!get(requestedMicrophoneState) || !get(requestedCameraState))){ + if ( + !localUserStore.getHelpCameraSettingsShown() && + (!get(requestedMicrophoneState) || !get(requestedCameraState)) + ) { helpCameraSettingsVisibleStore.set(true); localUserStore.setHelpCameraSettingsShown(); } @@ -104,7 +103,7 @@ export class GameManager { * This will close the socket connections and stop the gameScene, but won't remove it. */ leaveGame(scene: Phaser.Scene, targetSceneName: string, sceneClass: Phaser.Scene): void { - if (this.currentGameSceneName === null) throw 'No current scene id set!'; + if (this.currentGameSceneName === null) throw "No current scene id set!"; const gameScene: GameScene = scene.scene.get(this.currentGameSceneName) as GameScene; gameScene.cleanupClosingScene(); scene.scene.stop(this.currentGameSceneName); @@ -123,13 +122,13 @@ export class GameManager { scene.scene.start(this.currentGameSceneName); scene.scene.wake(MenuSceneName); } else { - scene.scene.run(fallbackSceneName) + scene.scene.run(fallbackSceneName); } } public getCurrentGameScene(scene: Phaser.Scene): GameScene { - if (this.currentGameSceneName === null) throw 'No current scene id set!'; - return scene.scene.get(this.currentGameSceneName) as GameScene + if (this.currentGameSceneName === null) throw "No current scene id set!"; + return scene.scene.get(this.currentGameSceneName) as GameScene; } } diff --git a/front/src/Phaser/Game/GameMap.ts b/front/src/Phaser/Game/GameMap.ts index f68c414a..d2b28531 100644 --- a/front/src/Phaser/Game/GameMap.ts +++ b/front/src/Phaser/Game/GameMap.ts @@ -1,9 +1,13 @@ -import type {ITiledMap, ITiledMapLayer, ITiledMapLayerProperty} from "../Map/ITiledMap"; +import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty } from "../Map/ITiledMap"; import { flattenGroupLayersMap } from "../Map/LayersFlattener"; import TilemapLayer = Phaser.Tilemaps.TilemapLayer; import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes"; -export type PropertyChangeCallback = (newValue: string | number | boolean | undefined, oldValue: string | number | boolean | undefined, allProps: Map) => void; +export type PropertyChangeCallback = ( + newValue: string | number | boolean | undefined, + oldValue: string | number | boolean | undefined, + allProps: Map +) => void; /** * A wrapper around a ITiledMap interface to provide additional capabilities. @@ -19,37 +23,50 @@ export class GameMap { public readonly flatLayers: ITiledMapLayer[]; public readonly phaserLayers: TilemapLayer[] = []; - public exitUrls: Array = [] + public exitUrls: Array = []; - public constructor(private map: ITiledMap, phaserMap: Phaser.Tilemaps.Tilemap, terrains: Array) { + public hasStartTile = false; + + public constructor( + private map: ITiledMap, + phaserMap: Phaser.Tilemaps.Tilemap, + terrains: Array + ) { this.flatLayers = flattenGroupLayersMap(map); let depth = -2; for (const layer of this.flatLayers) { - if(layer.type === 'tilelayer'){ + if (layer.type === "tilelayer") { this.phaserLayers.push(phaserMap.createLayer(layer.name, terrains, 0, 0).setDepth(depth)); } - if (layer.type === 'objectgroup' && layer.name === 'floorLayer') { + if (layer.type === "objectgroup" && layer.name === "floorLayer") { depth = DEPTH_OVERLAY_INDEX; } } for (const tileset of map.tilesets) { - tileset?.tiles?.forEach(tile => { + tileset?.tiles?.forEach((tile) => { if (tile.properties) { - this.tileSetPropertyMap[tileset.firstgid + tile.id] = tile.properties - tile.properties.forEach(prop => { - if (prop.name == 'name' && typeof prop.value == "string") { + this.tileSetPropertyMap[tileset.firstgid + tile.id] = tile.properties; + tile.properties.forEach((prop) => { + if (prop.name == "name" && typeof prop.value == "string") { this.tileNameMap.set(prop.value, tileset.firstgid + tile.id); } if (prop.name == "exitUrl" && typeof prop.value == "string") { this.exitUrls.push(prop.value); + } else if (prop.name == "start") { + this.hasStartTile = true; } - }) + }); } - }) + }); } } - + public getPropertiesForIndex(index: number): Array { + if (this.tileSetPropertyMap[index]) { + return this.tileSetPropertyMap[index]; + } + return []; + } /** * Sets the position of the current player (in pixels) @@ -93,7 +110,7 @@ export class GameMap { const properties = new Map(); for (const layer of this.flatLayers) { - if (layer.type !== 'tilelayer') { + if (layer.type !== "tilelayer") { continue; } @@ -103,7 +120,7 @@ export class GameMap { if (tiles[key] == 0) { continue; } - tileIndex = tiles[key] + tileIndex = tiles[key]; } // There is a tile in this layer, let's embed the properties @@ -117,28 +134,36 @@ export class GameMap { } if (tileIndex) { - this.tileSetPropertyMap[tileIndex]?.forEach(property => { + this.tileSetPropertyMap[tileIndex]?.forEach((property) => { if (property.value) { - properties.set(property.name, property.value) + properties.set(property.name, property.value); } else if (properties.has(property.name)) { - properties.delete(property.name) + properties.delete(property.name); } - }) + }); } } return properties; } - public getMap(): ITiledMap{ + public getMap(): ITiledMap { return this.map; } private getTileProperty(index: number): Array { - return this.tileSetPropertyMap[index]; + if (this.tileSetPropertyMap[index]) { + return this.tileSetPropertyMap[index]; + } + return []; } - private trigger(propName: string, oldValue: string | number | boolean | undefined, newValue: string | number | boolean | undefined, allProps: Map) { + private trigger( + propName: string, + oldValue: string | number | boolean | undefined, + newValue: string | number | boolean | undefined, + allProps: Map + ) { const callbacksArray = this.callbacks.get(propName); if (callbacksArray !== undefined) { for (const callback of callbacksArray) { @@ -167,7 +192,11 @@ export class GameMap { return this.phaserLayers.find((layer) => layer.layer.name === layerName); } - public addTerrain(terrain : Phaser.Tilemaps.Tileset): void { + 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); } @@ -175,40 +204,46 @@ 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."); + if (fLayer == undefined) { + 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."); + if (fLayer.type !== "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 (phaserLayer) { + if (tile === null) { + phaserLayer.putTileAt(-1, x, y); + return; + } const tileIndex = this.getIndexForTileType(tile); - if ( tileIndex !== undefined ) { + 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 '" + tile + "' that you want to place doesn't exist."); } - else { - console.error("The 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."); + } else { + console.error("The layer '" + layer + "' does not exist (or is not a tilelaye)."); } } @@ -218,5 +253,4 @@ export class GameMap { } return this.tileNameMap.get(tile); } - } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index fa95399e..37f3937d 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -1,4 +1,3 @@ -import { Queue } from "queue-typescript"; import type { Subscription } from "rxjs"; import { GlobalMessageManager } from "../../Administration/GlobalMessageManager"; import { userMessageManager } from "../../Administration/UserMessageManager"; @@ -14,20 +13,9 @@ import type { PositionInterface, RoomJoinedMessageInterface, } from "../../Connexion/ConnexionModels"; -import { localUserStore } from "../../Connexion/LocalUserStore"; -import { Room } from "../../Connexion/Room"; -import type { RoomConnection } from "../../Connexion/RoomConnection"; -import { worldFullMessageStream } from "../../Connexion/WorldFullMessageStream"; import { DEBUG_MODE, JITSI_PRIVATE_MODE, MAX_PER_GROUP, POSITION_DELAY } from "../../Enum/EnvironmentVariable"; -import { TextureError } from "../../Exception/TextureError"; -import type { UserMovedMessage } from "../../Messages/generated/messages_pb"; -import { ProtobufClientUtils } from "../../Network/ProtobufClientUtils"; -import { touchScreenManager } from "../../Touch/TouchScreenManager"; -import { urlManager } from "../../Url/UrlManager"; -import { audioManager } from "../../WebRtc/AudioManager"; -import { coWebsiteManager } from "../../WebRtc/CoWebsiteManager"; -import { HtmlUtils } from "../../WebRtc/HtmlUtils"; -import { jitsiFactory } from "../../WebRtc/JitsiFactory"; + +import { Queue } from "queue-typescript"; import { AUDIO_LOOP_PROPERTY, AUDIO_VOLUME_PROPERTY, @@ -39,15 +27,21 @@ import { TRIGGER_WEBSITE_PROPERTIES, WEBSITE_MESSAGE_PROPERTIES, } from "../../WebRtc/LayoutManager"; +import { coWebsiteManager } from "../../WebRtc/CoWebsiteManager"; +import type { UserMovedMessage } from "../../Messages/generated/messages_pb"; +import { ProtobufClientUtils } from "../../Network/ProtobufClientUtils"; +import type { RoomConnection } from "../../Connexion/RoomConnection"; +import { Room } from "../../Connexion/Room"; +import { jitsiFactory } from "../../WebRtc/JitsiFactory"; +import { urlManager } from "../../Url/UrlManager"; +import { audioManager } from "../../WebRtc/AudioManager"; +import { TextureError } from "../../Exception/TextureError"; +import { localUserStore } from "../../Connexion/LocalUserStore"; +import { HtmlUtils } from "../../WebRtc/HtmlUtils"; import { mediaManager } from "../../WebRtc/MediaManager"; -import { SimplePeer, UserSimplePeerInterface } from "../../WebRtc/SimplePeer"; -import { lazyLoadCompanionResource } from "../Companion/CompanionTexturesLoadingManager"; -import { ChatModeIcon } from "../Components/ChatModeIcon"; +import { SimplePeer } from "../../WebRtc/SimplePeer"; import { addLoader } from "../Components/Loader"; -import { joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey } from "../Components/MobileJoystick"; import { OpenChatIcon, openChatIconName } from "../Components/OpenChatIcon"; -import { PresentationModeIcon } from "../Components/PresentationModeIcon"; -import { TextUtils } from "../Components/TextUtils"; import { lazyLoadPlayerCharacterTextures, loadCustomTexture } from "../Entity/PlayerTexturesLoadingManager"; import { RemotePlayer } from "../Entity/RemotePlayer"; import type { ActionableItem } from "../Items/ActionableItem"; @@ -58,7 +52,6 @@ import type { ITiledMapLayer, ITiledMapLayerProperty, ITiledMapObject, - ITiledMapTileLayer, ITiledTileSet, } from "../Map/ITiledMap"; import { MenuScene, MenuSceneName } from "../Menu/MenuScene"; @@ -66,13 +59,8 @@ import { PlayerAnimationDirections } from "../Player/Animation"; import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player"; import { ErrorSceneName } from "../Reconnecting/ErrorScene"; import { ReconnectingSceneName } from "../Reconnecting/ReconnectingScene"; -import { waScaleManager } from "../Services/WaScaleManager"; -import { PinchManager } from "../UserInput/PinchManager"; import { UserInputManager } from "../UserInput/UserInputManager"; import type { AddPlayerInterface } from "./AddPlayerInterface"; -import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes"; -import { DirtyScene } from "./DirtyScene"; -import { EmoteManager } from "./EmoteManager"; import { gameManager } from "./GameManager"; import { GameMap } from "./GameMap"; import { PlayerMovement } from "./PlayerMovement"; @@ -83,16 +71,28 @@ import CanvasTexture = Phaser.Textures.CanvasTexture; import GameObject = Phaser.GameObjects.GameObject; import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR; import DOMElement = Phaser.GameObjects.DOMElement; +import { worldFullMessageStream } from "../../Connexion/WorldFullMessageStream"; +import { lazyLoadCompanionResource } from "../Companion/CompanionTexturesLoadingManager"; +import { DirtyScene } from "./DirtyScene"; +import { TextUtils } from "../Components/TextUtils"; +import { touchScreenManager } from "../../Touch/TouchScreenManager"; +import { PinchManager } from "../UserInput/PinchManager"; +import { joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey } from "../Components/MobileJoystick"; +import { waScaleManager } from "../Services/WaScaleManager"; +import { EmoteManager } from "./EmoteManager"; import EVENT_TYPE = Phaser.Scenes.Events; import RenderTexture = Phaser.GameObjects.RenderTexture; import Tilemap = Phaser.Tilemaps.Tilemap; import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent"; import AnimatedTiles from "phaser-animated-tiles"; +import { StartPositionCalculator } from "./StartPositionCalculator"; import { soundManager } from "./SoundManager"; import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore"; import { videoFocusStore } from "../../Stores/VideoFocusStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; +import { playersStore } from "../../Stores/PlayersStore"; +import { chatVisibilityStore } from "../../Stores/ChatStore"; export interface GameSceneInitInterface { initPosition: PointInterface | null; @@ -129,8 +129,6 @@ interface DeleteGroupEventInterface { groupId: number; } -const defaultStartLayerName = "start"; - export class GameScene extends DirtyScene { Terrains: Array; CurrentPlayer!: Player; @@ -141,8 +139,6 @@ export class GameScene extends DirtyScene { mapFile!: ITiledMap; animatedTiles!: AnimatedTiles; groups: Map; - startX!: number; - startY!: number; circleTexture!: CanvasTexture; circleRedTexture!: CanvasTexture; pendingEvents: Queue< @@ -174,9 +170,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; @@ -194,7 +191,6 @@ export class GameScene extends DirtyScene { private outlinedItem: ActionableItem | null = null; public userInputManager!: UserInputManager; private isReconnecting: boolean | undefined = undefined; - private startLayerName!: string | null; private openChatIcon!: OpenChatIcon; private playerName!: string; private characterLayers!: string[]; @@ -206,17 +202,18 @@ 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; 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; @@ -426,7 +423,6 @@ export class GameScene extends DirtyScene { gameManager.gameSceneIsCreated(this); urlManager.pushRoomIdToUrl(this.room); - this.startLayerName = urlManager.getStartLayerNameFromUrl(); if (touchScreenManager.supportTouchScreen) { this.pinchManager = new PinchManager(this); @@ -469,11 +465,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") { @@ -486,10 +484,15 @@ export class GameScene extends DirtyScene { } this.gameMap.exitUrls.forEach((exitUrl) => { - this.loadNextGame(exitUrl); + this.loadNextGameFromExitUrl(exitUrl); }); - this.initStartXAndStartY(); + this.startPositionCalculator = new StartPositionCalculator( + this.gameMap, + this.mapFile, + this.initPosition, + urlManager.getStartLayerNameFromUrl() + ); //add entities this.Objects = new Array(); @@ -572,6 +575,10 @@ export class GameScene extends DirtyScene { } oldPeerNumber = newPeerNumber; }); + + this.chatVisibilityUnsubscribe = chatVisibilityStore.subscribe((v) => { + this.openChatIcon.setVisible(!v); + }); } /** @@ -582,12 +589,11 @@ export class GameScene extends DirtyScene { connectionManager .connectToRoomSocket( - this.RoomId, + this.roomUrl, this.playerName, this.characterLayers, { - x: this.startX, - y: this.startY, + ...this.startPositionCalculator.startPosition, }, { left: camera.scrollX, @@ -600,6 +606,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, @@ -608,6 +616,7 @@ export class GameScene extends DirtyScene { position: message.position, visitCardUrl: message.visitCardUrl, companion: message.companion, + userUuid: message.userUuid, }; this.addPlayer(userMessage); }); @@ -691,12 +700,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(); } }, @@ -768,10 +777,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) { @@ -989,16 +1001,16 @@ ${escapedMessage} }) ); - this.iframeSubscriptionList.push( - iframeListener.enablePlayerControlStream.subscribe(()=>{ + this.iframeSubscriptionList.push( + iframeListener.enablePlayerControlStream.subscribe(() => { this.userInputManager.restoreControls(); }) ); 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())); }); }); }) @@ -1047,24 +1059,23 @@ ${escapedMessage} }) ); + iframeListener.registerAnswerer("getState", () => { + return { + mapUrl: this.MapUrlFile, + startLayerName: this.startPositionCalculator.startLayerName, + uuid: localUserStore.getLocalUser()?.uuid, + nickname: localUserStore.getName(), + roomId: this.roomUrl, + tags: this.connection ? this.connection.getAllTags() : [], + }; + }); this.iframeSubscriptionList.push( - iframeListener.gameStateStream.subscribe(() => { - iframeListener.sendGameStateEvent({ - mapUrl: this.MapUrlFile, - startLayerName: this.startLayerName, - uuid: localUserStore.getLocalUser()?.uuid, - nickname: localUserStore.getName(), - roomId: this.RoomId, - tags: this.connection ? this.connection.getAllTags() : [], - }); + iframeListener.setTilesStream.subscribe((eventTiles) => { + for (const eventTile of eventTiles) { + this.gameMap.putTile(eventTile.tile, eventTile.x, eventTile.y, eventTile.layer); + } }) - ) - this.iframeSubscriptionList.push(iframeListener.setTilesStream.subscribe((eventTiles) => { - for (const eventTile of eventTiles) { - this.gameMap.putTile(eventTile.tile, eventTile.x, eventTile.y, eventTile.layer); - } - })) - + ); } private setPropertyLayer( @@ -1077,53 +1088,88 @@ ${escapedMessage} console.warn('Could not find layer "' + layerName + '" when calling setProperty'); return; } - const property = (layer.properties as ITiledMapLayerProperty[])?.find( - (property) => property.name === propertyName - ); - if (property === undefined) { + 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); - 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.initPositionFromLayerName(hash || defaultStartLayerName); - this.CurrentPlayer.x = this.startX; - this.CurrentPlayer.y = this.startY; + 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); } } @@ -1148,7 +1194,9 @@ ${escapedMessage} this.pinchManager?.destroy(); this.emoteManager.destroy(); this.peerStoreUnsubscribe(); + this.chatVisibilityUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe(); + iframeListener.unregisterAnswerer("getState"); mediaManager.hideGameOverlay(); @@ -1170,46 +1218,6 @@ ${escapedMessage} this.MapPlayersByKey = new Map(); } - private initStartXAndStartY() { - // If there is an init position passed - if (this.initPosition !== null) { - this.startX = this.initPosition.x; - this.startY = this.initPosition.y; - } else { - // Now, let's find the start layer - if (this.startLayerName) { - this.initPositionFromLayerName(this.startLayerName); - } - if (this.startX === undefined) { - // If we have no start layer specified or if the hash passed does not exist, let's go with the default start position. - this.initPositionFromLayerName(defaultStartLayerName); - } - } - // Still no start position? Something is wrong with the map, we need a "start" layer. - if (this.startX === undefined) { - console.warn( - 'This map is missing a layer named "start" that contains the available default start positions.' - ); - // Let's start in the middle of the map - this.startX = this.mapFile.width * 16; - this.startY = this.mapFile.height * 16; - } - } - - private initPositionFromLayerName(layerName: string) { - for (const layer of this.gameMap.flatLayers) { - if ( - (layerName === layer.name || layer.name.endsWith("/" + layerName)) && - layer.type === "tilelayer" && - (layerName === defaultStartLayerName || this.isStartLayer(layer)) - ) { - const startPosition = this.startUser(layer); - this.startX = startPosition.x + this.mapFile.tilewidth / 2; - this.startY = startPosition.y + this.mapFile.tileheight / 2; - } - } - } - private getExitUrl(layer: ITiledMapLayer): string | undefined { return this.getProperty(layer, "exitUrl") as string | undefined; } @@ -1221,10 +1229,6 @@ ${escapedMessage} return this.getProperty(layer, "exitSceneUrl") as string | undefined; } - private isStartLayer(layer: ITiledMapLayer): boolean { - return this.getProperty(layer, "startLayer") == true; - } - private getScriptUrls(map: ITiledMap): string[] { return (this.getProperties(map, "script") as string[]).map((script) => new URL(script, this.MapUrlFile).toString() @@ -1255,38 +1259,18 @@ ${escapedMessage} .map((property) => property.value); } - //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 loadNextGameFromExitUrl(exitUrl: string): Promise { + return this.loadNextGame(Room.getRoomPathFromExitUrl(exitUrl, window.location.toString())); } - private startUser(layer: ITiledMapTileLayer): PositionInterface { - const tiles = layer.data; - if (typeof tiles === "string") { - throw new Error("The content of a JSON map must be filled as a JSON array, not as a string"); + //todo: push that into the gameManager + 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); } - const possibleStartPositions: PositionInterface[] = []; - tiles.forEach((objectKey: number, key: number) => { - if (objectKey === 0) { - return; - } - const y = Math.floor(key / layer.width); - const x = key % layer.width; - - possibleStartPositions.push({ x: x * this.mapFile.tilewidth, y: y * this.mapFile.tilewidth }); - }); - // Get a value at random amongst allowed values - if (possibleStartPositions.length === 0) { - console.warn('The start layer "' + layer.name + '" for this map is empty.'); - return { - x: 0, - y: 0, - }; - } - // Choose one of the available start positions at random amongst the list of available start positions. - return possibleStartPositions[Math.floor(Math.random() * possibleStartPositions.length)]; } //todo: in a dedicated class/function? @@ -1321,8 +1305,8 @@ ${escapedMessage} try { this.CurrentPlayer = new Player( this, - this.startX, - this.startY, + this.startPositionCalculator.startPosition.x, + this.startPositionCalculator.startPosition.y, this.playerName, texturesPromise, PlayerAnimationDirections.Down, @@ -1729,7 +1713,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", }); } @@ -1744,14 +1728,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/PlayerMovement.ts b/front/src/Phaser/Game/PlayerMovement.ts index 2369b86b..c3daedad 100644 --- a/front/src/Phaser/Game/PlayerMovement.ts +++ b/front/src/Phaser/Game/PlayerMovement.ts @@ -1,10 +1,14 @@ import { MAX_EXTRAPOLATION_TIME } from "../../Enum/EnvironmentVariable"; import type { PositionInterface } from "../../Connexion/ConnexionModels"; -import type { HasPlayerMovedEvent } from '../../Api/Events/HasPlayerMovedEvent'; +import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent"; export class PlayerMovement { - public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasPlayerMovedEvent, private endTick: number) { - } + public constructor( + private startPosition: PositionInterface, + private startTick: number, + private endPosition: HasPlayerMovedEvent, + private endTick: number + ) {} public isOutdated(tick: number): boolean { //console.log(tick, this.endTick, MAX_EXTRAPOLATION_TIME) @@ -24,14 +28,18 @@ export class PlayerMovement { return this.endPosition; } - const x = (this.endPosition.x - this.startPosition.x) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.x; - const y = (this.endPosition.y - this.startPosition.y) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.y; + const x = + (this.endPosition.x - this.startPosition.x) * ((tick - this.startTick) / (this.endTick - this.startTick)) + + this.startPosition.x; + const y = + (this.endPosition.y - this.startPosition.y) * ((tick - this.startTick) / (this.endTick - this.startTick)) + + this.startPosition.y; //console.log('Computed position ', x, y) return { x, y, direction: this.endPosition.direction, - moving: true - } + moving: true, + }; } } diff --git a/front/src/Phaser/Game/PlayersPositionInterpolator.ts b/front/src/Phaser/Game/PlayersPositionInterpolator.ts index 86b6c761..b9a7c8ec 100644 --- a/front/src/Phaser/Game/PlayersPositionInterpolator.ts +++ b/front/src/Phaser/Game/PlayersPositionInterpolator.ts @@ -2,7 +2,7 @@ * This class is in charge of computing the position of all players. * Player movement is delayed by 200ms so position depends on ticks. */ -import type { HasPlayerMovedEvent } from '../../Api/Events/HasPlayerMovedEvent'; +import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent"; import type { PlayerMovement } from "./PlayerMovement"; export class PlayersPositionInterpolator { @@ -24,7 +24,7 @@ export class PlayersPositionInterpolator { this.playerMovements.delete(userId); } //console.log("moving") - positions.set(userId, playerMovement.getPosition(tick)) + positions.set(userId, playerMovement.getPosition(tick)); }); return positions; } diff --git a/front/src/Phaser/Game/StartPositionCalculator.ts b/front/src/Phaser/Game/StartPositionCalculator.ts new file mode 100644 index 00000000..7460c81c --- /dev/null +++ b/front/src/Phaser/Game/StartPositionCalculator.ts @@ -0,0 +1,127 @@ +import type { PositionInterface } from "../../Connexion/ConnexionModels"; +import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty, ITiledMapTileLayer } from "../Map/ITiledMap"; +import type { GameMap } from "./GameMap"; + +const defaultStartLayerName = "start"; + +export class StartPositionCalculator { + public startPosition!: PositionInterface; + + constructor( + private readonly gameMap: GameMap, + private readonly mapFile: ITiledMap, + private readonly initPosition: PositionInterface | null, + public readonly startLayerName: string | null + ) { + this.initStartXAndStartY(); + } + private initStartXAndStartY() { + // If there is an init position passed + if (this.initPosition !== null) { + this.startPosition = this.initPosition; + } else { + // Now, let's find the start layer + if (this.startLayerName) { + this.initPositionFromLayerName(this.startLayerName, this.startLayerName); + } + if (this.startPosition === undefined) { + // If we have no start layer specified or if the hash passed does not exist, let's go with the default start position. + this.initPositionFromLayerName(defaultStartLayerName, this.startLayerName); + } + } + // Still no start position? Something is wrong with the map, we need a "start" layer. + if (this.startPosition === undefined) { + console.warn( + 'This map is missing a layer named "start" that contains the available default start positions.' + ); + // Let's start in the middle of the map + this.startPosition = { + x: this.mapFile.width * 16, + y: this.mapFile.height * 16, + }; + } + } + + /** + * + * @param selectedLayer this is always the layer that is selected with the hash in the url + * @param selectedOrDefaultLayer this can also be the {defaultStartLayerName} if the {selectedLayer} didnt yield any start points + */ + public initPositionFromLayerName(selectedOrDefaultLayer: string | null, selectedLayer: string | null) { + if (!selectedOrDefaultLayer) { + selectedOrDefaultLayer = defaultStartLayerName; + } + for (const layer of this.gameMap.flatLayers) { + if ( + (selectedOrDefaultLayer === layer.name || layer.name.endsWith("/" + selectedOrDefaultLayer)) && + layer.type === "tilelayer" && + (selectedOrDefaultLayer === defaultStartLayerName || this.isStartLayer(layer)) + ) { + const startPosition = this.startUser(layer, selectedLayer); + this.startPosition = { + x: startPosition.x + this.mapFile.tilewidth / 2, + y: startPosition.y + this.mapFile.tileheight / 2, + }; + } + } + } + + private isStartLayer(layer: ITiledMapLayer): boolean { + return this.getProperty(layer, "startLayer") == true; + } + + /** + * + * @param selectedLayer this is always the layer that is selected with the hash in the url + * @param selectedOrDefaultLayer this can also be the default layer if the {selectedLayer} didnt yield any start points + */ + private startUser(selectedOrDefaultLayer: ITiledMapTileLayer, selectedLayer: string | null): PositionInterface { + const tiles = selectedOrDefaultLayer.data; + if (typeof tiles === "string") { + throw new Error("The content of a JSON map must be filled as a JSON array, not as a string"); + } + const possibleStartPositions: PositionInterface[] = []; + tiles.forEach((objectKey: number, key: number) => { + if (objectKey === 0) { + return; + } + const y = Math.floor(key / selectedOrDefaultLayer.width); + const x = key % selectedOrDefaultLayer.width; + + if (selectedLayer && this.gameMap.hasStartTile) { + const properties = this.gameMap.getPropertiesForIndex(objectKey); + if ( + !properties.length || + !properties.some((property) => property.name == "start" && property.value == selectedLayer) + ) { + return; + } + } + possibleStartPositions.push({ x: x * this.mapFile.tilewidth, y: y * this.mapFile.tilewidth }); + }); + // Get a value at random amongst allowed values + if (possibleStartPositions.length === 0) { + console.warn('The start layer "' + selectedOrDefaultLayer.name + '" for this map is empty.'); + return { + x: 0, + y: 0, + }; + } + // Choose one of the available start positions at random amongst the list of available start positions. + return possibleStartPositions[Math.floor(Math.random() * possibleStartPositions.length)]; + } + + private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { + const properties: ITiledMapLayerProperty[] | undefined = layer.properties; + if (!properties) { + return undefined; + } + const obj = properties.find( + (property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() + ); + if (obj === undefined) { + return undefined; + } + return obj.value; + } +} 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 8ea9baf1..0653e83a 100644 --- a/front/src/Phaser/Map/ITiledMap.ts +++ b/front/src/Phaser/Map/ITiledMap.ts @@ -36,7 +36,7 @@ export interface ITiledMap { export interface ITiledMapLayerProperty { name: string; type: string; - value: string|boolean|number|undefined; + value: string | boolean | number | undefined; } /*export interface ITiledMapLayerBooleanProperty { @@ -48,7 +48,7 @@ export interface ITiledMapLayerProperty { export type ITiledMapLayer = ITiledMapGroupLayer | ITiledMapObjectLayer | ITiledMapTileLayer; export interface ITiledMapGroupLayer { - id?: number, + id?: number; name: string; opacity: number; properties?: ITiledMapLayerProperty[]; @@ -64,8 +64,8 @@ export interface ITiledMapGroupLayer { } export interface ITiledMapTileLayer { - id?: number, - data: number[]|string; + id?: number; + data: number[] | string; height: number; name: string; opacity: number; @@ -87,7 +87,7 @@ export interface ITiledMapTileLayer { } export interface ITiledMapObjectLayer { - id?: number, + id?: number; height: number; name: string; opacity: number; @@ -117,7 +117,7 @@ export interface ITiledMapObject { gid: number; height: number; name: string; - properties: {[key: string]: string}; + properties: { [key: string]: string }; rotation: number; type: string; visible: boolean; @@ -133,26 +133,26 @@ export interface ITiledMapObject { /** * Polygon points */ - polygon: {x: number, y: number}[]; + polygon: { x: number; y: number }[]; /** * Polyline points */ - polyline: {x: number, y: number}[]; + polyline: { x: number; y: number }[]; - text?: ITiledText + text?: ITiledText; } export interface ITiledText { - text: string, - wrap?: boolean, - fontfamily?: string, - pixelsize?: number, - color?: string, - underline?: boolean, - italic?: boolean, - strikeout?: boolean, - halign?: "center"|"right"|"justify"|"left" + text: string; + wrap?: boolean; + fontfamily?: string; + pixelsize?: number; + color?: string; + underline?: boolean; + italic?: boolean; + strikeout?: boolean; + halign?: "center" | "right" | "justify" | "left"; } export interface ITiledTileSet { @@ -163,7 +163,7 @@ export interface ITiledTileSet { imagewidth: number; margin: number; name: string; - properties: {[key: string]: string}; + properties: { [key: string]: string }; spacing: number; tilecount: number; tileheight: number; @@ -179,10 +179,10 @@ export interface ITiledTileSet { } export interface ITile { - id: number, - type?: string + id: number; + type?: string; - properties?: Array + properties?: Array; } export interface ITiledMapTerrain { diff --git a/front/src/Phaser/Map/LayersFlattener.ts b/front/src/Phaser/Map/LayersFlattener.ts index c5092779..d28402b5 100644 --- a/front/src/Phaser/Map/LayersFlattener.ts +++ b/front/src/Phaser/Map/LayersFlattener.ts @@ -1,21 +1,21 @@ -import type {ITiledMap, ITiledMapLayer} from "./ITiledMap"; +import type { ITiledMap, ITiledMapLayer } from "./ITiledMap"; /** * Flatten the grouped layers */ export function flattenGroupLayersMap(map: ITiledMap) { const flatLayers: ITiledMapLayer[] = []; - flattenGroupLayers(map.layers, '', flatLayers); + flattenGroupLayers(map.layers, "", flatLayers); return flatLayers; } -function flattenGroupLayers(layers : ITiledMapLayer[], prefix : string, flatLayers: ITiledMapLayer[]) { +function flattenGroupLayers(layers: ITiledMapLayer[], prefix: string, flatLayers: ITiledMapLayer[]) { for (const layer of layers) { - if (layer.type === 'group') { - flattenGroupLayers(layer.layers, prefix + layer.name + '/', flatLayers); + if (layer.type === "group") { + flattenGroupLayers(layer.layers, prefix + layer.name + "/", flatLayers); } else { - layer.name = prefix+layer.name + layer.name = prefix + layer.name; flatLayers.push(layer); } } -} \ No newline at end of file +} diff --git a/front/src/Phaser/Menu/MenuScene.ts b/front/src/Phaser/Menu/MenuScene.ts index e5ca5023..4e9297b6 100644 --- a/front/src/Phaser/Menu/MenuScene.ts +++ b/front/src/Phaser/Menu/MenuScene.ts @@ -1,29 +1,32 @@ -import {LoginScene, LoginSceneName} from "../Login/LoginScene"; -import {SelectCharacterScene, SelectCharacterSceneName} from "../Login/SelectCharacterScene"; -import {SelectCompanionScene, SelectCompanionSceneName} from "../Login/SelectCompanionScene"; -import {gameManager} from "../Game/GameManager"; -import {localUserStore} from "../../Connexion/LocalUserStore"; -import {gameReportKey, gameReportRessource, ReportMenu} from "./ReportMenu"; -import {connectionManager} from "../../Connexion/ConnectionManager"; -import {GameConnexionTypes} from "../../Url/UrlManager"; -import {WarningContainer, warningContainerHtml, warningContainerKey} from "../Components/WarningContainer"; -import {worldFullWarningStream} from "../../Connexion/WorldFullWarningStream"; -import {menuIconVisible} from "../../Stores/MenuStore"; -import {videoConstraintStore} from "../../Stores/MediaStore"; -import {showReportScreenStore} from "../../Stores/ShowReportScreenStore"; -import { HtmlUtils } from '../../WebRtc/HtmlUtils'; -import { iframeListener } from '../../Api/IframeListener'; -import { Subscription } from 'rxjs'; -import {registerMenuCommandStream} from "../../Api/Events/ui/MenuItemRegisterEvent"; -import {sendMenuClickedEvent} from "../../Api/iframe/Ui/MenuItem"; -import {consoleGlobalMessageManagerVisibleStore} from "../../Stores/ConsoleGlobalMessageManagerStore"; -import {get} from "svelte/store"; +import { LoginScene, LoginSceneName } from "../Login/LoginScene"; +import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene"; +import { SelectCompanionScene, SelectCompanionSceneName } from "../Login/SelectCompanionScene"; +import { gameManager } from "../Game/GameManager"; +import { localUserStore } from "../../Connexion/LocalUserStore"; +import { gameReportKey, gameReportRessource, ReportMenu } from "./ReportMenu"; +import { connectionManager } from "../../Connexion/ConnectionManager"; +import { GameConnexionTypes } from "../../Url/UrlManager"; +import { WarningContainer, warningContainerHtml, warningContainerKey } from "../Components/WarningContainer"; +import { worldFullWarningStream } from "../../Connexion/WorldFullWarningStream"; +import { menuIconVisible } from "../../Stores/MenuStore"; +import { videoConstraintStore } from "../../Stores/MediaStore"; +import { showReportScreenStore } from "../../Stores/ShowReportScreenStore"; +import { HtmlUtils } from "../../WebRtc/HtmlUtils"; +import { iframeListener } from "../../Api/IframeListener"; +import { Subscription } from "rxjs"; +import { registerMenuCommandStream } from "../../Api/Events/ui/MenuItemRegisterEvent"; +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'; -const gameMenuIconKey = 'gameMenuIcon'; -const gameSettingsMenuKey = 'gameSettingsMenu'; -const gameShare = 'gameShare'; +export const MenuSceneName = "MenuScene"; +const gameMenuKey = "gameMenu"; +const gameMenuIconKey = "gameMenuIcon"; +const gameSettingsMenuKey = "gameSettingsMenu"; +const gameShare = "gameShare"; const closedSideMenuX = -1000; const openedSideMenuX = 0; @@ -44,45 +47,49 @@ export class MenuScene extends Phaser.Scene { private menuButton!: Phaser.GameObjects.DOMElement; private warningContainer: WarningContainer | null = null; private warningContainerTimeout: NodeJS.Timeout | null = null; - private subscriptions = new Subscription() + private subscriptions = new Subscription(); constructor() { super({ key: MenuSceneName }); this.gameQualityValue = localUserStore.getGameQualityValue(); this.videoQualityValue = localUserStore.getVideoQualityValue(); - this.subscriptions.add(registerMenuCommandStream.subscribe(menuCommand => { - this.addMenuOption(menuCommand); - })) + this.subscriptions.add( + registerMenuCommandStream.subscribe((menuCommand) => { + this.addMenuOption(menuCommand); + }) + ); - this.subscriptions.add(iframeListener.unregisterMenuCommandStream.subscribe(menuCommand => { - this.destroyMenu(menuCommand); - })) + this.subscriptions.add( + iframeListener.unregisterMenuCommandStream.subscribe((menuCommand) => { + this.destroyMenu(menuCommand); + }) + ); } reset() { const addedMenuItems = [...this.menuElement.node.querySelectorAll(".fromApi")]; for (let index = addedMenuItems.length - 1; index >= 0; index--) { - addedMenuItems[index].remove() + addedMenuItems[index].remove(); } } public addMenuOption(menuText: string) { - const wrappingSection = document.createElement("section") + const wrappingSection = document.createElement("section"); const escapedHtml = HtmlUtils.escapeHtml(menuText); - wrappingSection.innerHTML = `` + wrappingSection.innerHTML = ``; const menuItemContainer = this.menuElement.node.querySelector("#gameMenu main"); if (menuItemContainer) { - menuItemContainer.querySelector(`#${escapedHtml}.fromApi`)?.remove() - menuItemContainer.insertBefore(wrappingSection, menuItemContainer.querySelector("#socialLinks")) + menuItemContainer.querySelector(`#${escapedHtml}.fromApi`)?.remove(); + menuItemContainer.insertBefore(wrappingSection, menuItemContainer.querySelector("#socialLinks")); } } preload() { - this.load.html(gameMenuKey, 'resources/html/gameMenu.html'); - this.load.html(gameMenuIconKey, 'resources/html/gameMenuIcon.html'); - this.load.html(gameSettingsMenuKey, 'resources/html/gameQualityMenu.html'); - this.load.html(gameShare, 'resources/html/gameShare.html'); + this.load.html(gameMenuKey, "resources/html/gameMenu.html"); + this.load.html(gameMenuIconKey, "resources/html/gameMenuIcon.html"); + this.load.html(gameSettingsMenuKey, "resources/html/gameQualityMenu.html"); + this.load.html(gameShare, "resources/html/gameShare.html"); this.load.html(gameReportKey, gameReportRessource); this.load.html(warningContainerKey, warningContainerHtml); } @@ -91,46 +98,59 @@ export class MenuScene extends Phaser.Scene { menuIconVisible.set(true); this.menuElement = this.add.dom(closedSideMenuX, 30).createFromCache(gameMenuKey); this.menuElement.setOrigin(0); - MenuScene.revealMenusAfterInit(this.menuElement, 'gameMenu'); + MenuScene.revealMenusAfterInit(this.menuElement, "gameMenu"); - const middleX = (window.innerWidth / 3) - 298; + 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'); - + MenuScene.revealMenusAfterInit(this.gameQualityMenuElement, "gameQuality"); this.gameShareElement = this.add.dom(middleX, -400).createFromCache(gameShare); MenuScene.revealMenusAfterInit(this.gameShareElement, gameShare); - this.gameShareElement.addListener('click'); - this.gameShareElement.on('click', (event: MouseEvent) => { + this.gameShareElement.addListener("click"); + this.gameShareElement.on("click", (event: MouseEvent) => { event.preventDefault(); - if ((event?.target as HTMLInputElement).id === 'gameShareFormSubmit') { + if ((event?.target as HTMLInputElement).id === "gameShareFormSubmit") { this.copyLink(); - } else if ((event?.target as HTMLInputElement).id === 'gameShareFormCancel') { + } else if ((event?.target as HTMLInputElement).id === "gameShareFormCancel") { this.closeGameShare(); } }); - this.gameReportElement = new ReportMenu(this, connectionManager.getConnexionType === GameConnexionTypes.anonymous); + this.gameReportElement = new ReportMenu( + this, + connectionManager.getConnexionType === GameConnexionTypes.anonymous + ); 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); } }); - this.input.keyboard.on('keyup-TAB', () => { + this.input.keyboard.on("keyup-TAB", () => { this.sideMenuOpened ? this.closeSideMenu() : this.openSideMenu(); }); this.menuButton = this.add.dom(0, 0).createFromCache(gameMenuIconKey); - this.menuButton.addListener('click'); - this.menuButton.on('click', () => { + this.menuButton.addListener("click"); + this.menuButton.on("click", () => { this.sideMenuOpened ? this.closeSideMenu() : this.openSideMenu(); }); - this.menuElement.addListener('click'); - this.menuElement.on('click', this.onMenuClick.bind(this)); + this.menuElement.addListener("click"); + 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 @@ -145,7 +165,7 @@ export class MenuScene extends Phaser.Scene { public revealMenuIcon(): void { //TODO fix me: add try catch because at the same time, 'this.menuButton' variable doesn't exist and there is error on 'getChildByID' function try { - (this.menuButton.getChildByID('menuIcon') as HTMLElement).hidden = false; + (this.menuButton.getChildByID("menuIcon") as HTMLElement).hidden = false; } catch (err) { console.error(err); } @@ -155,22 +175,22 @@ export class MenuScene extends Phaser.Scene { if (this.sideMenuOpened) return; this.closeAll(); this.sideMenuOpened = true; - this.menuButton.getChildByID('openMenuButton').innerHTML = 'X'; + this.menuButton.getChildByID("openMenuButton").innerHTML = "X"; const connection = gameManager.getCurrentGameScene(this).connection; if (connection && connection.isAdmin()) { - const adminSection = this.menuElement.getChildByID('adminConsoleSection') as HTMLElement; + const adminSection = this.menuElement.getChildByID("adminConsoleSection") as HTMLElement; adminSection.hidden = false; } //TODO bind with future metadata of card //if (connectionManager.getConnexionType === GameConnexionTypes.anonymous){ - const adminSection = this.menuElement.getChildByID('socialLinks') as HTMLElement; + const adminSection = this.menuElement.getChildByID("socialLinks") as HTMLElement; adminSection.hidden = false; //} this.tweens.add({ targets: this.menuElement, x: openedSideMenuX, duration: 500, - ease: 'Power3' + ease: "Power3", }); } @@ -183,23 +203,22 @@ export class MenuScene extends Phaser.Scene { } this.warningContainerTimeout = setTimeout(() => { this.warningContainer?.destroy(); - this.warningContainer = null - this.warningContainerTimeout = null + this.warningContainer = null; + this.warningContainerTimeout = null; }, 120000); - } private closeSideMenu(): void { if (!this.sideMenuOpened) return; this.sideMenuOpened = false; this.closeAll(); - this.menuButton.getChildByID('openMenuButton').innerHTML = ``; + this.menuButton.getChildByID("openMenuButton").innerHTML = ``; consoleGlobalMessageManagerVisibleStore.set(false); this.tweens.add({ targets: this.menuElement, x: closedSideMenuX, duration: 500, - ease: 'Power3' + ease: "Power3", }); } @@ -213,19 +232,23 @@ export class MenuScene extends Phaser.Scene { this.settingsMenuOpened = true; - const gameQualitySelect = this.gameQualityMenuElement.getChildByID('select-game-quality') as HTMLInputElement; - gameQualitySelect.value = '' + this.gameQualityValue; - const videoQualitySelect = this.gameQualityMenuElement.getChildByID('select-video-quality') as HTMLInputElement; - videoQualitySelect.value = '' + this.videoQualityValue; + const gameQualitySelect = this.gameQualityMenuElement.getChildByID("select-game-quality") as HTMLInputElement; + gameQualitySelect.value = "" + this.gameQualityValue; + const videoQualitySelect = this.gameQualityMenuElement.getChildByID("select-video-quality") as HTMLInputElement; + videoQualitySelect.value = "" + this.videoQualityValue; - this.gameQualityMenuElement.addListener('click'); - this.gameQualityMenuElement.on('click', (event: MouseEvent) => { + this.gameQualityMenuElement.addListener("click"); + this.gameQualityMenuElement.on("click", (event: MouseEvent) => { event.preventDefault(); - if ((event?.target as HTMLInputElement).id === 'gameQualityFormSubmit') { - const gameQualitySelect = this.gameQualityMenuElement.getChildByID('select-game-quality') as HTMLInputElement; - const videoQualitySelect = this.gameQualityMenuElement.getChildByID('select-video-quality') as HTMLInputElement; + if ((event?.target as HTMLInputElement).id === "gameQualityFormSubmit") { + const gameQualitySelect = this.gameQualityMenuElement.getChildByID( + "select-game-quality" + ) as HTMLInputElement; + const videoQualitySelect = this.gameQualityMenuElement.getChildByID( + "select-video-quality" + ) as HTMLInputElement; this.saveSetting(parseInt(gameQualitySelect.value), parseInt(videoQualitySelect.value)); - } else if ((event?.target as HTMLInputElement).id === 'gameQualityFormCancel') { + } else if ((event?.target as HTMLInputElement).id === "gameQualityFormCancel") { this.closeGameQualityMenu(); } }); @@ -243,7 +266,7 @@ export class MenuScene extends Phaser.Scene { y: middleY, x: middleX, duration: 1000, - ease: 'Power3' + ease: "Power3", }); } @@ -251,16 +274,15 @@ export class MenuScene extends Phaser.Scene { if (!this.settingsMenuOpened) return; this.settingsMenuOpened = false; - this.gameQualityMenuElement.removeListener('click'); + this.gameQualityMenuElement.removeListener("click"); this.tweens.add({ targets: this.gameQualityMenuElement, y: -400, duration: 1000, - ease: 'Power3' + ease: "Power3", }); } - private openGameShare(): void { if (this.gameShareOpened) { this.closeGameShare(); @@ -269,7 +291,7 @@ export class MenuScene extends Phaser.Scene { //close all this.closeAll(); - const gameShareLink = this.gameShareElement.getChildByID('gameShareLink') as HTMLInputElement; + const gameShareLink = this.gameShareElement.getChildByID("gameShareLink") as HTMLInputElement; gameShareLink.value = location.toString(); this.gameShareOpened = true; @@ -287,64 +309,67 @@ export class MenuScene extends Phaser.Scene { y: middleY, x: middleX, duration: 1000, - ease: 'Power3' + ease: "Power3", }); } private closeGameShare(): void { - const gameShareInfo = this.gameShareElement.getChildByID('gameShareInfo') as HTMLParagraphElement; - gameShareInfo.innerText = ''; - gameShareInfo.style.display = 'none'; + const gameShareInfo = this.gameShareElement.getChildByID("gameShareInfo") as HTMLParagraphElement; + gameShareInfo.innerText = ""; + gameShareInfo.style.display = "none"; this.gameShareOpened = false; this.tweens.add({ targets: this.gameShareElement, y: -400, duration: 1000, - ease: 'Power3' + ease: "Power3", }); } private onMenuClick(event: MouseEvent) { - const htmlMenuItem = (event?.target as HTMLInputElement); - if (htmlMenuItem.classList.contains('not-button')) { + const htmlMenuItem = event?.target as HTMLInputElement; + if (htmlMenuItem.classList.contains("not-button")) { return; } event.preventDefault(); if (htmlMenuItem.classList.contains("fromApi")) { - sendMenuClickedEvent(htmlMenuItem.id) - return + sendMenuClickedEvent(htmlMenuItem.id); + return; } switch ((event?.target as HTMLInputElement).id) { - case 'changeNameButton': + case "changeNameButton": this.closeSideMenu(); gameManager.leaveGame(this, LoginSceneName, new LoginScene()); break; - case 'sparkButton': + case "sparkButton": this.gotToCreateMapPage(); break; - case 'changeSkinButton': + case "changeSkinButton": this.closeSideMenu(); gameManager.leaveGame(this, SelectCharacterSceneName, new SelectCharacterScene()); break; - case 'changeCompanionButton': + case "changeCompanionButton": this.closeSideMenu(); gameManager.leaveGame(this, SelectCompanionSceneName, new SelectCompanionScene()); break; - case 'closeButton': + case "closeButton": this.closeSideMenu(); break; - case 'shareButton': + case "shareButton": this.openGameShare(); break; - case 'editGameSettingsButton': + case "editGameSettingsButton": this.openGameSettingsMenu(); break; - case 'toggleFullscreen': + case "toggleFullscreen": this.toggleFullscreen(); break; - case 'adminConsoleButton': + case "enableNotification": + this.enableNotification(); + break; + case "adminConsoleButton": if (get(consoleGlobalMessageManagerVisibleStore)) { consoleGlobalMessageManagerVisibleStore.set(false); } else { @@ -356,9 +381,9 @@ export class MenuScene extends Phaser.Scene { private async copyLink() { await navigator.clipboard.writeText(location.toString()); - const gameShareInfo = this.gameShareElement.getChildByID('gameShareInfo') as HTMLParagraphElement; - gameShareInfo.innerText = 'Link copied, you can share it now!'; - gameShareInfo.style.display = 'block'; + const gameShareInfo = this.gameShareElement.getChildByID("gameShareInfo") as HTMLParagraphElement; + gameShareInfo.innerText = "Link copied, you can share it now!"; + gameShareInfo.style.display = "block"; } private saveSetting(valueGame: number, valueVideo: number) { @@ -378,8 +403,8 @@ export class MenuScene extends Phaser.Scene { private gotToCreateMapPage() { //const sparkHost = 'https://'+window.location.host.replace('play.', '')+'/choose-map.html'; //TODO fix me: this button can to send us on WorkAdventure BO. - const sparkHost = 'https://workadventu.re/getting-started'; - window.open(sparkHost, '_blank'); + const sparkHost = "https://workadventu.re/getting-started"; + window.open(sparkHost, "_blank"); } private closeAll() { @@ -389,10 +414,10 @@ export class MenuScene extends Phaser.Scene { } private toggleFullscreen() { - const body = document.querySelector('body') + const body = document.querySelector("body"); if (body) { if (document.fullscreenElement ?? document.fullscreen) { - document.exitFullscreen() + document.exitFullscreen(); } else { body.requestFullscreen(); } @@ -406,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..3f53c6fd 100644 --- a/front/src/Phaser/Reconnecting/ErrorScene.ts +++ b/front/src/Phaser/Reconnecting/ErrorScene.ts @@ -34,7 +34,7 @@ export class ErrorScene extends Phaser.Scene { } 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( diff --git a/front/src/Phaser/Reconnecting/ReconnectingScene.ts b/front/src/Phaser/Reconnecting/ReconnectingScene.ts index 9b56dd63..4e49b880 100644 --- a/front/src/Phaser/Reconnecting/ReconnectingScene.ts +++ b/front/src/Phaser/Reconnecting/ReconnectingScene.ts @@ -19,7 +19,7 @@ export class ReconnectingScene extends Phaser.Scene { } 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( diff --git a/front/src/Stores/BiggestAvailableAreaStore.ts b/front/src/Stores/BiggestAvailableAreaStore.ts index 716f37fc..e0a21cc6 100644 --- a/front/src/Stores/BiggestAvailableAreaStore.ts +++ b/front/src/Stores/BiggestAvailableAreaStore.ts @@ -1,16 +1,16 @@ -import {get, writable} from "svelte/store"; -import type {Box} from "../WebRtc/LayoutManager"; -import {HtmlUtils} from "../WebRtc/HtmlUtils"; -import {LayoutMode} from "../WebRtc/LayoutManager"; -import {layoutModeStore} from "./StreamableCollectionStore"; +import { get, writable } from "svelte/store"; +import type { Box } from "../WebRtc/LayoutManager"; +import { HtmlUtils } from "../WebRtc/HtmlUtils"; +import { LayoutMode } from "../WebRtc/LayoutManager"; +import { layoutModeStore } from "./StreamableCollectionStore"; /** * Tries to find the biggest available box of remaining space (this is a space where we can center the character) */ function findBiggestAvailableArea(): Box { - const game = HtmlUtils.querySelectorOrFail('#game canvas'); + const game = HtmlUtils.querySelectorOrFail("#game canvas"); if (get(layoutModeStore) === LayoutMode.VideoChat) { - const children = document.querySelectorAll('div.chat-mode > div'); + const children = document.querySelectorAll("div.chat-mode > div"); const htmlChildren = Array.from(children.values()); // No chat? Let's go full center @@ -19,18 +19,17 @@ function findBiggestAvailableArea(): Box { xStart: 0, yStart: 0, xEnd: game.offsetWidth, - yEnd: game.offsetHeight - } + yEnd: game.offsetHeight, + }; } const lastDiv = htmlChildren[htmlChildren.length - 1]; // Compute area between top right of the last div and bottom right of window - const area1 = (game.offsetWidth - (lastDiv.offsetLeft + lastDiv.offsetWidth)) - * (game.offsetHeight - lastDiv.offsetTop); + const area1 = + (game.offsetWidth - (lastDiv.offsetLeft + lastDiv.offsetWidth)) * (game.offsetHeight - lastDiv.offsetTop); // Compute area between bottom of last div and bottom of the screen on whole width - const area2 = game.offsetWidth - * (game.offsetHeight - (lastDiv.offsetTop + lastDiv.offsetHeight)); + const area2 = game.offsetWidth * (game.offsetHeight - (lastDiv.offsetTop + lastDiv.offsetHeight)); if (area1 < 0 && area2 < 0) { // If screen is full, let's not attempt something foolish and simply center character in the middle. @@ -38,28 +37,30 @@ function findBiggestAvailableArea(): Box { xStart: 0, yStart: 0, xEnd: game.offsetWidth, - yEnd: game.offsetHeight - } + yEnd: game.offsetHeight, + }; } if (area1 <= area2) { return { xStart: 0, yStart: lastDiv.offsetTop + lastDiv.offsetHeight, xEnd: game.offsetWidth, - yEnd: game.offsetHeight - } + yEnd: game.offsetHeight, + }; } else { return { xStart: lastDiv.offsetLeft + lastDiv.offsetWidth, yStart: lastDiv.offsetTop, xEnd: game.offsetWidth, - yEnd: game.offsetHeight - } + yEnd: game.offsetHeight, + }; } } else { // Possible destinations: at the center bottom or at the right bottom. - const mainSectionChildren = Array.from(document.querySelectorAll('div.main-section > div').values()); - const sidebarChildren = Array.from(document.querySelectorAll('aside.sidebar > div').values()); + const mainSectionChildren = Array.from( + document.querySelectorAll("div.main-section > div").values() + ); + const sidebarChildren = Array.from(document.querySelectorAll("aside.sidebar > div").values()); // No presentation? Let's center on the screen if (mainSectionChildren.length === 0) { @@ -67,60 +68,58 @@ function findBiggestAvailableArea(): Box { xStart: 0, yStart: 0, xEnd: game.offsetWidth, - yEnd: game.offsetHeight - } + yEnd: game.offsetHeight, + }; } // At this point, we know we have at least one element in the main section. - const lastPresentationDiv = mainSectionChildren[mainSectionChildren.length-1]; + const lastPresentationDiv = mainSectionChildren[mainSectionChildren.length - 1]; - const presentationArea = (game.offsetHeight - (lastPresentationDiv.offsetTop + lastPresentationDiv.offsetHeight)) - * (lastPresentationDiv.offsetLeft + lastPresentationDiv.offsetWidth); + const presentationArea = + (game.offsetHeight - (lastPresentationDiv.offsetTop + lastPresentationDiv.offsetHeight)) * + (lastPresentationDiv.offsetLeft + lastPresentationDiv.offsetWidth); let leftSideBar: number; let bottomSideBar: number; if (sidebarChildren.length === 0) { - leftSideBar = HtmlUtils.getElementByIdOrFail('sidebar').offsetLeft; + leftSideBar = HtmlUtils.getElementByIdOrFail("sidebar").offsetLeft; bottomSideBar = 0; } else { const lastSideBarChildren = sidebarChildren[sidebarChildren.length - 1]; leftSideBar = lastSideBarChildren.offsetLeft; bottomSideBar = lastSideBarChildren.offsetTop + lastSideBarChildren.offsetHeight; } - const sideBarArea = (game.offsetWidth - leftSideBar) - * (game.offsetHeight - bottomSideBar); + const sideBarArea = (game.offsetWidth - leftSideBar) * (game.offsetHeight - bottomSideBar); if (presentationArea <= sideBarArea) { return { xStart: leftSideBar, yStart: bottomSideBar, xEnd: game.offsetWidth, - yEnd: game.offsetHeight - } + yEnd: game.offsetHeight, + }; } else { return { xStart: 0, yStart: lastPresentationDiv.offsetTop + lastPresentationDiv.offsetHeight, - xEnd: /*lastPresentationDiv.offsetLeft + lastPresentationDiv.offsetWidth*/ game.offsetWidth , // To avoid flickering when a chat start, we center on the center of the screen, not the center of the main content area - yEnd: game.offsetHeight - } + xEnd: /*lastPresentationDiv.offsetLeft + lastPresentationDiv.offsetWidth*/ game.offsetWidth, // To avoid flickering when a chat start, we center on the center of the screen, not the center of the main content area + yEnd: game.offsetHeight, + }; } } } - /** * A store that contains the list of (video) peers we are connected to. */ function createBiggestAvailableAreaStore() { - - const { subscribe, set } = writable({xStart:0, yStart: 0, xEnd: 1, yEnd: 1}); + const { subscribe, set } = writable({ xStart: 0, yStart: 0, xEnd: 1, yEnd: 1 }); return { subscribe, recompute: () => { set(findBiggestAvailableArea()); - } + }, }; } 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/GameOverlayStoreVisibility.ts b/front/src/Stores/GameOverlayStoreVisibility.ts index c58c929d..b1ca358b 100644 --- a/front/src/Stores/GameOverlayStoreVisibility.ts +++ b/front/src/Stores/GameOverlayStoreVisibility.ts @@ -1,4 +1,4 @@ -import {writable} from "svelte/store"; +import { writable } from "svelte/store"; /** * A store that contains whether the game overlay is shown or not. diff --git a/front/src/Stores/MediaStore.ts b/front/src/Stores/MediaStore.ts index 6cb9f75c..9144a6ee 100644 --- a/front/src/Stores/MediaStore.ts +++ b/front/src/Stores/MediaStore.ts @@ -1,14 +1,14 @@ -import {derived, get, Readable, readable, writable, Writable} from "svelte/store"; -import {localUserStore} from "../Connexion/LocalUserStore"; -import {userMovingStore} from "./GameStore"; -import {HtmlUtils} from "../WebRtc/HtmlUtils"; -import {BrowserTooOldError} from "./Errors/BrowserTooOldError"; -import {errorStore} from "./ErrorStore"; -import {isIOS} from "../WebRtc/DeviceUtils"; -import {WebviewOnOldIOS} from "./Errors/WebviewOnOldIOS"; -import {gameOverlayVisibilityStore} from "./GameOverlayStoreVisibility"; -import {peerStore} from "./PeerStore"; -import {privacyShutdownStore} from "./PrivacyShutdownStore"; +import { derived, get, Readable, readable, writable, Writable } from "svelte/store"; +import { localUserStore } from "../Connexion/LocalUserStore"; +import { userMovingStore } from "./GameStore"; +import { HtmlUtils } from "../WebRtc/HtmlUtils"; +import { BrowserTooOldError } from "./Errors/BrowserTooOldError"; +import { errorStore } from "./ErrorStore"; +import { isIOS } from "../WebRtc/DeviceUtils"; +import { WebviewOnOldIOS } from "./Errors/WebviewOnOldIOS"; +import { gameOverlayVisibilityStore } from "./GameOverlayStoreVisibility"; +import { peerStore } from "./PeerStore"; +import { privacyShutdownStore } from "./PrivacyShutdownStore"; /** * A store that contains the camera state requested by the user (on or off). @@ -57,7 +57,7 @@ export const enableCameraSceneVisibilityStore = createEnableCameraSceneVisibilit * A store containing whether the webcam was enabled in the last 10 seconds */ const enabledWebCam10secondsAgoStore = readable(false, function start(set) { - let timeout: NodeJS.Timeout|null = null; + let timeout: NodeJS.Timeout | null = null; const unsubscribe = requestedCameraState.subscribe((enabled) => { if (enabled === true) { @@ -71,7 +71,7 @@ const enabledWebCam10secondsAgoStore = readable(false, function start(set) { } else { set(false); } - }) + }); return function stop() { unsubscribe(); @@ -82,7 +82,7 @@ const enabledWebCam10secondsAgoStore = readable(false, function start(set) { * A store containing whether the webcam was enabled in the last 5 seconds */ const userMoved5SecondsAgoStore = readable(false, function start(set) { - let timeout: NodeJS.Timeout|null = null; + let timeout: NodeJS.Timeout | null = null; const unsubscribe = userMovingStore.subscribe((moving) => { if (moving === true) { @@ -94,45 +94,51 @@ const userMoved5SecondsAgoStore = readable(false, function start(set) { timeout = setTimeout(() => { set(false); }, 5000); - } - }) + }); return function stop() { unsubscribe(); }; }); - /** * A store containing whether the mouse is getting close the bottom right corner. */ const mouseInBottomRight = readable(false, function start(set) { let lastInBottomRight = false; - const gameDiv = HtmlUtils.getElementByIdOrFail('game'); + const gameDiv = HtmlUtils.getElementByIdOrFail("game"); const detectInBottomRight = (event: MouseEvent) => { const rect = gameDiv.getBoundingClientRect(); - const inBottomRight = event.x - rect.left > rect.width * 3 / 4 && event.y - rect.top > rect.height * 3 / 4; + const inBottomRight = event.x - rect.left > (rect.width * 3) / 4 && event.y - rect.top > (rect.height * 3) / 4; if (inBottomRight !== lastInBottomRight) { lastInBottomRight = inBottomRight; set(inBottomRight); } }; - document.addEventListener('mousemove', detectInBottomRight); + document.addEventListener("mousemove", detectInBottomRight); return function stop() { - document.removeEventListener('mousemove', detectInBottomRight); - } + document.removeEventListener("mousemove", detectInBottomRight); + }; }); /** * A store that contains "true" if the webcam should be stopped for energy efficiency reason - i.e. we are not moving and not in a conversation. */ -export const cameraEnergySavingStore = derived([userMoved5SecondsAgoStore, peerStore, enabledWebCam10secondsAgoStore, mouseInBottomRight], ([$userMoved5SecondsAgoStore,$peerStore, $enabledWebCam10secondsAgoStore, $mouseInBottomRight]) => { - return !$mouseInBottomRight && !$userMoved5SecondsAgoStore && $peerStore.size === 0 && !$enabledWebCam10secondsAgoStore; -}); +export const cameraEnergySavingStore = derived( + [userMoved5SecondsAgoStore, peerStore, enabledWebCam10secondsAgoStore, mouseInBottomRight], + ([$userMoved5SecondsAgoStore, $peerStore, $enabledWebCam10secondsAgoStore, $mouseInBottomRight]) => { + return ( + !$mouseInBottomRight && + !$userMoved5SecondsAgoStore && + $peerStore.size === 0 && + !$enabledWebCam10secondsAgoStore + ); + } +); /** * A store that contains video constraints. @@ -143,28 +149,30 @@ function createVideoConstraintStore() { height: { min: 400, ideal: 720 }, frameRate: { ideal: localUserStore.getVideoQualityValue() }, facingMode: "user", - resizeMode: 'crop-and-scale', - aspectRatio: 1.777777778 + resizeMode: "crop-and-scale", + aspectRatio: 1.777777778, } as MediaTrackConstraints); return { subscribe, - setDeviceId: (deviceId: string|undefined) => update((constraints) => { - if (deviceId !== undefined) { - constraints.deviceId = { - exact: deviceId - }; - } else { - delete constraints.deviceId; - } + setDeviceId: (deviceId: string | undefined) => + update((constraints) => { + if (deviceId !== undefined) { + constraints.deviceId = { + exact: deviceId, + }; + } else { + delete constraints.deviceId; + } - return constraints; - }), - setFrameRate: (frameRate: number) => update((constraints) => { - constraints.frameRate = { ideal: frameRate }; + return constraints; + }), + setFrameRate: (frameRate: number) => + update((constraints) => { + constraints.frameRate = { ideal: frameRate }; - return constraints; - }) + return constraints; + }), }; } @@ -178,39 +186,39 @@ function createAudioConstraintStore() { //TODO: make these values configurable in the game settings menu and store them in localstorage autoGainControl: false, echoCancellation: true, - noiseSuppression: true - } as boolean|MediaTrackConstraints); + noiseSuppression: true, + } as boolean | MediaTrackConstraints); let selectedDeviceId = null; return { subscribe, - setDeviceId: (deviceId: string|undefined) => update((constraints) => { - selectedDeviceId = deviceId; + setDeviceId: (deviceId: string | undefined) => + update((constraints) => { + selectedDeviceId = deviceId; - if (typeof(constraints) === 'boolean') { - constraints = {} - } - if (deviceId !== undefined) { - constraints.deviceId = { - exact: selectedDeviceId - }; - } else { - delete constraints.deviceId; - } + if (typeof constraints === "boolean") { + constraints = {}; + } + if (deviceId !== undefined) { + constraints.deviceId = { + exact: selectedDeviceId, + }; + } else { + delete constraints.deviceId; + } - return constraints; - }) + return constraints; + }), }; } export const audioConstraintStore = createAudioConstraintStore(); - let timeout: NodeJS.Timeout; -let previousComputedVideoConstraint: boolean|MediaTrackConstraints = false; -let previousComputedAudioConstraint: boolean|MediaTrackConstraints = false; +let previousComputedVideoConstraint: boolean | MediaTrackConstraints = false; +let previousComputedAudioConstraint: boolean | MediaTrackConstraints = false; /** * A store containing the media constraints we want to apply. @@ -225,7 +233,8 @@ export const mediaStreamConstraintsStore = derived( audioConstraintStore, privacyShutdownStore, cameraEnergySavingStore, - ], ( + ], + ( [ $requestedCameraState, $requestedMicrophoneState, @@ -235,92 +244,97 @@ export const mediaStreamConstraintsStore = derived( $audioConstraintStore, $privacyShutdownStore, $cameraEnergySavingStore, - ], set + ], + set ) => { + let currentVideoConstraint: boolean | MediaTrackConstraints = $videoConstraintStore; + let currentAudioConstraint: boolean | MediaTrackConstraints = $audioConstraintStore; - let currentVideoConstraint: boolean|MediaTrackConstraints = $videoConstraintStore; - let currentAudioConstraint: boolean|MediaTrackConstraints = $audioConstraintStore; - - if ($enableCameraSceneVisibilityStore) { - set({ - video: currentVideoConstraint, - audio: currentAudioConstraint, - }); - return; - } - - // Disable webcam if the user requested so - if ($requestedCameraState === false) { - currentVideoConstraint = false; - } - - // Disable microphone if the user requested so - if ($requestedMicrophoneState === false) { - currentAudioConstraint = false; - } - - // Disable webcam and microphone when in a Jitsi - if ($gameOverlayVisibilityStore === false) { - currentVideoConstraint = false; - currentAudioConstraint = false; - } - - // Disable webcam for privacy reasons (the game is not visible and we were talking to noone) - if ($privacyShutdownStore === true) { - currentVideoConstraint = false; - } - - // Disable webcam for energy reasons (the user is not moving and we are talking to noone) - if ($cameraEnergySavingStore === true) { - currentVideoConstraint = false; - currentAudioConstraint = false; - } - - // Let's make the changes only if the new value is different from the old one. - if (previousComputedVideoConstraint != currentVideoConstraint || previousComputedAudioConstraint != currentAudioConstraint) { - previousComputedVideoConstraint = currentVideoConstraint; - previousComputedAudioConstraint = currentAudioConstraint; - // Let's copy the objects. - if (typeof previousComputedVideoConstraint !== 'boolean') { - previousComputedVideoConstraint = {...previousComputedVideoConstraint}; - } - if (typeof previousComputedAudioConstraint !== 'boolean') { - previousComputedAudioConstraint = {...previousComputedAudioConstraint}; - } - - if (timeout) { - clearTimeout(timeout); - } - - // Let's wait a little bit to avoid sending too many constraint changes. - timeout = setTimeout(() => { + if ($enableCameraSceneVisibilityStore) { set({ video: currentVideoConstraint, audio: currentAudioConstraint, }); - }, 100); - } -}, { - video: false, - audio: false -} as MediaStreamConstraints); + return; + } + + // Disable webcam if the user requested so + if ($requestedCameraState === false) { + currentVideoConstraint = false; + } + + // Disable microphone if the user requested so + if ($requestedMicrophoneState === false) { + currentAudioConstraint = false; + } + + // Disable webcam and microphone when in a Jitsi + if ($gameOverlayVisibilityStore === false) { + currentVideoConstraint = false; + currentAudioConstraint = false; + } + + // Disable webcam for privacy reasons (the game is not visible and we were talking to noone) + if ($privacyShutdownStore === true) { + currentVideoConstraint = false; + } + + // Disable webcam for energy reasons (the user is not moving and we are talking to noone) + if ($cameraEnergySavingStore === true) { + currentVideoConstraint = false; + currentAudioConstraint = false; + } + + // Let's make the changes only if the new value is different from the old one. + if ( + previousComputedVideoConstraint != currentVideoConstraint || + previousComputedAudioConstraint != currentAudioConstraint + ) { + previousComputedVideoConstraint = currentVideoConstraint; + previousComputedAudioConstraint = currentAudioConstraint; + // Let's copy the objects. + if (typeof previousComputedVideoConstraint !== "boolean") { + previousComputedVideoConstraint = { ...previousComputedVideoConstraint }; + } + if (typeof previousComputedAudioConstraint !== "boolean") { + previousComputedAudioConstraint = { ...previousComputedAudioConstraint }; + } + + if (timeout) { + clearTimeout(timeout); + } + + // Let's wait a little bit to avoid sending too many constraint changes. + timeout = setTimeout(() => { + set({ + video: currentVideoConstraint, + audio: currentAudioConstraint, + }); + }, 100); + } + }, + { + video: false, + audio: false, + } as MediaStreamConstraints +); export type LocalStreamStoreValue = StreamSuccessValue | StreamErrorValue; interface StreamSuccessValue { - type: "success", - stream: MediaStream|null, + type: "success"; + stream: MediaStream | null; // The constraints that we got (and not the one that have been requested) - constraints: MediaStreamConstraints + constraints: MediaStreamConstraints; } interface StreamErrorValue { - type: "error", - error: Error, - constraints: MediaStreamConstraints + type: "error"; + error: Error; + constraints: MediaStreamConstraints; } -let currentStream : MediaStream|null = null; +let currentStream: MediaStream | null = null; /** * Stops the camera from filming @@ -347,84 +361,94 @@ function stopMicrophone(): void { /** * A store containing the MediaStream object (or null if nothing requested, or Error if an error occurred) */ -export const localStreamStore = derived, LocalStreamStoreValue>(mediaStreamConstraintsStore, ($mediaStreamConstraintsStore, set) => { - const constraints = { ...$mediaStreamConstraintsStore }; +export const localStreamStore = derived, LocalStreamStoreValue>( + mediaStreamConstraintsStore, + ($mediaStreamConstraintsStore, set) => { + const constraints = { ...$mediaStreamConstraintsStore }; - if (navigator.mediaDevices === undefined) { - if (window.location.protocol === 'http:') { - //throw new Error('Unable to access your camera or microphone. You need to use a HTTPS connection.'); + if (navigator.mediaDevices === undefined) { + if (window.location.protocol === "http:") { + //throw new Error('Unable to access your camera or microphone. You need to use a HTTPS connection.'); + set({ + type: "error", + error: new Error("Unable to access your camera or microphone. You need to use a HTTPS connection."), + constraints, + }); + return; + } else if (isIOS()) { + set({ + type: "error", + error: new WebviewOnOldIOS(), + constraints, + }); + return; + } else { + set({ + type: "error", + error: new BrowserTooOldError(), + constraints, + }); + return; + } + } + + if (constraints.audio === false) { + stopMicrophone(); + } + if (constraints.video === false) { + stopCamera(); + } + + if (constraints.audio === false && constraints.video === false) { + currentStream = null; set({ - type: 'error', - error: new Error('Unable to access your camera or microphone. You need to use a HTTPS connection.'), - constraints - }); - return; - } else if (isIOS()) { - set({ - type: 'error', - error: new WebviewOnOldIOS(), - constraints - }); - return; - } else { - set({ - type: 'error', - error: new BrowserTooOldError(), - constraints + type: "success", + stream: null, + constraints, }); return; } - } - if (constraints.audio === false) { - stopMicrophone(); - } - if (constraints.video === false) { - stopCamera(); - } - - if (constraints.audio === false && constraints.video === false) { - currentStream = null; - set({ - type: 'success', - stream: null, - constraints - }); - return; - } - - (async () => { - try { - stopMicrophone(); - stopCamera(); - currentStream = await navigator.mediaDevices.getUserMedia(constraints); - set({ - type: 'success', - stream: currentStream, - constraints - }); - return; - } catch (e) { - if (constraints.video !== false) { - console.info("Error. Unable to get microphone and/or camera access. Trying audio only.", $mediaStreamConstraintsStore, e); - // TODO: does it make sense to pop this error when retrying? + (async () => { + try { + stopMicrophone(); + stopCamera(); + currentStream = await navigator.mediaDevices.getUserMedia(constraints); set({ - type: 'error', - error: e, - constraints + type: "success", + stream: currentStream, + constraints, }); - // Let's try without video constraints - requestedCameraState.disableWebcam(); - } else { - console.info("Error. Unable to get microphone and/or camera access.", $mediaStreamConstraintsStore, e); - set({ - type: 'error', - error: e, - constraints - }); - } + return; + } catch (e) { + if (constraints.video !== false) { + console.info( + "Error. Unable to get microphone and/or camera access. Trying audio only.", + $mediaStreamConstraintsStore, + e + ); + // TODO: does it make sense to pop this error when retrying? + set({ + type: "error", + error: e, + constraints, + }); + // Let's try without video constraints + requestedCameraState.disableWebcam(); + } else { + console.info( + "Error. Unable to get microphone and/or camera access.", + $mediaStreamConstraintsStore, + e + ); + set({ + type: "error", + error: e, + constraints, + }); + } - /*constraints.video = false; + /*constraints.video = false; if (constraints.audio === false) { console.info("Error. Unable to get microphone and/or camera access.", $mediaStreamConstraintsStore, e); set({ @@ -453,9 +477,10 @@ export const localStreamStore = derived, LocalS }); } }*/ - } - })(); -}); + } + })(); + } +); /** * A store containing the real active media constrained (not the one requested by the user, but the one we got from the system) @@ -472,12 +497,15 @@ export const deviceListStore = readable([], function start(se const queryDeviceList = () => { // Note: so far, we are ignoring any failures. - navigator.mediaDevices.enumerateDevices().then((mediaDeviceInfos) => { - set(mediaDeviceInfos); - }).catch((e) => { - console.error(e); - throw e; - }); + navigator.mediaDevices + .enumerateDevices() + .then((mediaDeviceInfos) => { + set(mediaDeviceInfos); + }) + .catch((e) => { + console.error(e); + throw e; + }); }; const unsubscribe = localStreamStore.subscribe((streamResult) => { @@ -490,23 +518,23 @@ export const deviceListStore = readable([], function start(se }); if (navigator.mediaDevices) { - navigator.mediaDevices.addEventListener('devicechange', queryDeviceList); + navigator.mediaDevices.addEventListener("devicechange", queryDeviceList); } return function stop() { unsubscribe(); if (navigator.mediaDevices) { - navigator.mediaDevices.removeEventListener('devicechange', queryDeviceList); + navigator.mediaDevices.removeEventListener("devicechange", queryDeviceList); } }; }); export const cameraListStore = derived(deviceListStore, ($deviceListStore) => { - return $deviceListStore.filter(device => device.kind === 'videoinput'); + return $deviceListStore.filter((device) => device.kind === "videoinput"); }); export const microphoneListStore = derived(deviceListStore, ($deviceListStore) => { - return $deviceListStore.filter(device => device.kind === 'audioinput'); + return $deviceListStore.filter((device) => device.kind === "audioinput"); }); // TODO: detect the new webcam and automatically switch on it. @@ -519,7 +547,7 @@ cameraListStore.subscribe((devices) => { // If we cannot find the device ID, let's remove it. // @ts-ignore - if (!devices.find(device => device.deviceId === constraints.deviceId.exact)) { + if (!devices.find((device) => device.deviceId === constraints.deviceId.exact)) { videoConstraintStore.setDeviceId(undefined); } }); @@ -527,7 +555,7 @@ cameraListStore.subscribe((devices) => { microphoneListStore.subscribe((devices) => { // If the selected camera is unplugged, let's remove the constraint on deviceId const constraints = get(audioConstraintStore); - if (typeof constraints === 'boolean') { + if (typeof constraints === "boolean") { return; } if (!constraints.deviceId) { @@ -536,13 +564,13 @@ microphoneListStore.subscribe((devices) => { // If we cannot find the device ID, let's remove it. // @ts-ignore - if (!devices.find(device => device.deviceId === constraints.deviceId.exact)) { + if (!devices.find((device) => device.deviceId === constraints.deviceId.exact)) { audioConstraintStore.setDeviceId(undefined); } }); -localStreamStore.subscribe(streamResult => { - if (streamResult.type === 'error') { +localStreamStore.subscribe((streamResult) => { + if (streamResult.type === "error") { if (streamResult.error.name === BrowserTooOldError.NAME || streamResult.error.name === WebviewOnOldIOS.NAME) { errorStore.addErrorMessage(streamResult.error); } diff --git a/front/src/Stores/PeerStore.ts b/front/src/Stores/PeerStore.ts index 725c8940..11217b08 100644 --- a/front/src/Stores/PeerStore.ts +++ b/front/src/Stores/PeerStore.ts @@ -1,7 +1,7 @@ -import {readable, writable} from "svelte/store"; -import type {RemotePeer, SimplePeer} from "../WebRtc/SimplePeer"; -import {VideoPeer} from "../WebRtc/VideoPeer"; -import {ScreenSharingPeer} from "../WebRtc/ScreenSharingPeer"; +import { readable, writable } from "svelte/store"; +import type { RemotePeer, SimplePeer } from "../WebRtc/SimplePeer"; +import { VideoPeer } from "../WebRtc/VideoPeer"; +import { ScreenSharingPeer } from "../WebRtc/ScreenSharingPeer"; /** * A store that contains the list of (video) peers we are connected to. @@ -19,20 +19,20 @@ function createPeerStore() { simplePeer.registerPeerConnectionListener({ onConnect(peer: RemotePeer) { if (peer instanceof VideoPeer) { - update(users => { + update((users) => { users.set(peer.userId, peer); return users; }); } }, onDisconnect(userId: number) { - update(users => { + update((users) => { users.delete(userId); return users; }); - } - }) - } + }, + }); + }, }; } @@ -52,20 +52,20 @@ function createScreenSharingPeerStore() { simplePeer.registerPeerConnectionListener({ onConnect(peer: RemotePeer) { if (peer instanceof ScreenSharingPeer) { - update(users => { + update((users) => { users.set(peer.userId, peer); return users; }); } }, onDisconnect(userId: number) { - update(users => { + update((users) => { users.delete(userId); return users; }); - } - }) - } + }, + }); + }, }; } @@ -79,8 +79,7 @@ function createScreenSharingStreamStore() { let peers = new Map(); return readable>(peers, function start(set) { - - let unsubscribes: (()=>void)[] = []; + let unsubscribes: (() => void)[] = []; const unsubscribe = screenSharingPeerStore.subscribe((screenSharingPeers) => { for (const unsubscribe of unsubscribes) { @@ -91,24 +90,23 @@ function createScreenSharingStreamStore() { peers = new Map(); screenSharingPeers.forEach((screenSharingPeer: ScreenSharingPeer, key: number) => { - if (screenSharingPeer.isReceivingScreenSharingStream()) { peers.set(key, screenSharingPeer); } - unsubscribes.push(screenSharingPeer.streamStore.subscribe((stream) => { - if (stream) { - peers.set(key, screenSharingPeer); - } else { - peers.delete(key); - } - set(peers); - })); - + unsubscribes.push( + screenSharingPeer.streamStore.subscribe((stream) => { + if (stream) { + peers.set(key, screenSharingPeer); + } else { + peers.delete(key); + } + set(peers); + }) + ); }); set(peers); - }); return function stop() { @@ -117,9 +115,7 @@ function createScreenSharingStreamStore() { unsubscribe(); } }; - }) + }); } export const screenSharingStreamStore = createScreenSharingStreamStore(); - - 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/PrivacyShutdownStore.ts b/front/src/Stores/PrivacyShutdownStore.ts index cd9cb1b9..6ef31fe7 100644 --- a/front/src/Stores/PrivacyShutdownStore.ts +++ b/front/src/Stores/PrivacyShutdownStore.ts @@ -1,6 +1,6 @@ -import {get, writable} from "svelte/store"; -import {peerStore} from "./PeerStore"; -import {visibilityStore} from "./VisibilityStore"; +import { get, writable } from "svelte/store"; +import { peerStore } from "./PeerStore"; +import { visibilityStore } from "./VisibilityStore"; /** * A store that contains "true" if the webcam should be stopped for privacy reasons - i.e. if the the user left the the page while not in a discussion. @@ -28,7 +28,6 @@ function createPrivacyShutdownStore() { } }); - return { subscribe, }; diff --git a/front/src/Stores/ScreenSharingStore.ts b/front/src/Stores/ScreenSharingStore.ts index ccb8c02c..e69307c8 100644 --- a/front/src/Stores/ScreenSharingStore.ts +++ b/front/src/Stores/ScreenSharingStore.ts @@ -1,12 +1,10 @@ -import {derived, get, Readable, readable, writable, Writable} from "svelte/store"; -import {peerStore} from "./PeerStore"; -import type { - LocalStreamStoreValue, -} from "./MediaStore"; -import {DivImportance} from "../WebRtc/LayoutManager"; -import {gameOverlayVisibilityStore} from "./GameOverlayStoreVisibility"; +import { derived, get, Readable, readable, writable, Writable } from "svelte/store"; +import { peerStore } from "./PeerStore"; +import type { LocalStreamStoreValue } from "./MediaStore"; +import { DivImportance } from "../WebRtc/LayoutManager"; +import { gameOverlayVisibilityStore } from "./GameOverlayStoreVisibility"; -declare const navigator:any; // eslint-disable-line @typescript-eslint/no-explicit-any +declare const navigator: any; // eslint-disable-line @typescript-eslint/no-explicit-any /** * A store that contains the camera state requested by the user (on or off). @@ -23,7 +21,7 @@ function createRequestedScreenSharingState() { export const requestedScreenSharingState = createRequestedScreenSharingState(); -let currentStream : MediaStream|null = null; +let currentStream: MediaStream | null = null; /** * Stops the camera from filming @@ -37,27 +35,17 @@ function stopScreenSharing(): void { currentStream = null; } -let previousComputedVideoConstraint: boolean|MediaTrackConstraints = false; -let previousComputedAudioConstraint: boolean|MediaTrackConstraints = false; +let previousComputedVideoConstraint: boolean | MediaTrackConstraints = false; +let previousComputedAudioConstraint: boolean | MediaTrackConstraints = false; /** * A store containing the media constraints we want to apply. */ export const screenSharingConstraintsStore = derived( - [ - requestedScreenSharingState, - gameOverlayVisibilityStore, - peerStore, - ], ( - [ - $requestedScreenSharingState, - $gameOverlayVisibilityStore, - $peerStore, - ], set - ) => { - - let currentVideoConstraint: boolean|MediaTrackConstraints = true; - let currentAudioConstraint: boolean|MediaTrackConstraints = false; + [requestedScreenSharingState, gameOverlayVisibilityStore, peerStore], + ([$requestedScreenSharingState, $gameOverlayVisibilityStore, $peerStore], set) => { + let currentVideoConstraint: boolean | MediaTrackConstraints = true; + let currentAudioConstraint: boolean | MediaTrackConstraints = false; // Disable screen sharing if the user requested so if (!$requestedScreenSharingState) { @@ -78,7 +66,10 @@ export const screenSharingConstraintsStore = derived( } // Let's make the changes only if the new value is different from the old one. - if (previousComputedVideoConstraint != currentVideoConstraint || previousComputedAudioConstraint != currentAudioConstraint) { + if ( + previousComputedVideoConstraint != currentVideoConstraint || + previousComputedAudioConstraint != currentAudioConstraint + ) { previousComputedVideoConstraint = currentVideoConstraint; previousComputedAudioConstraint = currentAudioConstraint; // Let's copy the objects. @@ -94,85 +85,89 @@ export const screenSharingConstraintsStore = derived( audio: currentAudioConstraint, }); } - }, { + }, + { video: false, - audio: false - } as MediaStreamConstraints); - + audio: false, + } as MediaStreamConstraints +); /** * A store containing the MediaStream object for ScreenSharing (or null if nothing requested, or Error if an error occurred) */ -export const screenSharingLocalStreamStore = derived, LocalStreamStoreValue>(screenSharingConstraintsStore, ($screenSharingConstraintsStore, set) => { - const constraints = $screenSharingConstraintsStore; +export const screenSharingLocalStreamStore = derived, LocalStreamStoreValue>( + screenSharingConstraintsStore, + ($screenSharingConstraintsStore, set) => { + const constraints = $screenSharingConstraintsStore; - if ($screenSharingConstraintsStore.video === false && $screenSharingConstraintsStore.audio === false) { - stopScreenSharing(); - requestedScreenSharingState.disableScreenSharing(); - set({ - type: 'success', - stream: null, - constraints - }); - return; - } - - let currentStreamPromise: Promise; - if (navigator.getDisplayMedia) { - currentStreamPromise = navigator.getDisplayMedia({constraints}); - } else if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) { - currentStreamPromise = navigator.mediaDevices.getDisplayMedia({constraints}); - } else { - stopScreenSharing(); - set({ - type: 'error', - error: new Error('Your browser does not support sharing screen'), - constraints - }); - return; - } - - (async () => { - try { + if ($screenSharingConstraintsStore.video === false && $screenSharingConstraintsStore.audio === false) { stopScreenSharing(); - currentStream = await currentStreamPromise; - - // If stream ends (for instance if user clicks the stop screen sharing button in the browser), let's close the view - for (const track of currentStream.getTracks()) { - track.onended = () => { - stopScreenSharing(); - requestedScreenSharingState.disableScreenSharing(); - previousComputedVideoConstraint = false; - previousComputedAudioConstraint = false; - set({ - type: 'success', - stream: null, - constraints: { - video: false, - audio: false - } - }); - }; - } - + requestedScreenSharingState.disableScreenSharing(); set({ - type: 'success', - stream: currentStream, - constraints + type: "success", + stream: null, + constraints, }); return; - } catch (e) { - currentStream = null; - requestedScreenSharingState.disableScreenSharing(); - console.info("Error. Unable to share screen.", e); - set({ - type: 'error', - error: e, - constraints - }); } - })(); -}); + + let currentStreamPromise: Promise; + if (navigator.getDisplayMedia) { + currentStreamPromise = navigator.getDisplayMedia({ constraints }); + } else if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) { + currentStreamPromise = navigator.mediaDevices.getDisplayMedia({ constraints }); + } else { + stopScreenSharing(); + set({ + type: "error", + error: new Error("Your browser does not support sharing screen"), + constraints, + }); + return; + } + + (async () => { + try { + stopScreenSharing(); + currentStream = await currentStreamPromise; + + // If stream ends (for instance if user clicks the stop screen sharing button in the browser), let's close the view + for (const track of currentStream.getTracks()) { + track.onended = () => { + stopScreenSharing(); + requestedScreenSharingState.disableScreenSharing(); + previousComputedVideoConstraint = false; + previousComputedAudioConstraint = false; + set({ + type: "success", + stream: null, + constraints: { + video: false, + audio: false, + }, + }); + }; + } + + set({ + type: "success", + stream: currentStream, + constraints, + }); + return; + } catch (e) { + currentStream = null; + requestedScreenSharingState.disableScreenSharing(); + console.info("Error. Unable to share screen.", e); + set({ + type: "error", + error: e, + constraints, + }); + } + })(); + } +); /** * A store containing whether the screen sharing button should be displayed or hidden. @@ -188,19 +183,18 @@ export const screenSharingAvailableStore = derived(peerStore, ($peerStore, set) export interface ScreenSharingLocalMedia { uniqueId: string; - stream: MediaStream|null; + stream: MediaStream | null; //subscribe(this: void, run: Subscriber, invalidate?: (value?: ScreenSharingLocalMedia) => void): Unsubscriber; } /** * The representation of the screen sharing stream. */ -export const screenSharingLocalMedia = readable(null, function start(set) { - +export const screenSharingLocalMedia = readable(null, function start(set) { const localMedia: ScreenSharingLocalMedia = { uniqueId: "localScreenSharingStream", - stream: null - } + stream: null, + }; const unsubscribe = screenSharingLocalStreamStore.subscribe((screenSharingLocalStream) => { if (screenSharingLocalStream.type === "success") { @@ -214,4 +208,4 @@ export const screenSharingLocalMedia = readable(nu return function stop() { unsubscribe(); }; -}) +}); diff --git a/front/src/Stores/ShowReportScreenStore.ts b/front/src/Stores/ShowReportScreenStore.ts index f05e545a..665048da 100644 --- a/front/src/Stores/ShowReportScreenStore.ts +++ b/front/src/Stores/ShowReportScreenStore.ts @@ -1,3 +1,3 @@ -import {writable} from "svelte/store"; +import { writable } from "svelte/store"; -export const showReportScreenStore = writable<{userId: number, userName: string}|null>(null); +export const showReportScreenStore = writable<{ userId: number; userName: string } | null>(null); diff --git a/front/src/Stores/StreamableCollectionStore.ts b/front/src/Stores/StreamableCollectionStore.ts index bbcd354f..687afc75 100644 --- a/front/src/Stores/StreamableCollectionStore.ts +++ b/front/src/Stores/StreamableCollectionStore.ts @@ -1,8 +1,8 @@ -import {derived, get, Readable, writable} from "svelte/store"; -import {ScreenSharingLocalMedia, screenSharingLocalMedia} from "./ScreenSharingStore"; -import { peerStore, screenSharingStreamStore} from "./PeerStore"; -import type {RemotePeer} from "../WebRtc/SimplePeer"; -import {LayoutMode} from "../WebRtc/LayoutManager"; +import { derived, get, Readable, writable } from "svelte/store"; +import { ScreenSharingLocalMedia, screenSharingLocalMedia } from "./ScreenSharingStore"; +import { peerStore, screenSharingStreamStore } from "./PeerStore"; +import type { RemotePeer } from "../WebRtc/SimplePeer"; +import { LayoutMode } from "../WebRtc/LayoutManager"; export type Streamable = RemotePeer | ScreenSharingLocalMedia; @@ -12,32 +12,25 @@ export const layoutModeStore = writable(LayoutMode.Presentation); * A store that contains everything that can produce a stream (so the peers + the local screen sharing stream) */ function createStreamableCollectionStore(): Readable> { + return derived( + [screenSharingStreamStore, peerStore, screenSharingLocalMedia], + ([$screenSharingStreamStore, $peerStore, $screenSharingLocalMedia], set) => { + const peers = new Map(); - return derived([ - screenSharingStreamStore, - peerStore, - screenSharingLocalMedia, - ], ([ - $screenSharingStreamStore, - $peerStore, - $screenSharingLocalMedia, - ], set) => { + const addPeer = (peer: Streamable) => { + peers.set(peer.uniqueId, peer); + }; - const peers = new Map(); + $screenSharingStreamStore.forEach(addPeer); + $peerStore.forEach(addPeer); - const addPeer = (peer: Streamable) => { - peers.set(peer.uniqueId, peer); - }; + if ($screenSharingLocalMedia?.stream) { + addPeer($screenSharingLocalMedia); + } - $screenSharingStreamStore.forEach(addPeer); - $peerStore.forEach(addPeer); - - if ($screenSharingLocalMedia?.stream) { - addPeer($screenSharingLocalMedia); + set(peers); } - - set(peers); - }); + ); } export const streamableCollectionStore = createStreamableCollectionStore(); 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/Stores/VideoFocusStore.ts b/front/src/Stores/VideoFocusStore.ts index 28e948ce..e183a5f3 100644 --- a/front/src/Stores/VideoFocusStore.ts +++ b/front/src/Stores/VideoFocusStore.ts @@ -1,8 +1,8 @@ -import {writable} from "svelte/store"; -import type {RemotePeer, SimplePeer} from "../WebRtc/SimplePeer"; -import {VideoPeer} from "../WebRtc/VideoPeer"; -import {ScreenSharingPeer} from "../WebRtc/ScreenSharingPeer"; -import type {Streamable} from "./StreamableCollectionStore"; +import { writable } from "svelte/store"; +import type { RemotePeer, SimplePeer } from "../WebRtc/SimplePeer"; +import { VideoPeer } from "../WebRtc/VideoPeer"; +import { ScreenSharingPeer } from "../WebRtc/ScreenSharingPeer"; +import type { Streamable } from "./StreamableCollectionStore"; /** * A store that contains the peer / media that has currently the "importance" focus. @@ -32,15 +32,17 @@ function createVideoFocusStore() { }, connectToSimplePeer: (simplePeer: SimplePeer) => { simplePeer.registerPeerConnectionListener({ - onConnect(peer: RemotePeer) { - }, + onConnect(peer: RemotePeer) {}, onDisconnect(userId: number) { - if ((focusedMedia instanceof VideoPeer || focusedMedia instanceof ScreenSharingPeer) && focusedMedia.userId === userId) { + if ( + (focusedMedia instanceof VideoPeer || focusedMedia instanceof ScreenSharingPeer) && + focusedMedia.userId === userId + ) { set(null); } - } - }) - } + }, + }); + }, }; } diff --git a/front/src/Stores/VisibilityStore.ts b/front/src/Stores/VisibilityStore.ts index 4a13ffae..f2ce596c 100644 --- a/front/src/Stores/VisibilityStore.ts +++ b/front/src/Stores/VisibilityStore.ts @@ -1,16 +1,16 @@ -import {readable} from "svelte/store"; +import { readable } from "svelte/store"; /** * A store containing whether the current page is visible or not. */ -export const visibilityStore = readable(document.visibilityState === 'visible', function start(set) { +export const visibilityStore = readable(document.visibilityState === "visible", function start(set) { const onVisibilityChange = () => { - set(document.visibilityState === 'visible'); + set(document.visibilityState === "visible"); }; - document.addEventListener('visibilitychange', onVisibilityChange); + document.addEventListener("visibilitychange", onVisibilityChange); return function stop() { - document.removeEventListener('visibilitychange', onVisibilityChange); + document.removeEventListener("visibilitychange", onVisibilityChange); }; }); 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 b728eca0..fcf04ef1 100644 --- a/front/src/WebRtc/DiscussionManager.ts +++ b/front/src/WebRtc/DiscussionManager.ts @@ -1,231 +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 { iframeListener } from "../Api/IframeListener"; +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(); - - 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..569abd07 100644 --- a/front/src/WebRtc/HtmlUtils.ts +++ b/front/src/WebRtc/HtmlUtils.ts @@ -25,7 +25,7 @@ export class HtmlUtils { } public static escapeHtml(html: string): string { - const text = document.createTextNode(html); + const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g,'
')); const p = document.createElement('p'); p.appendChild(text); return p.innerHTML; diff --git a/front/src/WebRtc/LayoutManager.ts b/front/src/WebRtc/LayoutManager.ts index da295a98..47864139 100644 --- a/front/src/WebRtc/LayoutManager.ts +++ b/front/src/WebRtc/LayoutManager.ts @@ -1,5 +1,5 @@ import type { UserInputManager } from "../Phaser/UserInput/UserInputManager"; -import {HtmlUtils} from "./HtmlUtils"; +import { HtmlUtils } from "./HtmlUtils"; export enum LayoutMode { // All videos are displayed on the right side of the screen. If there is a screen sharing, it is displayed in the middle. @@ -15,40 +15,40 @@ export enum DivImportance { Normal = "Normal", } -export const ON_ACTION_TRIGGER_BUTTON = 'onaction'; +export const ON_ACTION_TRIGGER_BUTTON = "onaction"; -export const TRIGGER_WEBSITE_PROPERTIES = 'openWebsiteTrigger'; -export const TRIGGER_JITSI_PROPERTIES = 'jitsiTrigger'; +export const TRIGGER_WEBSITE_PROPERTIES = "openWebsiteTrigger"; +export const TRIGGER_JITSI_PROPERTIES = "jitsiTrigger"; -export const WEBSITE_MESSAGE_PROPERTIES = 'openWebsiteTriggerMessage'; -export const JITSI_MESSAGE_PROPERTIES = 'jitsiTriggerMessage'; +export const WEBSITE_MESSAGE_PROPERTIES = "openWebsiteTriggerMessage"; +export const JITSI_MESSAGE_PROPERTIES = "jitsiTriggerMessage"; -export const AUDIO_VOLUME_PROPERTY = 'audioVolume'; -export const AUDIO_LOOP_PROPERTY = 'audioLoop'; +export const AUDIO_VOLUME_PROPERTY = "audioVolume"; +export const AUDIO_LOOP_PROPERTY = "audioLoop"; -export type Box = {xStart: number, yStart: number, xEnd: number, yEnd: number}; +export type Box = { xStart: number; yStart: number; xEnd: number; yEnd: number }; class LayoutManager { private actionButtonTrigger: Map = new Map(); private actionButtonInformation: Map = new Map(); - public addActionButton(id: string, text: string, callBack: Function, userInputManager: UserInputManager){ + public addActionButton(id: string, text: string, callBack: Function, userInputManager: UserInputManager) { //delete previous element this.removeActionButton(id, userInputManager); //create div and text html component - const p = document.createElement('p'); - p.classList.add('action-body'); + const p = document.createElement("p"); + p.classList.add("action-body"); p.innerText = text; - const div = document.createElement('div'); - div.classList.add('action'); + const div = document.createElement("div"); + div.classList.add("action"); div.id = id; div.appendChild(p); this.actionButtonInformation.set(id, div); - const mainContainer = HtmlUtils.getElementByIdOrFail('main-container'); + const mainContainer = HtmlUtils.getElementByIdOrFail("main-container"); mainContainer.appendChild(div); //add trigger action @@ -57,42 +57,42 @@ class LayoutManager { userInputManager.addSpaceEventListner(callBack); } - public removeActionButton(id: string, userInputManager?: UserInputManager){ + public removeActionButton(id: string, userInputManager?: UserInputManager) { //delete previous element const previousDiv = this.actionButtonInformation.get(id); - if(previousDiv){ + if (previousDiv) { previousDiv.remove(); this.actionButtonInformation.delete(id); } const previousEventCallback = this.actionButtonTrigger.get(id); - if(previousEventCallback && userInputManager){ + if (previousEventCallback && userInputManager) { userInputManager.removeSpaceEventListner(previousEventCallback); } } - public addInformation(id: string, text: string, callBack?: Function, userInputManager?: UserInputManager){ + public addInformation(id: string, text: string, callBack?: Function, userInputManager?: UserInputManager) { //delete previous element - for ( const [key, value] of this.actionButtonInformation ) { + for (const [key, value] of this.actionButtonInformation) { this.removeActionButton(key, userInputManager); } //create div and text html component - const p = document.createElement('p'); - p.classList.add('action-body'); + const p = document.createElement("p"); + p.classList.add("action-body"); p.innerText = text; - const div = document.createElement('div'); - div.classList.add('action'); + const div = document.createElement("div"); + div.classList.add("action"); div.classList.add(id); div.id = id; div.appendChild(p); this.actionButtonInformation.set(id, div); - const mainContainer = HtmlUtils.getElementByIdOrFail('main-container'); + const mainContainer = HtmlUtils.getElementByIdOrFail("main-container"); mainContainer.appendChild(div); //add trigger action - if(callBack){ + if (callBack) { div.onpointerdown = () => { callBack(); this.removeActionButton(id, userInputManager); @@ -102,7 +102,7 @@ class LayoutManager { //remove it after 10 sec setTimeout(() => { this.removeActionButton(id, userInputManager); - }, 10000) + }, 10000); } } diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index faa5edf7..d7e9f514 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -1,73 +1,65 @@ -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"; +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; -import {cowebsiteCloseButtonId} from "./CoWebsiteManager"; -import {gameOverlayVisibilityStore} from "../Stores/GameOverlayStoreVisibility"; +import { cowebsiteCloseButtonId } from "./CoWebsiteManager"; +import { gameOverlayVisibilityStore } from "../Stores/GameOverlayStoreVisibility"; export class MediaManager { - startScreenSharingCallBacks : Set = new Set(); - stopScreenSharingCallBacks : Set = new Set(); - - - - private focused: boolean = true; + startScreenSharingCallBacks: Set = new Set(); + stopScreenSharingCallBacks: Set = new Set(); private triggerCloseJistiFrame: Map = new Map(); private userInputManager?: UserInputManager; constructor() { - - //Check of ask notification navigator permission - this.getNotification(); - localStreamStore.subscribe((result) => { - if (result.type === 'error') { + if (result.type === "error") { console.error(result.error); - layoutManager.addInformation('warning', 'Camera access denied. Click here and check your browser permissions.', () => { - helpCameraSettingsVisibleStore.set(true); - }, this.userInputManager); + layoutManager.addInformation( + "warning", + "Camera access denied. Click here and check your browser permissions.", + () => { + helpCameraSettingsVisibleStore.set(true); + }, + this.userInputManager + ); return; } }); screenSharingLocalStreamStore.subscribe((result) => { - if (result.type === 'error') { + if (result.type === "error") { console.error(result.error); - layoutManager.addInformation('warning', 'Screen sharing denied. Click here and check your browser permissions.', () => { - helpCameraSettingsVisibleStore.set(true); - }, this.userInputManager); + layoutManager.addInformation( + "warning", + "Screen sharing denied. Click here and check your browser permissions.", + () => { + helpCameraSettingsVisibleStore.set(true); + }, + this.userInputManager + ); return; } }); } public showGameOverlay(): void { - const gameOverlay = HtmlUtils.getElementByIdOrFail('game-overlay'); - gameOverlay.classList.add('active'); + const gameOverlay = HtmlUtils.getElementByIdOrFail("game-overlay"); + gameOverlay.classList.add("active"); const buttonCloseFrame = HtmlUtils.getElementByIdOrFail(cowebsiteCloseButtonId); const functionTrigger = () => { this.triggerCloseJitsiFrameButton(); - } - buttonCloseFrame.removeEventListener('click', () => { + }; + buttonCloseFrame.removeEventListener("click", () => { buttonCloseFrame.blur(); functionTrigger(); }); @@ -76,14 +68,14 @@ export class MediaManager { } public hideGameOverlay(): void { - const gameOverlay = HtmlUtils.getElementByIdOrFail('game-overlay'); - gameOverlay.classList.remove('active'); + const gameOverlay = HtmlUtils.getElementByIdOrFail("game-overlay"); + gameOverlay.classList.remove("active"); const buttonCloseFrame = HtmlUtils.getElementByIdOrFail(cowebsiteCloseButtonId); const functionTrigger = () => { this.triggerCloseJitsiFrameButton(); - } - buttonCloseFrame.addEventListener('click', () => { + }; + buttonCloseFrame.addEventListener("click", () => { buttonCloseFrame.blur(); functionTrigger(); }); @@ -100,7 +92,7 @@ export class MediaManager { if (!element) { return; } - element.classList.add('active') //todo: why does a method 'disable' add a class 'active'? + element.classList.add("active"); //todo: why does a method 'disable' add a class 'active'? } enabledMicrophoneByUserId(userId: number) { @@ -108,7 +100,7 @@ export class MediaManager { if (!element) { return; } - element.classList.remove('active') //todo: why does a method 'enable' remove a class 'active'? + element.classList.remove("active"); //todo: why does a method 'enable' remove a class 'active'? } disabledVideoByUserId(userId: number) { @@ -134,8 +126,8 @@ export class MediaManager { } toggleBlockLogo(userId: number, show: boolean): void { - const blockLogoElement = HtmlUtils.getElementByIdOrFail('blocking-' + userId); - show ? blockLogoElement.classList.add('active') : blockLogoElement.classList.remove('active'); + const blockLogoElement = HtmlUtils.getElementByIdOrFail("blocking-" + userId); + show ? blockLogoElement.classList.add("active") : blockLogoElement.classList.remove("active"); } isError(userId: string): void { @@ -144,27 +136,28 @@ export class MediaManager { if (!element) { return; } - const errorDiv = element.getElementsByClassName('rtc-error').item(0) as HTMLDivElement | null; + const errorDiv = element.getElementsByClassName("rtc-error").item(0) as HTMLDivElement | null; if (errorDiv === null) { return; } - errorDiv.style.display = 'block'; + errorDiv.style.display = "block"; } isErrorScreenSharing(userId: string): void { this.isError(this.getScreenSharingId(userId)); } - private getSpinner(userId: string): HTMLDivElement | null { const element = document.getElementById(`div-${userId}`); if (!element) { return null; } - const connectingSpinnerDiv = element.getElementsByClassName('connecting-spinner').item(0) as HTMLDivElement|null; + const connectingSpinnerDiv = element + .getElementsByClassName("connecting-spinner") + .item(0) as HTMLDivElement | null; return connectingSpinnerDiv; } - public addTriggerCloseJitsiFrameButton(id: String, Function: Function){ + public addTriggerCloseJitsiFrameButton(id: String, Function: Function) { this.triggerCloseJistiFrame.set(id, Function); } @@ -178,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){ + 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"; + } + + public requestNotification() { + if (window.Notification && Notification.permission !== "granted") { + return Notification.requestPermission(); + } else { + return Promise.reject(); } } - /** - * 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; - } - - return true; - } - - public createNotification(userName: string){ - if(this.focused){ + public createNotification(userName: string) { + 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', + 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 be534276..18810182 100644 --- a/front/src/WebRtc/ScreenSharingPeer.ts +++ b/front/src/WebRtc/ScreenSharingPeer.ts @@ -1,13 +1,12 @@ 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 {videoFocusStore} from "../Stores/VideoFocusStore"; +import type { RoomConnection } from "../Connexion/RoomConnection"; +import { MESSAGE_TYPE_CONSTRAINT, PeerStatus } from "./VideoPeer"; +import type { UserSimplePeerInterface } from "./SimplePeer"; +import { Readable, readable } from "svelte/store"; +import { videoFocusStore } from "../Stores/VideoFocusStore"; +import { getIceServersConfig } from "../Components/Video/utils"; -const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer'); +const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); /** * A peer connection used to transmit video / audio signals between 2 peers. @@ -16,7 +15,7 @@ export class ScreenSharingPeer extends Peer { /** * Whether this connection is currently receiving a video stream from a remote user. */ - private isReceivingStream:boolean = false; + private isReceivingStream: boolean = false; public toClose: boolean = false; public _connected: boolean = false; public readonly userId: number; @@ -24,29 +23,25 @@ export class ScreenSharingPeer extends Peer { public readonly streamStore: Readable; public readonly statusStore: Readable; - constructor(user: UserSimplePeerInterface, initiator: boolean, public readonly userName: string, private connection: RoomConnection, stream: MediaStream | null) { + constructor( + user: UserSimplePeerInterface, + initiator: boolean, + public readonly userName: string, + private connection: RoomConnection, + 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), + }, }); this.userId = user.userId; - this.uniqueId = 'screensharing_'+this.userId; + this.uniqueId = "screensharing_" + this.userId; - this.streamStore = readable(null, (set) => { - const onStream = (stream: MediaStream|null) => { + this.streamStore = readable(null, (set) => { + const onStream = (stream: MediaStream | null) => { videoFocusStore.focus(this); set(stream); }; @@ -54,71 +49,71 @@ export class ScreenSharingPeer extends Peer { // We unfortunately need to rely on an event to let the other party know a stream has stopped. // It seems there is no native way to detect that. // TODO: we might rely on the "ended" event: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended_event - const message = JSON.parse(chunk.toString('utf8')); + const message = JSON.parse(chunk.toString("utf8")); if (message.streamEnded !== true) { - console.error('Unexpected message on screen sharing peer connection'); + console.error("Unexpected message on screen sharing peer connection"); return; } set(null); - } + }; - this.on('stream', onStream); - this.on('data', onData); + this.on("stream", onStream); + this.on("data", onData); return () => { - this.off('stream', onStream); - this.off('data', onData); + this.off("stream", onStream); + this.off("data", onData); }; }); this.statusStore = readable("connecting", (set) => { const onConnect = () => { - set('connected'); + set("connected"); }; const onError = () => { - set('error'); + set("error"); }; const onClose = () => { - set('closed'); + set("closed"); }; - this.on('connect', onConnect); - this.on('error', onError); - this.on('close', onClose); + this.on("connect", onConnect); + this.on("error", onError); + this.on("close", onClose); return () => { - this.off('connect', onConnect); - this.off('error', onError); - this.off('close', onClose); + this.off("connect", onConnect); + this.off("error", onError); + this.off("close", onClose); }; }); //start listen signal for the peer connection - this.on('signal', (data: unknown) => { + this.on("signal", (data: unknown) => { this.sendWebrtcScreenSharingSignal(data); }); - this.on('stream', (stream: MediaStream) => { + this.on("stream", (stream: MediaStream) => { this.stream(stream); }); - this.on('close', () => { + this.on("close", () => { this._connected = false; this.toClose = true; this.destroy(); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.on('error', (err: any) => { + this.on("error", (err: any) => { console.error(`screen sharing error => ${this.userId} => ${err.code}`, err); }); - this.on('connect', () => { + this.on("connect", () => { this._connected = true; console.info(`connect => ${this.userId}`); }); - this.once('finish', () => { + this.once("finish", () => { this._onFinish(); }); @@ -130,7 +125,7 @@ export class ScreenSharingPeer extends Peer { private sendWebrtcScreenSharingSignal(data: unknown) { try { this.connection.sendWebrtcScreenSharingSignal(data, this.userId); - }catch (e) { + } catch (e) { console.error(`sendWebrtcScreenSharingSignal => ${this.userId}`, e); } } @@ -139,7 +134,7 @@ export class ScreenSharingPeer extends Peer { * Sends received stream to screen. */ private stream(stream?: MediaStream) { - if(!stream){ + if (!stream) { this.isReceivingStream = false; } else { this.isReceivingStream = true; @@ -152,8 +147,8 @@ export class ScreenSharingPeer extends Peer { public destroy(error?: Error): void { try { - this._connected = false - if(!this.toClose){ + this._connected = false; + if (!this.toClose) { return; } // FIXME: I don't understand why "Closing connection with" message is displayed TWICE before "Nb users in peerConnectionArray" @@ -162,24 +157,24 @@ export class ScreenSharingPeer extends Peer { super.destroy(error); //console.log('Nb users in peerConnectionArray '+this.PeerConnectionArray.size); } catch (err) { - console.error("ScreenSharingPeer::destroy", err) + console.error("ScreenSharingPeer::destroy", err); } } - _onFinish () { - if (this.destroyed) return + _onFinish() { + if (this.destroyed) return; const destroySoon = () => { this.destroy(); - } + }; if (this._connected) { destroySoon(); } else { - this.once('connect', destroySoon); + this.once("connect", destroySoon); } } public stopPushingScreenSharingToRemoteUser(stream: MediaStream) { this.removeStream(stream); - this.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, streamEnded: true}))); + this.write(new Buffer(JSON.stringify({ type: MESSAGE_TYPE_CONSTRAINT, streamEnded: true }))); } } diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index ecc0e21b..e30f1b1f 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -2,26 +2,23 @@ import type { WebRtcDisconnectMessageInterface, WebRtcSignalReceivedMessageInterface, } from "../Connexion/ConnexionModels"; -import { - mediaManager, - StartScreenSharingCallback, - StopScreenSharingCallback, -} from "./MediaManager"; -import {ScreenSharingPeer} from "./ScreenSharingPeer"; -import {MESSAGE_TYPE_BLOCKED, MESSAGE_TYPE_CONSTRAINT, MESSAGE_TYPE_MESSAGE, VideoPeer} from "./VideoPeer"; -import type {RoomConnection} from "../Connexion/RoomConnection"; -import {blackListManager} from "./BlackListManager"; -import {get} from "svelte/store"; -import {localStreamStore, LocalStreamStoreValue, obtainedMediaConstraintStore} from "../Stores/MediaStore"; -import {screenSharingLocalStreamStore} from "../Stores/ScreenSharingStore"; -import {discussionManager} from "./DiscussionManager"; +import { mediaManager, StartScreenSharingCallback, StopScreenSharingCallback } from "./MediaManager"; +import { ScreenSharingPeer } from "./ScreenSharingPeer"; +import { MESSAGE_TYPE_BLOCKED, MESSAGE_TYPE_CONSTRAINT, MESSAGE_TYPE_MESSAGE, VideoPeer } from "./VideoPeer"; +import type { RoomConnection } from "../Connexion/RoomConnection"; +import { blackListManager } from "./BlackListManager"; +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{ +export interface UserSimplePeerInterface { userId: number; - name?: string; initiator?: boolean; - webRtcUser?: string|undefined; - webRtcPassword?: string|undefined; + webRtcUser?: string | undefined; + webRtcPassword?: string | undefined; } export type RemotePeer = VideoPeer | ScreenSharingPeer; @@ -45,36 +42,40 @@ export class SimplePeer { private readonly unsubscribers: (() => void)[] = []; private readonly peerConnectionListeners: Array = new Array(); private readonly userId: number; - private lastWebrtcUserName: string|undefined; - private lastWebrtcPassword: string|undefined; + private lastWebrtcUserName: string | undefined; + private lastWebrtcPassword: string | undefined; constructor(private Connection: RoomConnection, private enableReporting: boolean, private myName: string) { // We need to go through this weird bound function pointer in order to be able to "free" this reference later. this.sendLocalScreenSharingStreamCallback = this.sendLocalScreenSharingStream.bind(this); this.stopLocalScreenSharingStreamCallback = this.stopLocalScreenSharingStream.bind(this); - this.unsubscribers.push(localStreamStore.subscribe((streamResult) => { - this.sendLocalVideoStream(streamResult); - })); + this.unsubscribers.push( + localStreamStore.subscribe((streamResult) => { + this.sendLocalVideoStream(streamResult); + }) + ); - let localScreenCapture: MediaStream|null = null; + let localScreenCapture: MediaStream | null = null; - this.unsubscribers.push(screenSharingLocalStreamStore.subscribe((streamResult) => { - if (streamResult.type === 'error') { - // Let's ignore screen sharing errors, we will deal with those in a different way. - return; - } - - if (streamResult.stream !== null) { - localScreenCapture = streamResult.stream; - this.sendLocalScreenSharingStream(localScreenCapture); - } else { - if (localScreenCapture) { - this.stopLocalScreenSharingStream(localScreenCapture); - localScreenCapture = null; + this.unsubscribers.push( + screenSharingLocalStreamStore.subscribe((streamResult) => { + if (streamResult.type === "error") { + // Let's ignore screen sharing errors, we will deal with those in a different way. + return; } - } - })); + + if (streamResult.stream !== null) { + localScreenCapture = streamResult.stream; + this.sendLocalScreenSharingStream(localScreenCapture); + } else { + if (localScreenCapture) { + this.stopLocalScreenSharingStream(localScreenCapture); + localScreenCapture = null; + } + } + }) + ); this.userId = Connection.getUserId(); this.initialise(); @@ -92,7 +93,6 @@ export class SimplePeer { * permit to listen when user could start visio */ private initialise() { - //receive signal by gemer this.Connection.receiveWebrtcSignal((message: WebRtcSignalReceivedMessageInterface) => { this.receiveWebrtcSignal(message); @@ -122,12 +122,12 @@ export class SimplePeer { // This would be symmetrical to the way we handle disconnection. //start connection - if(!user.initiator){ + if (!user.initiator) { return; } const streamResult = get(localStreamStore); - let stream : MediaStream | null = null; - if (streamResult.type === 'success' && streamResult.stream) { + let stream: MediaStream | null = null; + if (streamResult.type === "success" && streamResult.stream) { stream = streamResult.stream; } @@ -137,15 +137,15 @@ export class SimplePeer { /** * create peer connection to bind users */ - private createPeerConnection(user : UserSimplePeerInterface, localStream: MediaStream | null) : VideoPeer | null { - const peerConnection = this.PeerConnectionArray.get(user.userId) + private createPeerConnection(user: UserSimplePeerInterface, localStream: MediaStream | null): VideoPeer | null { + const peerConnection = this.PeerConnectionArray.get(user.userId); if (peerConnection) { if (peerConnection.destroyed) { peerConnection.toClose = true; peerConnection.destroy(); const peerConnexionDeleted = this.PeerConnectionArray.delete(user.userId); if (!peerConnexionDeleted) { - throw 'Error to delete peer connection'; + throw "Error to delete peer connection"; } //return this.createPeerConnection(user, localStream); } else { @@ -154,36 +154,26 @@ 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! - peer.on('connect', () => { + peer.on("connect", () => { const streamResult = get(screenSharingLocalStreamStore); - if (streamResult.type === 'success' && streamResult.stream !== null) { + if (streamResult.type === "success" && streamResult.stream !== null) { this.sendLocalScreenSharingStreamToUser(user.userId, streamResult.stream); } }); //Create a notification for first user in circle discussion - if(this.PeerConnectionArray.size === 0){ - mediaManager.createNotification(user.name??''); + if (this.PeerConnectionArray.size === 0) { + mediaManager.createNotification(name); } this.PeerConnectionArray.set(user.userId, peer); @@ -194,29 +184,27 @@ 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 || ""; } /** * create peer connection to bind users */ - private createPeerScreenSharingConnection(user : UserSimplePeerInterface, stream: MediaStream | null) : ScreenSharingPeer | null{ + private createPeerScreenSharingConnection( + user: UserSimplePeerInterface, + stream: MediaStream | null + ): ScreenSharingPeer | null { const peerConnection = this.PeerScreenSharingConnectionArray.get(user.userId); - if(peerConnection){ - if(peerConnection.destroyed){ + if (peerConnection) { + if (peerConnection.destroyed) { peerConnection.toClose = true; peerConnection.destroy(); const peerConnexionDeleted = this.PeerScreenSharingConnectionArray.delete(user.userId); - if(!peerConnexionDeleted){ - throw 'Error to delete peer connection'; + if (!peerConnexionDeleted) { + throw "Error to delete peer connection"; } this.createPeerConnection(user, stream); - }else { + } else { peerConnection.toClose = false; } return null; @@ -230,7 +218,13 @@ export class SimplePeer { const name = this.getName(user.userId); - const peer = new ScreenSharingPeer(user, user.initiator ? user.initiator : false, name, this.Connection, stream); + const peer = new ScreenSharingPeer( + user, + user.initiator ? user.initiator : false, + name, + this.Connection, + stream + ); this.PeerScreenSharingConnectionArray.set(user.userId, peer); for (const peerConnectionListener of this.peerConnectionListeners) { @@ -242,11 +236,13 @@ export class SimplePeer { /** * This is triggered twice. Once by the server, and once by a remote client disconnecting */ - private closeConnection(userId : number) { + private closeConnection(userId: number) { try { const peer = this.PeerConnectionArray.get(userId); if (peer === undefined) { - console.warn("closeConnection => Tried to close connection for user "+userId+" but could not find user"); + console.warn( + "closeConnection => Tried to close connection for user " + userId + " but could not find user" + ); return; } //create temp perr to close @@ -257,18 +253,18 @@ export class SimplePeer { this.closeScreenSharingConnection(userId); - const userIndex = this.Users.findIndex(user => user.userId === userId); - if(userIndex < 0){ - throw 'Couldn\'t delete user'; + const userIndex = this.Users.findIndex((user) => user.userId === userId); + if (userIndex < 0) { + throw "Couldn't delete user"; } else { this.Users.splice(userIndex, 1); } } catch (err) { - console.error("closeConnection", err) + console.error("closeConnection", err); } //if user left discussion, clear array peer connection of sharing - if(this.Users.length === 0) { + if (this.Users.length === 0) { for (const userId of this.PeerScreenSharingConnectionArray.keys()) { this.closeScreenSharingConnection(userId); this.PeerScreenSharingConnectionArray.delete(userId); @@ -283,12 +279,16 @@ export class SimplePeer { /** * This is triggered twice. Once by the server, and once by a remote client disconnecting */ - private closeScreenSharingConnection(userId : number) { + private closeScreenSharingConnection(userId: number) { try { //mediaManager.removeActiveScreenSharingVideo("" + userId); const peer = this.PeerScreenSharingConnectionArray.get(userId); if (peer === undefined) { - console.warn("closeScreenSharingConnection => Tried to close connection for user "+userId+" but could not find user") + console.warn( + "closeScreenSharingConnection => Tried to close connection for user " + + userId + + " but could not find user" + ); return; } // FIXME: I don't understand why "Closing connection with" message is displayed TWICE before "Nb users in peerConnectionArray" @@ -301,7 +301,7 @@ export class SimplePeer { }*/ //console.log('Nb users in peerConnectionArray '+this.PeerConnectionArray.size); } catch (err) { - console.error("closeConnection", err) + console.error("closeConnection", err); } } @@ -328,10 +328,10 @@ export class SimplePeer { private receiveWebrtcSignal(data: WebRtcSignalReceivedMessageInterface) { try { //if offer type, create peer connection - if(data.signal.type === "offer"){ + if (data.signal.type === "offer") { const streamResult = get(localStreamStore); - let stream : MediaStream | null = null; - if (streamResult.type === 'success' && streamResult.stream) { + let stream: MediaStream | null = null; + if (streamResult.type === "success" && streamResult.stream) { stream = streamResult.stream; } @@ -341,7 +341,7 @@ export class SimplePeer { if (peer !== undefined) { peer.signal(data.signal); } else { - console.error('Could not find peer whose ID is "'+data.userId+'" in PeerConnectionArray'); + console.error('Could not find peer whose ID is "' + data.userId + '" in PeerConnectionArray'); } } catch (e) { console.error(`receiveWebrtcSignal => ${data.userId}`, e); @@ -349,25 +349,28 @@ 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; - if (streamResult.type === 'success' && streamResult.stream !== null) { + let stream: MediaStream | null = null; + if (streamResult.type === "success" && streamResult.stream !== null) { stream = streamResult.stream; } try { //if offer type, create peer connection - if(data.signal.type === "offer"){ + if (data.signal.type === "offer") { this.createPeerScreenSharingConnection(data, stream); } const peer = this.PeerScreenSharingConnectionArray.get(data.userId); if (peer !== undefined) { peer.signal(data.signal); } else { - console.error('Could not find peer whose ID is "'+data.userId+'" in receiveWebrtcScreenSharingSignal'); - console.info('Attempt to create new peer connexion'); + console.error( + 'Could not find peer whose ID is "' + data.userId + '" in receiveWebrtcScreenSharingSignal' + ); + console.info("Attempt to create new peer connexion"); if (stream) { this.sendLocalScreenSharingStreamToUser(data.userId, stream); } @@ -384,17 +387,19 @@ export class SimplePeer { try { const PeerConnection = this.PeerConnectionArray.get(userId); if (!PeerConnection) { - throw new Error('While adding media, cannot find user with ID ' + userId); + throw new Error("While adding media, cannot find user with ID " + userId); } - PeerConnection.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...streamResult.constraints}))); + PeerConnection.write( + new Buffer(JSON.stringify({ type: MESSAGE_TYPE_CONSTRAINT, ...streamResult.constraints })) + ); - if (streamResult.type === 'error') { + if (streamResult.type === "error") { return; } const localStream: MediaStream | null = streamResult.stream; - if(!localStream){ + if (!localStream) { return; } @@ -404,7 +409,7 @@ export class SimplePeer { (track as any).added = true; // eslint-disable-line @typescript-eslint/no-explicit-any PeerConnection.addTrack(track, localStream); } - }catch (e) { + } catch (e) { console.error(`pushVideoToRemoteUser => ${userId}`, e); } } @@ -412,7 +417,7 @@ export class SimplePeer { private pushScreenSharingToRemoteUser(userId: number, localScreenCapture: MediaStream) { const PeerConnection = this.PeerScreenSharingConnectionArray.get(userId); if (!PeerConnection) { - throw new Error('While pushing screen sharing, cannot find user with ID ' + userId); + throw new Error("While pushing screen sharing, cannot find user with ID " + userId); } for (const track of localScreenCapture.getTracks()) { @@ -421,7 +426,7 @@ export class SimplePeer { return; } - public sendLocalVideoStream(streamResult: LocalStreamStoreValue){ + public sendLocalVideoStream(streamResult: LocalStreamStoreValue) { for (const user of this.Users) { this.pushVideoToRemoteUser(user.userId, streamResult); } @@ -446,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); @@ -455,9 +461,12 @@ export class SimplePeer { const screenSharingUser: UserSimplePeerInterface = { userId, - initiator: true + initiator: true, }; - const PeerConnectionScreenSharing = this.createPeerScreenSharingConnection(screenSharingUser, localScreenCapture); + const PeerConnectionScreenSharing = this.createPeerScreenSharingConnection( + screenSharingUser, + localScreenCapture + ); if (!PeerConnectionScreenSharing) { return; } @@ -466,7 +475,7 @@ export class SimplePeer { private stopLocalScreenSharingStreamToUser(userId: number, stream: MediaStream): void { const PeerConnectionScreenSharing = this.PeerScreenSharingConnectionArray.get(userId); if (!PeerConnectionScreenSharing) { - throw new Error('Weird, screen sharing connection to user ' + userId + 'not found') + throw new Error("Weird, screen sharing connection to user " + userId + "not found"); } console.log("updatedScreenSharing => destroy", PeerConnectionScreenSharing); diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index 5b5212b9..aee3f735 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -1,22 +1,23 @@ 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 {obtainedMediaConstraintStore} from "../Stores/MediaStore"; -import {discussionManager} from "./DiscussionManager"; +import { mediaManager } from "./MediaManager"; +import type { RoomConnection } from "../Connexion/RoomConnection"; +import { blackListManager } from "./BlackListManager"; +import type { Subscription } from "rxjs"; +import type { UserSimplePeerInterface } from "./SimplePeer"; +import { get, readable, Readable, Unsubscriber } from "svelte/store"; +import { obtainedMediaConstraintStore } from "../Stores/MediaStore"; +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'); +const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); export type PeerStatus = "connecting" | "connected" | "error" | "closed"; -export const MESSAGE_TYPE_CONSTRAINT = 'constraint'; -export const MESSAGE_TYPE_MESSAGE = 'message'; -export const MESSAGE_TYPE_BLOCKED = 'blocked'; -export const MESSAGE_TYPE_UNBLOCKED = 'unblocked'; +export const MESSAGE_TYPE_CONSTRAINT = "constraint"; +export const MESSAGE_TYPE_MESSAGE = "message"; +export const MESSAGE_TYPE_BLOCKED = "blocked"; +export const MESSAGE_TYPE_UNBLOCKED = "unblocked"; /** * A peer connection used to transmit video / audio signals between 2 peers. */ @@ -26,121 +27,135 @@ 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; + 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, initiator: boolean, public readonly userName: string, private connection: RoomConnection, localStream: MediaStream | null) { + constructor( + public user: UserSimplePeerInterface, + initiator: boolean, + public readonly userName: string, + private connection: RoomConnection, + 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.uniqueId = 'video_'+this.userId; + this.userUuid = playersStore.getPlayerById(this.userId)?.userUuid || ""; + this.uniqueId = "video_" + this.userId; - this.streamStore = readable(null, (set) => { - const onStream = (stream: MediaStream|null) => { + this.streamStore = readable(null, (set) => { + const onStream = (stream: MediaStream | null) => { set(stream); }; const onData = (chunk: Buffer) => { - this.on('data', (chunk: Buffer) => { - const message = JSON.parse(chunk.toString('utf8')); + this.on("data", (chunk: Buffer) => { + const message = JSON.parse(chunk.toString("utf8")); if (message.type === MESSAGE_TYPE_CONSTRAINT) { if (!message.video) { set(null); } } }); - } + }; - this.on('stream', onStream); - this.on('data', onData); + this.on("stream", onStream); + this.on("data", onData); return () => { - this.off('stream', onStream); - this.off('data', onData); + this.off("stream", onStream); + this.off("data", onData); }; }); - this.constraintsStore = readable(null, (set) => { + this.constraintsStore = readable(null, (set) => { const onData = (chunk: Buffer) => { - const message = JSON.parse(chunk.toString('utf8')); - if(message.type === MESSAGE_TYPE_CONSTRAINT) { + const message = JSON.parse(chunk.toString("utf8")); + if (message.type === MESSAGE_TYPE_CONSTRAINT) { set(message); } - } + }; - this.on('data', onData); + this.on("data", onData); return () => { - this.off('data', onData); + this.off("data", onData); }; }); this.statusStore = readable("connecting", (set) => { const onConnect = () => { - set('connected'); + set("connected"); }; const onError = () => { - set('error'); + set("error"); }; const onClose = () => { - set('closed'); + set("closed"); }; - this.on('connect', onConnect); - this.on('error', onError); - this.on('close', onClose); + this.on("connect", onConnect); + this.on("error", onError); + this.on("close", onClose); return () => { - this.off('connect', onConnect); - this.off('error', onError); - this.off('close', onClose); + this.off("connect", onConnect); + this.off("error", onError); + this.off("close", onClose); }; }); //start listen signal for the peer connection - this.on('signal', (data: unknown) => { + this.on("signal", (data: unknown) => { this.sendWebrtcSignal(data); }); - this.on('stream', (stream: MediaStream) => this.stream(stream)); + this.on("stream", (stream: MediaStream) => this.stream(stream)); - this.on('close', () => { + this.on("close", () => { this._connected = false; this.toClose = true; this.destroy(); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.on('error', (err: any) => { + this.on("error", (err: any) => { console.error(`error => ${this.userId} => ${err.code}`, err); mediaManager.isError("" + this.userId); }); - this.on('connect', () => { + 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) => { - const message = JSON.parse(chunk.toString('utf8')); - if(message.type === MESSAGE_TYPE_CONSTRAINT) { + this.on("data", (chunk: Buffer) => { + const message = JSON.parse(chunk.toString("utf8")); + if (message.type === MESSAGE_TYPE_CONSTRAINT) { if (message.audio) { mediaManager.enabledMicrophoneByUserId(this.userId); } else { @@ -152,58 +167,67 @@ export class VideoPeer extends Peer { } else { mediaManager.disabledVideoByUserId(this.userId); } - } else if(message.type === MESSAGE_TYPE_MESSAGE) { - if (!blackListManager.isBlackListed(message.userId)) { - mediaManager.addNewMessage(message.name, message.message); + } else if (message.type === MESSAGE_TYPE_MESSAGE) { + if (!blackListManager.isBlackListed(this.userUuid)) { + chatMessagesStore.addExternalMessage(this.userId, message.message); } - } else if(message.type === MESSAGE_TYPE_BLOCKED) { + } 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. // Find a way to block A's output stream in A's js client //However, the output stream stream B is correctly blocked in A client this.blocked = true; this.toggleRemoteStream(false); - } else if(message.type === MESSAGE_TYPE_UNBLOCKED) { + } else if (message.type === MESSAGE_TYPE_UNBLOCKED) { this.blocked = false; this.toggleRemoteStream(true); } }); - this.once('finish', () => { + this.once("finish", () => { this._onFinish(); }); 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)) { - this.sendBlockMessage(true) + if (blackListManager.isBlackListed(this.userUuid)) { + this.sendBlockMessage(true); } } private sendBlockMessage(blocking: boolean) { - this.write(new Buffer(JSON.stringify({type: blocking ? MESSAGE_TYPE_BLOCKED : MESSAGE_TYPE_UNBLOCKED, name: this.userName.toUpperCase(), userId: this.userId, message: ''}))); + this.write( + new Buffer( + JSON.stringify({ + type: blocking ? MESSAGE_TYPE_BLOCKED : MESSAGE_TYPE_UNBLOCKED, + name: this.userName.toUpperCase(), + userId: this.userId, + message: "", + }) + ) + ); } private toggleRemoteStream(enable: boolean) { - this.remoteStream.getTracks().forEach(track => track.enabled = enable); + this.remoteStream.getTracks().forEach((track) => (track.enabled = enable)); mediaManager.toggleBlockLogo(this.userId, !enable); } private sendWebrtcSignal(data: unknown) { try { this.connection.sendWebrtcSignal(data, this.userId); - }catch (e) { + } catch (e) { console.error(`sendWebrtcSignal => ${this.userId}`, e); } } @@ -214,10 +238,10 @@ 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){ + } catch (err) { console.error(err); } } @@ -225,47 +249,49 @@ 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){ + this._connected = false; + 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) + console.error("VideoPeer::destroy", err); } } - _onFinish () { - if (this.destroyed) return + _onFinish() { + if (this.destroyed) return; const destroySoon = () => { this.destroy(); - } + }; if (this._connected) { destroySoon(); } else { - this.once('connect', destroySoon); + this.once("connect", destroySoon); } } private pushVideoToRemoteUser(localStream: MediaStream | null) { try { - this.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...get(obtainedMediaConstraintStore)}))); + this.write( + new Buffer(JSON.stringify({ type: MESSAGE_TYPE_CONSTRAINT, ...get(obtainedMediaConstraintStore) })) + ); - if(!localStream){ + if (!localStream) { return; } for (const track of localStream.getTracks()) { this.addTrack(track, localStream); } - }catch (e) { + } catch (e) { console.error(`pushVideoToRemoteUser => ${this.userId}`, e); } } diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index 0c2a7fde..189457ab 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -1,12 +1,12 @@ import { registeredCallbacks } from "./Api/iframe/registeredCallbacks"; import { IframeResponseEvent, - IframeResponseEventMap, + IframeResponseEventMap, isIframeAnswerEvent, isIframeErrorAnswerEvent, isIframeResponseEventWrapper, - TypedMessageEvent + TypedMessageEvent, } from "./Api/Events/IframeEvent"; import chat from "./Api/iframe/chat"; -import type { IframeCallback } from './Api/iframe/IframeApiContribution'; +import type { IframeCallback } from "./Api/iframe/IframeApiContribution"; import nav from "./Api/iframe/nav"; import controls from "./Api/iframe/controls"; import ui from "./Api/iframe/ui"; @@ -16,7 +16,7 @@ import player 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 {sendToWorkadventure} from "./Api/iframe/IframeApiContribution"; +import { answerPromises, sendToWorkadventure} from "./Api/iframe/IframeApiContribution"; const wa = { ui, @@ -34,7 +34,7 @@ const wa = { * @deprecated Use WA.chat.sendChatMessage instead */ sendChatMessage(message: string, author: string): void { - console.warn('Method WA.sendChatMessage is deprecated. Please use WA.chat.sendChatMessage instead'); + console.warn("Method WA.sendChatMessage is deprecated. Please use WA.chat.sendChatMessage instead"); chat.sendChatMessage(message, author); }, @@ -42,7 +42,9 @@ const wa = { * @deprecated Use WA.chat.disablePlayerControls instead */ disablePlayerControls(): void { - console.warn('Method WA.disablePlayerControls is deprecated. Please use WA.controls.disablePlayerControls instead'); + console.warn( + "Method WA.disablePlayerControls is deprecated. Please use WA.controls.disablePlayerControls instead" + ); controls.disablePlayerControls(); }, @@ -50,7 +52,9 @@ const wa = { * @deprecated Use WA.controls.restorePlayerControls instead */ restorePlayerControls(): void { - console.warn('Method WA.restorePlayerControls is deprecated. Please use WA.controls.restorePlayerControls instead'); + console.warn( + "Method WA.restorePlayerControls is deprecated. Please use WA.controls.restorePlayerControls instead" + ); controls.restorePlayerControls(); }, @@ -58,7 +62,7 @@ const wa = { * @deprecated Use WA.ui.displayBubble instead */ displayBubble(): void { - console.warn('Method WA.displayBubble is deprecated. Please use WA.ui.displayBubble instead'); + console.warn("Method WA.displayBubble is deprecated. Please use WA.ui.displayBubble instead"); ui.displayBubble(); }, @@ -66,7 +70,7 @@ const wa = { * @deprecated Use WA.ui.removeBubble instead */ removeBubble(): void { - console.warn('Method WA.removeBubble is deprecated. Please use WA.ui.removeBubble instead'); + console.warn("Method WA.removeBubble is deprecated. Please use WA.ui.removeBubble instead"); ui.removeBubble(); }, @@ -74,7 +78,7 @@ const wa = { * @deprecated Use WA.nav.openTab instead */ openTab(url: string): void { - console.warn('Method WA.openTab is deprecated. Please use WA.nav.openTab instead'); + console.warn("Method WA.openTab is deprecated. Please use WA.nav.openTab instead"); nav.openTab(url); }, @@ -82,7 +86,7 @@ const wa = { * @deprecated Use WA.sound.loadSound instead */ loadSound(url: string): Sound { - console.warn('Method WA.loadSound is deprecated. Please use WA.sound.loadSound instead'); + console.warn("Method WA.loadSound is deprecated. Please use WA.sound.loadSound instead"); return sound.loadSound(url); }, @@ -90,7 +94,7 @@ const wa = { * @deprecated Use WA.nav.goToPage instead */ goToPage(url: string): void { - console.warn('Method WA.goToPage is deprecated. Please use WA.nav.goToPage instead'); + console.warn("Method WA.goToPage is deprecated. Please use WA.nav.goToPage instead"); nav.goToPage(url); }, @@ -98,7 +102,7 @@ const wa = { * @deprecated Use WA.nav.goToRoom instead */ goToRoom(url: string): void { - console.warn('Method WA.goToRoom is deprecated. Please use WA.nav.goToRoom instead'); + console.warn("Method WA.goToRoom is deprecated. Please use WA.nav.goToRoom instead"); nav.goToRoom(url); }, @@ -106,7 +110,7 @@ const wa = { * @deprecated Use WA.nav.openCoWebSite instead */ openCoWebSite(url: string, allowApi: boolean = false, allowPolicy: string = ""): void { - console.warn('Method WA.openCoWebSite is deprecated. Please use WA.nav.openCoWebSite instead'); + console.warn("Method WA.openCoWebSite is deprecated. Please use WA.nav.openCoWebSite instead"); nav.openCoWebSite(url, allowApi, allowPolicy); }, @@ -114,36 +118,36 @@ const wa = { * @deprecated Use WA.nav.closeCoWebSite instead */ closeCoWebSite(): void { - console.warn('Method WA.closeCoWebSite is deprecated. Please use WA.nav.closeCoWebSite instead'); + console.warn("Method WA.closeCoWebSite is deprecated. Please use WA.nav.closeCoWebSite instead"); nav.closeCoWebSite(); }, /** - * @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'); + console.warn("Method WA.openPopup is deprecated. Please use WA.ui.openPopup instead"); return ui.openPopup(targetObject, message, buttons); }, /** * @deprecated Use WA.chat.onChatMessage instead */ onChatMessage(callback: (message: string) => void): void { - console.warn('Method WA.onChatMessage is deprecated. Please use WA.chat.onChatMessage instead'); + console.warn("Method WA.onChatMessage is deprecated. Please use WA.chat.onChatMessage instead"); chat.onChatMessage(callback); }, /** * @deprecated Use WA.room.onEnterZone instead */ onEnterZone(name: string, callback: () => void): void { - console.warn('Method WA.onEnterZone is deprecated. Please use WA.room.onEnterZone instead'); + console.warn("Method WA.onEnterZone is deprecated. Please use WA.room.onEnterZone instead"); room.onEnterZone(name, callback); }, /** * @deprecated Use WA.room.onLeaveZone instead */ onLeaveZone(name: string, callback: () => void): void { - console.warn('Method WA.onLeaveZone is deprecated. Please use WA.room.onLeaveZone instead'); + console.warn("Method WA.onLeaveZone is deprecated. Please use WA.room.onLeaveZone instead"); room.onLeaveZone(name, callback); }, }; @@ -151,30 +155,54 @@ const wa = { export type WorkAdventureApi = typeof wa; declare global { - interface Window { - WA: WorkAdventureApi + WA: WorkAdventureApi; } - let WA: WorkAdventureApi + let WA: WorkAdventureApi; } window.WA = wa; -window.addEventListener('message', (message: TypedMessageEvent>) => { +window.addEventListener( + "message", (message: TypedMessageEvent>) => { if (message.source !== window.parent) { return; // Skip message in this event listener } const payload = message.data; + console.debug(payload); - if (isIframeResponseEventWrapper(payload)) { + if (isIframeAnswerEvent(payload)) { + const queryId = payload.id; const payloadData = payload.data; - const callback = registeredCallbacks[payload.type] as IframeCallback | undefined - if (callback?.typeChecker(payloadData)) { - callback?.callback(payloadData) + const resolver = answerPromises.get(queryId); + if (resolver === undefined) { + throw new Error('In Iframe API, got an answer for a question that we have no track of.'); } - } + 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; + + const callback = registeredCallbacks[payload.type] as IframeCallback | undefined; + if (callback?.typeChecker(payloadData)) { + callback?.callback(payloadData); + } + } + + // ... + } +); diff --git a/front/src/index.ts b/front/src/index.ts index 59e748b4..3d53ac89 100644 --- a/front/src/index.ts +++ b/front/src/index.ts @@ -160,3 +160,15 @@ const app = new 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 80624d64..00000000 --- a/front/tests/Phaser/Game/RoomTest.ts +++ /dev/null @@ -1,58 +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(''); - }); - 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(''); - }); - - - 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(''); - }); - 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(''); - }); - 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(''); - }); - 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(''); - }); - 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(''); - }); - 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(''); - }); - - 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"); - }); -}); \ No newline at end of file diff --git a/front/tests/Phaser/Map/LayersIteratorTest.ts b/front/tests/Phaser/Map/LayersIteratorTest.ts index 6cc4c0ff..a3951a7a 100644 --- a/front/tests/Phaser/Map/LayersIteratorTest.ts +++ b/front/tests/Phaser/Map/LayersIteratorTest.ts @@ -1,150 +1,155 @@ import "jasmine"; -import {Room} from "../../../src/Connexion/Room"; -import {flattenGroupLayersMap} from "../../../src/Phaser/Map/LayersFlattener"; -import type {ITiledMapLayer} from "../../../src/Phaser/Map/ITiledMap"; +import { Room } from "../../../src/Connexion/Room"; +import { flattenGroupLayersMap } from "../../../src/Phaser/Map/LayersFlattener"; +import type { ITiledMapLayer } from "../../../src/Phaser/Map/ITiledMap"; describe("Layers flattener", () => { it("should iterate maps with no group", () => { - let flatLayers:ITiledMapLayer[] = []; + let flatLayers: ITiledMapLayer[] = []; flatLayers = flattenGroupLayersMap({ - "compressionlevel": -1, - "height": 2, - "infinite": false, - "layers": [ + compressionlevel: -1, + height: 2, + infinite: false, + layers: [ { - "data": [0, 0, 0, 0], - "height": 2, - "id": 1, - "name": "Tile Layer 1", - "opacity": 1, - "type": "tilelayer", - "visible": true, - "width": 2, - "x": 0, - "y": 0 + data: [0, 0, 0, 0], + height: 2, + id: 1, + name: "Tile Layer 1", + opacity: 1, + type: "tilelayer", + visible: true, + width: 2, + x: 0, + y: 0, }, { - "data": [0, 0, 0, 0], - "height": 2, - "id": 1, - "name": "Tile Layer 2", - "opacity": 1, - "type": "tilelayer", - "visible": true, - "width": 2, - "x": 0, - "y": 0 - }], - "nextlayerid": 2, - "nextobjectid": 1, - "orientation": "orthogonal", - "renderorder": "right-down", - "tiledversion": "2021.03.23", - "tileheight": 32, - "tilesets": [], - "tilewidth": 32, - "type": "map", - "version": 1.5, - "width": 2 - }) + data: [0, 0, 0, 0], + height: 2, + id: 1, + name: "Tile Layer 2", + opacity: 1, + type: "tilelayer", + visible: true, + width: 2, + x: 0, + y: 0, + }, + ], + nextlayerid: 2, + nextobjectid: 1, + orientation: "orthogonal", + renderorder: "right-down", + tiledversion: "2021.03.23", + tileheight: 32, + tilesets: [], + tilewidth: 32, + type: "map", + version: 1.5, + width: 2, + }); const layers = []; for (const layer of flatLayers) { layers.push(layer.name); } - expect(layers).toEqual(['Tile Layer 1', 'Tile Layer 2']); + expect(layers).toEqual(["Tile Layer 1", "Tile Layer 2"]); }); it("should iterate maps with recursive groups", () => { - let flatLayers:ITiledMapLayer[] = []; + let flatLayers: ITiledMapLayer[] = []; flatLayers = flattenGroupLayersMap({ - "compressionlevel": -1, - "height": 2, - "infinite": false, - "layers": [ + compressionlevel: -1, + height: 2, + infinite: false, + layers: [ { - "id": 6, - "layers": [ + id: 6, + layers: [ { - "id": 5, - "layers": [ + id: 5, + layers: [ { - "data": [0, 0, 0, 0], - "height": 2, - "id": 10, - "name": "Tile3", - "opacity": 1, - "type": "tilelayer", - "visible": true, - "width": 2, - "x": 0, - "y": 0 + data: [0, 0, 0, 0], + height: 2, + id: 10, + name: "Tile3", + opacity: 1, + type: "tilelayer", + visible: true, + width: 2, + x: 0, + y: 0, }, { - "data": [0, 0, 0, 0], - "height": 2, - "id": 9, - "name": "Tile2", - "opacity": 1, - "type": "tilelayer", - "visible": true, - "width": 2, - "x": 0, - "y": 0 - }], - "name": "Group 3", - "opacity": 1, - "type": "group", - "visible": true, - "x": 0, - "y": 0 + data: [0, 0, 0, 0], + height: 2, + id: 9, + name: "Tile2", + opacity: 1, + type: "tilelayer", + visible: true, + width: 2, + x: 0, + y: 0, + }, + ], + name: "Group 3", + opacity: 1, + type: "group", + visible: true, + x: 0, + y: 0, }, { - "id": 7, - "layers": [ + id: 7, + layers: [ { - "data": [0, 0, 0, 0], - "height": 2, - "id": 8, - "name": "Tile1", - "opacity": 1, - "type": "tilelayer", - "visible": true, - "width": 2, - "x": 0, - "y": 0 - }], - "name": "Group 2", - "opacity": 1, - "type": "group", - "visible": true, - "x": 0, - "y": 0 - }], - "name": "Group 1", - "opacity": 1, - "type": "group", - "visible": true, - "x": 0, - "y": 0 - }], - "nextlayerid": 11, - "nextobjectid": 1, - "orientation": "orthogonal", - "renderorder": "right-down", - "tiledversion": "2021.03.23", - "tileheight": 32, - "tilesets": [], - "tilewidth": 32, - "type": "map", - "version": 1.5, - "width": 2 - }) + data: [0, 0, 0, 0], + height: 2, + id: 8, + name: "Tile1", + opacity: 1, + type: "tilelayer", + visible: true, + width: 2, + x: 0, + y: 0, + }, + ], + name: "Group 2", + opacity: 1, + type: "group", + visible: true, + x: 0, + y: 0, + }, + ], + name: "Group 1", + opacity: 1, + type: "group", + visible: true, + x: 0, + y: 0, + }, + ], + nextlayerid: 11, + nextobjectid: 1, + orientation: "orthogonal", + renderorder: "right-down", + tiledversion: "2021.03.23", + tileheight: 32, + tilesets: [], + tilewidth: 32, + type: "map", + version: 1.5, + width: 2, + }); const layers = []; for (const layer of flatLayers) { layers.push(layer.name); } - expect(layers).toEqual(['Group 1/Group 3/Tile3', 'Group 1/Group 3/Tile2', 'Group 1/Group 2/Tile1']); + expect(layers).toEqual(["Group 1/Group 3/Tile3", "Group 1/Group 3/Tile2", "Group 1/Group 2/Tile1"]); }); }); diff --git a/front/webpack.config.ts b/front/webpack.config.ts index 5326705f..757e934b 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 index 7429b2a8..485f2ac8 100644 --- a/maps/tests/Metadata/getCurrentRoom.html +++ b/maps/tests/Metadata/getCurrentRoom.html @@ -1,16 +1,23 @@ - + - +

Log in the console the information of the current room

\ No newline at end of file diff --git a/maps/tests/Metadata/getCurrentUser.html b/maps/tests/Metadata/getCurrentUser.html index 02be24f7..da0c3191 100644 --- a/maps/tests/Metadata/getCurrentUser.html +++ b/maps/tests/Metadata/getCurrentUser.html @@ -1,15 +1,22 @@ - + - +

Log in the console the information of the current player

\ 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/setTiles.json b/maps/tests/Metadata/setTiles.json index 5b281a15..7eb9791a 100644 --- a/maps/tests/Metadata/setTiles.json +++ b/maps/tests/Metadata/setTiles.json @@ -286,11 +286,6 @@ "name":"jitsiTrigger", "type":"string", "value":"onaction" - }, - { - "name":"jitsiUrl", - "type":"string", - "value":"meet.jit.si" }] }, { 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/function_tiles.json b/maps/tests/function_tiles.json new file mode 100644 index 00000000..9bc374eb --- /dev/null +++ b/maps/tests/function_tiles.json @@ -0,0 +1,33 @@ +{ "columns":2, + "image":"function_tiles.png", + "imageheight":64, + "imagewidth":64, + "margin":0, + "name":"function_tiles", + "spacing":0, + "tilecount":4, + "tiledversion":"1.6.0", + "tileheight":32, + "tiles":[ + { + "id":0, + "properties":[ + { + "name":"start", + "type":"string", + "value":"S1" + }] + }, + { + "id":1, + "properties":[ + { + "name":"start", + "type":"string", + "value":"S2" + }] + }], + "tilewidth":32, + "type":"tileset", + "version":"1.6" +} \ No newline at end of file diff --git a/maps/tests/function_tiles.png b/maps/tests/function_tiles.png new file mode 100644 index 00000000..147eb619 Binary files /dev/null and b/maps/tests/function_tiles.png differ diff --git a/maps/tests/index.html b/maps/tests/index.html index 1a4e3590..df305cfa 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -122,14 +122,6 @@ 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 @@ -138,6 +130,14 @@ Testing return current user attributes by Scripting API + + + Success Failure Pending + + + Testing return current room attributes by Scripting API (Need to test from current user) + + Success Failure Pending @@ -172,10 +172,18 @@ - Success Failure Pending + Success Failure Pending - Test cowebsite opened by script is allowed to use IFrame API + Test start tile (S1) + + + + + Success Failure Pending + + + Test start tile (S2) diff --git a/maps/tests/start-tile.json b/maps/tests/start-tile.json new file mode 100644 index 00000000..4a65429a --- /dev/null +++ b/maps/tests/start-tile.json @@ -0,0 +1,101 @@ +{ "compressionlevel":-1, + "height":5, + "infinite":false, + "layers":[ + { + "data":[4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4], + "height":5, + "id":4, + "name":"background", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":5, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 3, 0, 0, 2, 3, 3, 0, 2, 2], + "height":5, + "id":1, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":5, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":3, + "name":"floorLayer", + "objects":[ + { + "height":66.6666666666667, + "id":1, + "name":"", + "rotation":0, + "text": + { + "fontfamily":"Sans Serif", + "pixelsize":11, + "text":"If URL contains hash #S1, player starts on S1.\nIf URL contains hash #S2, player starts on S2.", + "wrap":true + }, + "type":"", + "visible":true, + "width":155.104166666667, + "x":3.28125, + "y":2.5 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":5, + "nextobjectid":2, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"2021.03.23", + "tileheight":32, + "tilesets":[ + { + "columns":2, + "firstgid":1, + "image":"function_tiles.png", + "imageheight":64, + "imagewidth":64, + "margin":0, + "name":"function_tiles", + "spacing":0, + "tilecount":4, + "tileheight":32, + "tiles":[ + { + "id":0, + "properties":[ + { + "name":"start", + "type":"string", + "value":"S1" + }] + }, + { + "id":1, + "properties":[ + { + "name":"start", + "type":"string", + "value":"S2" + }] + }], + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":1.5, + "width":5 +} \ No newline at end of file diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index 52d58d6d..a2e55bd8 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -62,7 +62,7 @@ message WebRtcSignalToServerMessage { } message ReportPlayerMessage { - int32 reportedUserId = 1; + string reportedUserUuid = 1; string reportComment = 2; } @@ -158,6 +158,7 @@ message UserJoinedMessage { PositionMessage position = 4; CompanionMessage companion = 5; string visitCardUrl = 6; + string userUuid = 7; } message UserLeftMessage { @@ -183,7 +184,6 @@ message RoomJoinedMessage { message WebRtcStartMessage { int32 userId = 1; - string name = 2; bool initiator = 3; string webrtcUserName = 4; string webrtcPassword = 5; @@ -257,7 +257,7 @@ message ServerToClientMessage { AdminRoomMessage adminRoomMessage = 14; WorldFullWarningMessage worldFullWarningMessage = 15; WorldFullMessage worldFullMessage = 16; - RefreshRoomMessage refreshRoomMessage = 17; + RefreshRoomMessage refreshRoomMessage = 17; WorldConnexionMessage worldConnexionMessage = 18; EmoteEventMessage emoteEventMessage = 19; } @@ -286,6 +286,7 @@ message UserJoinedZoneMessage { Zone fromZone = 5; CompanionMessage companion = 6; string visitCardUrl = 7; + string userUuid = 8; } message UserLeftZoneMessage { diff --git a/pusher/src/Controller/AuthenticateController.ts b/pusher/src/Controller/AuthenticateController.ts index 3012e275..e937ece9 100644 --- a/pusher/src/Controller/AuthenticateController.ts +++ b/pusher/src/Controller/AuthenticateController.ts @@ -39,9 +39,7 @@ export class AuthenticateController extends BaseController { if (typeof organizationMemberToken != "string") throw new Error("No organization token"); const data = await adminApi.fetchMemberDataByToken(organizationMemberToken); const userUuid = data.userUuid; - const organizationSlug = data.organizationSlug; - const worldSlug = data.worldSlug; - const roomSlug = data.roomSlug; + const roomUrl = data.roomUrl; const mapUrlStart = data.mapUrlStart; const textures = data.textures; @@ -52,9 +50,7 @@ export class AuthenticateController extends BaseController { JSON.stringify({ authToken, userUuid, - organizationSlug, - worldSlug, - roomSlug, + roomUrl, mapUrlStart, organizationMemberToken, textures, diff --git a/pusher/src/Controller/DebugController.ts b/pusher/src/Controller/DebugController.ts index 0b0d188b..e9e3540d 100644 --- a/pusher/src/Controller/DebugController.ts +++ b/pusher/src/Controller/DebugController.ts @@ -16,7 +16,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/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index 1af9d917..bd9b5821 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -22,13 +22,14 @@ import { UserMovesMessage } from "../Messages/generated/messages_pb"; import { TemplatedApp } from "uWebSockets.js"; import { parse } from "query-string"; import { jwtTokenManager } from "../Services/JWTTokenManager"; -import { adminApi, CharacterTexture, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; +import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { SocketManager, socketManager } from "../Services/SocketManager"; import { emitInBatch } from "../Services/IoSocketHelpers"; import { ADMIN_API_TOKEN, ADMIN_API_URL, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable"; import { Zone } from "_Model/Zone"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { v4 } from "uuid"; +import { CharacterTexture } from "../Services/AdminApi/CharacterTexture"; export class IoSocketController { private nextUserId: number = 1; @@ -221,14 +222,12 @@ export class IoSocketController { memberVisitCardUrl = userData.visitCardUrl; memberTextures = userData.textures; if ( - !room.public && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && (userData.anonymous === true || !room.canAccess(memberTags)) ) { throw new Error("Insufficient privileges to access this room"); } if ( - !room.public && room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY && userData.anonymous === true ) { diff --git a/pusher/src/Controller/MapController.ts b/pusher/src/Controller/MapController.ts index 1df828d4..6ea2f19d 100644 --- a/pusher/src/Controller/MapController.ts +++ b/pusher/src/Controller/MapController.ts @@ -2,6 +2,9 @@ import { HttpRequest, HttpResponse, TemplatedApp } from "uWebSockets.js"; import { BaseController } from "./BaseController"; import { parse } from "query-string"; import { adminApi } from "../Services/AdminApi"; +import { ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import { GameRoomPolicyTypes } from "../Model/PusherRoom"; +import { MapDetailsData } from "../Services/AdminApi/MapDetailsData"; export class MapController extends BaseController { constructor(private App: TemplatedApp) { @@ -25,35 +28,46 @@ export class MapController extends BaseController { const query = parse(req.getQuery()); - if (typeof query.organizationSlug !== "string") { - console.error("Expected organizationSlug parameter"); + if (typeof query.playUri !== "string") { + console.error("Expected playUri parameter in /map endpoint"); res.writeStatus("400 Bad request"); this.addCorsHeaders(res); - res.end("Expected organizationSlug parameter"); + res.end("Expected playUri parameter"); return; } - if (typeof query.worldSlug !== "string") { - console.error("Expected worldSlug parameter"); - res.writeStatus("400 Bad request"); + + // If no admin URL is set, let's react on '/_/[instance]/[map url]' URLs + if (!ADMIN_API_URL) { + const roomUrl = new URL(query.playUri); + + const match = /\/_\/[^/]+\/(.+)/.exec(roomUrl.pathname); + if (!match) { + res.writeStatus("404 Not Found"); + this.addCorsHeaders(res); + res.end(JSON.stringify({})); + return; + } + + const mapUrl = roomUrl.protocol + "//" + match[1]; + + res.writeStatus("200 OK"); this.addCorsHeaders(res); - res.end("Expected worldSlug parameter"); - return; - } - if (typeof query.roomSlug !== "string" && query.roomSlug !== undefined) { - console.error("Expected only one roomSlug parameter"); - res.writeStatus("400 Bad request"); - this.addCorsHeaders(res); - res.end("Expected only one roomSlug parameter"); + res.end( + JSON.stringify({ + mapUrl, + policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY, + roomSlug: "", // Deprecated + tags: [], + textures: [], + } as MapDetailsData) + ); + return; } (async () => { try { - const mapDetails = await adminApi.fetchMapDetails( - query.organizationSlug as string, - query.worldSlug as string, - query.roomSlug as string | undefined - ); + const mapDetails = await adminApi.fetchMapDetails(query.playUri as string); res.writeStatus("200 OK"); this.addCorsHeaders(res); diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index a49fce3e..72625108 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -1,42 +1,27 @@ import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface"; import { PositionDispatcher } from "./PositionDispatcher"; import { ViewportInterface } from "_Model/Websocket/ViewportMessage"; -import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; import { arrayIntersect } from "../Services/ArrayHelper"; import { ZoneEventListener } from "_Model/Zone"; export enum GameRoomPolicyTypes { - ANONYMUS_POLICY = 1, + ANONYMOUS_POLICY = 1, MEMBERS_ONLY_POLICY, USE_TAGS_POLICY, } export class PusherRoom { private readonly positionNotifier: PositionDispatcher; - public readonly public: boolean; public tags: string[]; public policyType: GameRoomPolicyTypes; - public readonly roomSlug: string; - public readonly worldSlug: string = ""; - public readonly organizationSlug: string = ""; private versionNumber: number = 1; - constructor(public readonly roomId: string, private socketListener: ZoneEventListener) { - this.public = isRoomAnonymous(roomId); + constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) { this.tags = []; - this.policyType = GameRoomPolicyTypes.ANONYMUS_POLICY; - - if (this.public) { - this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); - } else { - const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId); - this.roomSlug = roomSlug; - this.organizationSlug = organizationSlug; - this.worldSlug = worldSlug; - } + this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY; // A zone is 10 sprites wide. - this.positionNotifier = new PositionDispatcher(this.roomId, 320, 320, this.socketListener); + this.positionNotifier = new PositionDispatcher(this.roomUrl, 320, 320, this.socketListener); } public setViewport(socket: ExSocketInterface, viewport: ViewportInterface): void { diff --git a/pusher/src/Model/RoomIdentifier.ts b/pusher/src/Model/RoomIdentifier.ts deleted file mode 100644 index d1de8800..00000000 --- a/pusher/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/pusher/src/Model/Websocket/ExAdminSocketInterface.ts b/pusher/src/Model/Websocket/ExAdminSocketInterface.ts index 7599c82c..572bd0fe 100644 --- a/pusher/src/Model/Websocket/ExAdminSocketInterface.ts +++ b/pusher/src/Model/Websocket/ExAdminSocketInterface.ts @@ -10,7 +10,6 @@ import { SubMessage, } from "../../Messages/generated/messages_pb"; import { WebSocket } from "uWebSockets.js"; -import { CharacterTexture } from "../../Services/AdminApi"; import { ClientDuplexStream } from "grpc"; import { Zone } from "_Model/Zone"; diff --git a/pusher/src/Model/Websocket/ExSocketInterface.ts b/pusher/src/Model/Websocket/ExSocketInterface.ts index 9a92a0e7..ff5ed211 100644 --- a/pusher/src/Model/Websocket/ExSocketInterface.ts +++ b/pusher/src/Model/Websocket/ExSocketInterface.ts @@ -9,9 +9,9 @@ import { SubMessage, } from "../../Messages/generated/messages_pb"; import { WebSocket } from "uWebSockets.js"; -import { CharacterTexture } from "../../Services/AdminApi"; import { ClientDuplexStream } from "grpc"; import { Zone } from "_Model/Zone"; +import { CharacterTexture } from "../../Services/AdminApi/CharacterTexture"; export type BackConnection = ClientDuplexStream; diff --git a/pusher/src/Model/Zone.ts b/pusher/src/Model/Zone.ts index 8eeeb3ef..501a2541 100644 --- a/pusher/src/Model/Zone.ts +++ b/pusher/src/Model/Zone.ts @@ -39,6 +39,7 @@ export type LeavesCallback = (thing: Movable, listener: User) => void;*/ export class UserDescriptor { private constructor( public readonly userId: number, + private userUuid: string, private name: string, private characterLayers: CharacterLayerMessage[], private position: PositionMessage, @@ -57,6 +58,7 @@ export class UserDescriptor { } return new UserDescriptor( message.getUserid(), + message.getUseruuid(), message.getName(), message.getCharacterlayersList(), position, @@ -84,6 +86,7 @@ export class UserDescriptor { userJoinedMessage.setVisitcardurl(this.visitCardUrl); } userJoinedMessage.setCompanion(this.companion); + userJoinedMessage.setUseruuid(this.userUuid); return userJoinedMessage; } diff --git a/pusher/src/Services/AdminApi.ts b/pusher/src/Services/AdminApi.ts index 2cbac52c..30fa8e5d 100644 --- a/pusher/src/Services/AdminApi.ts +++ b/pusher/src/Services/AdminApi.ts @@ -1,11 +1,12 @@ import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable"; import Axios from "axios"; import { GameRoomPolicyTypes } from "_Model/PusherRoom"; +import { CharacterTexture } from "./AdminApi/CharacterTexture"; +import { MapDetailsData } from "./AdminApi/MapDetailsData"; +import { RoomRedirect } from "./AdminApi/RoomRedirect"; export interface AdminApiData { - organizationSlug: string; - worldSlug: string; - roomSlug: string; + roomUrl: string; mapUrlStart: string; tags: string[]; policy_type: number; @@ -14,25 +15,11 @@ export interface AdminApiData { textures: CharacterTexture[]; } -export interface MapDetailsData { - roomSlug: string; - mapUrl: string; - policy_type: GameRoomPolicyTypes; - tags: string[]; -} - export interface AdminBannedData { is_banned: boolean; message: string; } -export interface CharacterTexture { - id: number; - level: number; - url: string; - rights: string; -} - export interface FetchMemberDataByUuidResponse { uuid: string; tags: string[]; @@ -43,24 +30,15 @@ export interface FetchMemberDataByUuidResponse { } class AdminApi { - async fetchMapDetails( - organizationSlug: string, - worldSlug: string, - roomSlug: string | undefined - ): Promise { + async fetchMapDetails(playUri: string): Promise { if (!ADMIN_API_URL) { return Promise.reject(new Error("No admin backoffice set!")); } - const params: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { - organizationSlug, - worldSlug, + const params: { playUri: string } = { + playUri, }; - if (roomSlug) { - params.roomSlug = roomSlug; - } - const res = await Axios.get(ADMIN_API_URL + "/api/map", { headers: { Authorization: `${ADMIN_API_TOKEN}` }, params, @@ -121,26 +99,20 @@ class AdminApi { ); } - async verifyBanUser( - organizationMemberToken: string, - ipAddress: string, - organization: string, - world: string - ): Promise { + async verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise { if (!ADMIN_API_URL) { return Promise.reject(new Error("No admin backoffice set!")); } //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. return Axios.get( ADMIN_API_URL + - "/api/check-moderate-user/" + - organization + - "/" + - world + + "/api/ban" + "?ipAddress=" + - ipAddress + + encodeURIComponent(ipAddress) + "&token=" + - organizationMemberToken, + encodeURIComponent(userUuid) + + "&roomUrl=" + + encodeURIComponent(roomUrl), { headers: { Authorization: `${ADMIN_API_TOKEN}` } } ).then((data) => { return data.data; diff --git a/pusher/src/Services/AdminApi/CharacterTexture.ts b/pusher/src/Services/AdminApi/CharacterTexture.ts new file mode 100644 index 00000000..055b3033 --- /dev/null +++ b/pusher/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/pusher/src/Services/AdminApi/MapDetailsData.ts b/pusher/src/Services/AdminApi/MapDetailsData.ts new file mode 100644 index 00000000..bf711799 --- /dev/null +++ b/pusher/src/Services/AdminApi/MapDetailsData.ts @@ -0,0 +1,20 @@ +import * as tg from "generic-type-guard"; +import { GameRoomPolicyTypes } from "_Model/PusherRoom"; +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({ + roomSlug: tg.isOptional(tg.isString), // deprecated + mapUrl: tg.isString, + policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes), + tags: tg.isArray(tg.isString), + textures: tg.isArray(isCharacterTexture), + }) + .get(); +export type MapDetailsData = tg.GuardedType; diff --git a/pusher/src/Services/AdminApi/RoomRedirect.ts b/pusher/src/Services/AdminApi/RoomRedirect.ts new file mode 100644 index 00000000..7257ebd3 --- /dev/null +++ b/pusher/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/pusher/src/Services/JWTTokenManager.ts b/pusher/src/Services/JWTTokenManager.ts index 2e82929e..cc44fc08 100644 --- a/pusher/src/Services/JWTTokenManager.ts +++ b/pusher/src/Services/JWTTokenManager.ts @@ -9,7 +9,7 @@ class JWTTokenManager { return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token } - public async getUserUuidFromToken(token: unknown, ipAddress?: string, room?: string): Promise { + public async getUserUuidFromToken(token: unknown, ipAddress?: string, roomUrl?: string): Promise { if (!token) { throw new Error("An authentication error happened, a user tried to connect without a token."); } @@ -50,8 +50,8 @@ class JWTTokenManager { if (ADMIN_API_URL) { //verify user in admin let promise = new Promise((resolve) => resolve()); - if (ipAddress && room) { - promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, room); + if (ipAddress && roomUrl) { + promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, roomUrl); } promise .then(() => { @@ -79,19 +79,9 @@ class JWTTokenManager { }); } - private verifyBanUser(userUuid: string, ipAddress: string, room: string): Promise { - const parts = room.split("/"); - if (parts.length < 3 || parts[0] !== "@") { - return Promise.resolve({ - is_banned: false, - message: "", - }); - } - - const organization = parts[1]; - const world = parts[2]; + private verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise { return adminApi - .verifyBanUser(userUuid, ipAddress, organization, world) + .verifyBanUser(userUuid, ipAddress, roomUrl) .then((data: AdminBannedData) => { if (data && data.is_banned) { throw new Error("User was banned"); diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 8a0d3673..b8221b8e 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -32,8 +32,8 @@ import { EmotePromptMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; -import { JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; -import { adminApi, CharacterTexture } from "./AdminApi"; +import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; +import { adminApi } from "./AdminApi"; import { emitInBatch } from "./IoSocketHelpers"; import Jwt from "jsonwebtoken"; import { JITSI_URL } from "../Enum/EnvironmentVariable"; @@ -44,6 +44,8 @@ import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone" import Debug from "debug"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { WebSocket } from "uWebSockets.js"; +import { isRoomRedirect } from "./AdminApi/RoomRedirect"; +import { CharacterTexture } from "./AdminApi/CharacterTexture"; const debug = Debug("socket"); @@ -61,7 +63,6 @@ export interface AdminSocketData { export class SocketManager implements ZoneEventListener { private rooms: Map = new Map(); - private sockets: Map = new Map(); constructor() { clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => { @@ -191,8 +192,6 @@ export class SocketManager implements ZoneEventListener { .on("data", (message: ServerToClientMessage) => { if (message.hasRoomjoinedmessage()) { client.userId = (message.getRoomjoinedmessage() as RoomJoinedMessage).getCurrentuserid(); - // TODO: do we need this.sockets anymore? - this.sockets.set(client.userId, client); // If this is the first message sent, send back the viewport. this.handleViewport(client, viewport); @@ -302,14 +301,8 @@ export class SocketManager implements ZoneEventListener { async handleReportMessage(client: ExSocketInterface, reportPlayerMessage: ReportPlayerMessage) { try { - const reportedSocket = this.sockets.get(reportPlayerMessage.getReporteduserid()); - if (!reportedSocket) { - throw "reported socket user not found"; - } - //TODO report user on admin application - //todo: move to back because this fail if the reported player is in another pusher. await adminApi.reportPlayer( - reportedSocket.userUuid, + reportPlayerMessage.getReporteduseruuid(), reportPlayerMessage.getReportcomment(), client.userUuid, client.roomId.split("/")[2] @@ -334,14 +327,6 @@ export class SocketManager implements ZoneEventListener { socket.backConnection.write(pusherToBackMessage); } - private searchClientByIdOrFail(userId: number): ExSocketInterface { - const client: ExSocketInterface | undefined = this.sockets.get(userId); - if (client === undefined) { - throw new Error("Could not find user with id " + userId); - } - return client; - } - leaveRoom(socket: ExSocketInterface) { // leave previous room and world try { @@ -364,9 +349,8 @@ export class SocketManager implements ZoneEventListener { //Client.leave(Client.roomId); } finally { //delete Client.roomId; - this.sockets.delete(socket.userId); clientEventsEmitter.emitClientLeave(socket.userUuid, socket.roomId); - console.log("A user left (", this.sockets.size, " connected users)"); + console.log("A user left"); } } } finally { @@ -376,23 +360,30 @@ export class SocketManager implements ZoneEventListener { } } - async getOrCreateRoom(roomId: string): Promise { + async getOrCreateRoom(roomUrl: string): Promise { //check and create new world for a room - let world = this.rooms.get(roomId); - if (world === undefined) { - world = new PusherRoom(roomId, this); - if (!world.public) { - await this.updateRoomWithAdminData(world); + let room = this.rooms.get(roomUrl); + if (room === undefined) { + room = new PusherRoom(roomUrl, this); + if (ADMIN_API_URL) { + await this.updateRoomWithAdminData(room); } - this.rooms.set(roomId, world); + + this.rooms.set(roomUrl, room); } - return Promise.resolve(world); + return room; } - public async updateRoomWithAdminData(world: PusherRoom): Promise { - const data = await adminApi.fetchMapDetails(world.organizationSlug, world.worldSlug, world.roomSlug); - world.tags = data.tags; - world.policyType = Number(data.policy_type); + public async updateRoomWithAdminData(room: PusherRoom): Promise { + const data = await adminApi.fetchMapDetails(room.roomUrl); + + if (isRoomRedirect(data)) { + // TODO: if the updated room data is actually a redirect, we need to take everybody on the map + // and redirect everybody to the new location (so we need to close the connection for everybody) + } else { + room.tags = data.tags; + room.policyType = Number(data.policy_type); + } } emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) { @@ -410,15 +401,6 @@ export class SocketManager implements ZoneEventListener { return this.rooms; } - searchClientByUuid(uuid: string): ExSocketInterface | null { - for (const socket of this.sockets.values()) { - if (socket.userUuid === uuid) { - return socket; - } - } - return null; - } - public handleQueryJitsiJwtMessage(client: ExSocketInterface, queryJitsiJwtMessage: QueryJitsiJwtMessage) { try { const room = queryJitsiJwtMessage.getJitsiroom(); diff --git a/pusher/tests/RoomIdentifierTest.ts b/pusher/tests/RoomIdentifierTest.ts deleted file mode 100644 index c3817ff7..00000000 --- a/pusher/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