From f5aa70ddc284d371111790075c12f025b2460e49 Mon Sep 17 00:00:00 2001 From: arp Date: Tue, 20 Oct 2020 13:44:57 +0200 Subject: [PATCH 1/6] improved the local storage of the the selectcharacterScene --- front/src/Connexion/LocalUserStore.ts | 17 +++- front/src/Phaser/Game/GameManager.ts | 14 +-- front/src/Phaser/Login/CustomizeScene.ts | 90 ++++++++++--------- .../src/Phaser/Login/SelectCharacterScene.ts | 37 +++----- 4 files changed, 75 insertions(+), 83 deletions(-) diff --git a/front/src/Connexion/LocalUserStore.ts b/front/src/Connexion/LocalUserStore.ts index cfecd3d4..a3c7d54d 100644 --- a/front/src/Connexion/LocalUserStore.ts +++ b/front/src/Connexion/LocalUserStore.ts @@ -6,7 +6,6 @@ class LocalUserStore { saveUser(localUser: LocalUser) { localStorage.setItem('localUser', JSON.stringify(localUser)); } - getLocalUser(): LocalUser|null { const data = localStorage.getItem('localUser'); return data ? JSON.parse(data) : null; @@ -15,11 +14,23 @@ class LocalUserStore { setName(name:string): void { window.localStorage.setItem('playerName', name); } - getName(): string { return window.localStorage.getItem('playerName') ?? ''; } - + + setPlayerCharacterIndex(playerCharacterIndex: number): void { + window.localStorage.setItem('selectedPlayer', ''+playerCharacterIndex); + } + getPlayerCharacterIndex(): number { + return parseInt(window.localStorage.getItem('selectedPlayer') || ''); + } + + setCustomCursorPosition(x:number, y:number, selectedLayers: number[]): void { + window.localStorage.setItem('customCursorPosition', JSON.stringify({x, y, selectedLayers})); + } + getCustomCursorPosition(): {x:number, y:number, selectedLayers:number[]}|null { + return JSON.parse(window.localStorage.getItem('customCursorPosition') || "null"); + } } export const localUserStore = new LocalUserStore(); \ No newline at end of file diff --git a/front/src/Phaser/Game/GameManager.ts b/front/src/Phaser/Game/GameManager.ts index 7622381a..b9862c49 100644 --- a/front/src/Phaser/Game/GameManager.ts +++ b/front/src/Phaser/Game/GameManager.ts @@ -1,7 +1,6 @@ import {GameScene} from "./GameScene"; import {connectionManager} from "../../Connexion/ConnectionManager"; import {Room} from "../../Connexion/Room"; -import {FourOFourSceneName} from "../Reconnecting/FourOFourScene"; export interface HasMovedEvent { direction: string; @@ -24,11 +23,7 @@ export class GameManager { this.playerName = name; } - public setCharacterUserSelected(characterUserSelected : string): void { - this.characterLayers = [characterUserSelected]; - } - - public setCharacterLayers(layers: string[]) { + public setCharacterLayers(layers: string[]): void { this.characterLayers = layers; } @@ -54,13 +49,6 @@ export class GameManager { } } - public getMapKeyByUrl(mapUrlStart: string) : string { - // FIXME: the key should be computed from the full URL of the map. - const startPos = mapUrlStart.indexOf('://')+3; - const endPos = mapUrlStart.indexOf(".json"); - return mapUrlStart.substring(startPos, endPos); - } - public async goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin) { const url = await this.startRoom.getMapUrl(); console.log('Starting scene '+url); diff --git a/front/src/Phaser/Login/CustomizeScene.ts b/front/src/Phaser/Login/CustomizeScene.ts index 0a014dae..0b0338f2 100644 --- a/front/src/Phaser/Login/CustomizeScene.ts +++ b/front/src/Phaser/Login/CustomizeScene.ts @@ -7,6 +7,7 @@ import Sprite = Phaser.GameObjects.Sprite; import Container = Phaser.GameObjects.Container; import {gameManager} from "../Game/GameManager"; import {ResizableScene} from "./ResizableScene"; +import {localUserStore} from "../../Connexion/LocalUserStore"; export const CustomizeSceneName = "CustomizeScene"; @@ -32,9 +33,10 @@ export class CustomizeScene extends ResizableScene { private logo!: Image; - private selectedLayers: Array = [0]; - private containersRow: Array> = new Array>(); - private activeRow = 0; + private selectedLayers: number[] = [0]; + private containersRow: Container[][] = []; + private activeRow:number = 0; + //private x:number = 0; constructor() { super({ @@ -103,43 +105,51 @@ export class CustomizeScene extends ResizableScene { return this.scene.start(EnableCameraSceneName); }); - this.input.keyboard.on('keydown-RIGHT', () => { - if (this.selectedLayers[this.activeRow] === undefined) { - this.selectedLayers[this.activeRow] = 0; - } - if (this.selectedLayers[this.activeRow] < LAYERS[this.activeRow].length - 1) { - this.selectedLayers[this.activeRow]++; - this.moveLayers(); - this.updateSelectedLayer(); - } - }); - - this.input.keyboard.on('keydown-LEFT', () => { - if (this.selectedLayers[this.activeRow] > 0) { - if (this.selectedLayers[this.activeRow] === 0) { - delete this.selectedLayers[this.activeRow]; - } else { - this.selectedLayers[this.activeRow]--; - } - this.moveLayers(); - this.updateSelectedLayer(); - } - }); - - this.input.keyboard.on('keydown-DOWN', () => { - if (this.activeRow < LAYERS.length - 1) { - this.activeRow++; - this.moveLayers(); - } - }); - - this.input.keyboard.on('keydown-UP', () => { - if (this.activeRow > 0) { - this.activeRow--; - this.moveLayers(); - } - }); + this.input.keyboard.on('keydown-RIGHT', () => this.moveCursorHorizontally(1)); + this.input.keyboard.on('keydown-LEFT', () => this.moveCursorHorizontally(-1)); + this.input.keyboard.on('keydown-DOWN', () => this.moveCursorVertically(1)); + this.input.keyboard.on('keydown-UP', () => this.moveCursorVertically(-1)); + + /*const customCursorPosition = localUserStore.getCustomCursorPosition(); + if (customCursorPosition) { + this.selectedLayers = customCursorPosition.selectedLayers; + for (let i = 0; i < customCursorPosition.x; i++) this.moveCursorVertically(1); + for (let i = 0; i < customCursorPosition.y; i++) this.moveCursorHorizontally(1); + }*/ } + + private moveCursorHorizontally(index: number): void { + if (this.selectedLayers[this.activeRow] === undefined) { + this.selectedLayers[this.activeRow] = index; + + } else { + this.selectedLayers[this.activeRow] += index; + } + if (this.selectedLayers[this.activeRow] < 0) { + this.selectedLayers[this.activeRow] = 0 + } else if(this.selectedLayers[this.activeRow] > LAYERS[this.activeRow].length - 1) { + this.selectedLayers[this.activeRow] = LAYERS[this.activeRow].length - 1 + } + this.moveLayers(); + this.updateSelectedLayer(); + //this.saveInLocalStorage(); + } + + private moveCursorVertically(index:number): void { + this.activeRow += index; + if (this.activeRow < 0) { + this.activeRow = 0 + } else if (this.activeRow > LAYERS.length - 1) { + this.activeRow = LAYERS.length - 1 + } + this.moveLayers(); + //this.saveInLocalStorage(); + } + + /*private saveInLocalStorage() { + localUserStore.setCustomCursorPosition(this.x, this.activeRow, this.selectedLayers); + }*/ + update(time: number, delta: number): void { super.update(time, delta); this.enterField.setVisible(!!(Math.floor(time / 500) % 2)); @@ -152,7 +162,7 @@ export class CustomizeScene extends ResizableScene { * create the layer and display it on the scene */ private createCustomizeLayer(x: number, y: number, layerNumber: number): void { - this.containersRow[layerNumber] = new Array(); + this.containersRow[layerNumber] = []; let alpha = 0; let layerPosX = 0; for (let i = 0; i < LAYERS[layerNumber].length; i++) { diff --git a/front/src/Phaser/Login/SelectCharacterScene.ts b/front/src/Phaser/Login/SelectCharacterScene.ts index d7e65eb1..25332b7d 100644 --- a/front/src/Phaser/Login/SelectCharacterScene.ts +++ b/front/src/Phaser/Login/SelectCharacterScene.ts @@ -6,6 +6,7 @@ import {PLAYER_RESOURCES, PlayerResourceDescriptionInterface} from "../Entity/Ch import {EnableCameraSceneName} from "./EnableCameraScene"; import {CustomizeSceneName} from "./CustomizeScene"; import {ResizableScene} from "./ResizableScene"; +import {localUserStore} from "../../Connexion/LocalUserStore"; //todo: put this constants in a dedicated file @@ -98,13 +99,15 @@ export class SelectCharacterScene extends ResizableScene { /*create user*/ this.createCurrentPlayer(); - - if (window.localStorage) { - const playerNumberStr: string = window.localStorage.getItem('selectedPlayer') ?? '0'; - const playerNumber: number = Number(playerNumberStr); + + const playerNumber = localUserStore.getPlayerCharacterIndex(); + if (playerNumber && playerNumber !== -1) { this.selectedRectangleXPos = playerNumber % this.nbCharactersPerRow; this.selectedRectangleYPos = Math.floor(playerNumber / this.nbCharactersPerRow); this.updateSelectedPlayer(); + } else if (playerNumber === -1) { + this.selectedRectangleYPos = Math.ceil(PLAYER_RESOURCES.length / this.nbCharactersPerRow); + this.updateSelectedPlayer(); } } @@ -113,33 +116,14 @@ export class SelectCharacterScene extends ResizableScene { } private nextScene(): void { - if (this.selectedPlayer !== null) { - gameManager.setCharacterUserSelected(this.selectedPlayer.texture.key); - + gameManager.setCharacterLayers([this.selectedPlayer.texture.key]); this.scene.start(EnableCameraSceneName); } else { this.scene.start(CustomizeSceneName); } } - /** - * Returns the map URL and the instance from the current URL - */ - private findMapUrl(): [string, string]|null { - const path = window.location.pathname; - if (!path.startsWith('/_/')) { - return null; - } - const instanceAndMap = path.substr(3); - const firstSlash = instanceAndMap.indexOf('/'); - if (firstSlash === -1) { - return null; - } - const instance = instanceAndMap.substr(0, firstSlash); - return [window.location.protocol+'//'+instanceAndMap.substr(firstSlash+1), instance]; - } - createCurrentPlayer(): void { for (let i = 0; i Date: Tue, 20 Oct 2020 14:48:59 +0200 Subject: [PATCH 2/6] Fix screen sharing Foce screan sharing to create new peer connexion and share it --- front/src/WebRtc/SimplePeer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index 718837b7..6f38bc27 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -235,7 +235,9 @@ export class SimplePeer { // I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel. //console.log('Closing connection with '+userId); peer.destroy(); - this.PeerScreenSharingConnectionArray.delete(userId) + if(!this.PeerScreenSharingConnectionArray.delete(userId)){ + throw 'Couln\'t delete peer screen sharing connexion'; + } //console.log('Nb users in peerConnectionArray '+this.PeerConnectionArray.size); } catch (err) { console.error("closeConnection", err) @@ -292,6 +294,9 @@ export class SimplePeer { } } catch (e) { console.error(`receiveWebrtcSignal => ${data.userId}`, e); + //force delete and reconnect peer connexion + this.PeerScreenSharingConnectionArray.delete(data.userId); + this.receiveWebrtcScreenSharingSignal(data); } } From dff189b223d7b72e6e110dafd94f4f53d9304b29 Mon Sep 17 00:00:00 2001 From: arp Date: Tue, 20 Oct 2020 17:22:32 +0200 Subject: [PATCH 3/6] local storage of the custom layers --- front/src/Connexion/LocalUserStore.ts | 6 ++--- front/src/Phaser/Login/CustomizeScene.ts | 28 ++++++++++-------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/front/src/Connexion/LocalUserStore.ts b/front/src/Connexion/LocalUserStore.ts index a3c7d54d..afe01bcd 100644 --- a/front/src/Connexion/LocalUserStore.ts +++ b/front/src/Connexion/LocalUserStore.ts @@ -25,10 +25,10 @@ class LocalUserStore { return parseInt(window.localStorage.getItem('selectedPlayer') || ''); } - setCustomCursorPosition(x:number, y:number, selectedLayers: number[]): void { - window.localStorage.setItem('customCursorPosition', JSON.stringify({x, y, selectedLayers})); + setCustomCursorPosition(activeRow:number, selectedLayers: number[]): void { + window.localStorage.setItem('customCursorPosition', JSON.stringify({activeRow, selectedLayers})); } - getCustomCursorPosition(): {x:number, y:number, selectedLayers:number[]}|null { + getCustomCursorPosition(): {activeRow:number, selectedLayers:number[]}|null { return JSON.parse(window.localStorage.getItem('customCursorPosition') || "null"); } } diff --git a/front/src/Phaser/Login/CustomizeScene.ts b/front/src/Phaser/Login/CustomizeScene.ts index 0b0338f2..7b4afa51 100644 --- a/front/src/Phaser/Login/CustomizeScene.ts +++ b/front/src/Phaser/Login/CustomizeScene.ts @@ -36,7 +36,6 @@ export class CustomizeScene extends ResizableScene { private selectedLayers: number[] = [0]; private containersRow: Container[][] = []; private activeRow:number = 0; - //private x:number = 0; constructor() { super({ @@ -110,21 +109,17 @@ export class CustomizeScene extends ResizableScene { this.input.keyboard.on('keydown-DOWN', () => this.moveCursorVertically(1)); this.input.keyboard.on('keydown-UP', () => this.moveCursorVertically(-1)); - /*const customCursorPosition = localUserStore.getCustomCursorPosition(); + const customCursorPosition = localUserStore.getCustomCursorPosition(); if (customCursorPosition) { + this.activeRow = customCursorPosition.activeRow; this.selectedLayers = customCursorPosition.selectedLayers; - for (let i = 0; i < customCursorPosition.x; i++) this.moveCursorVertically(1); - for (let i = 0; i < customCursorPosition.y; i++) this.moveCursorHorizontally(1); - }*/ + this.moveLayers(); + this.updateSelectedLayer(); + } } private moveCursorHorizontally(index: number): void { - if (this.selectedLayers[this.activeRow] === undefined) { - this.selectedLayers[this.activeRow] = index; - - } else { - this.selectedLayers[this.activeRow] += index; - } + this.selectedLayers[this.activeRow] += index; if (this.selectedLayers[this.activeRow] < 0) { this.selectedLayers[this.activeRow] = 0 } else if(this.selectedLayers[this.activeRow] > LAYERS[this.activeRow].length - 1) { @@ -132,7 +127,7 @@ export class CustomizeScene extends ResizableScene { } this.moveLayers(); this.updateSelectedLayer(); - //this.saveInLocalStorage(); + this.saveInLocalStorage(); } private moveCursorVertically(index:number): void { @@ -143,12 +138,12 @@ export class CustomizeScene extends ResizableScene { this.activeRow = LAYERS.length - 1 } this.moveLayers(); - //this.saveInLocalStorage(); + this.saveInLocalStorage(); } - /*private saveInLocalStorage() { - localUserStore.setCustomCursorPosition(this.x, this.activeRow, this.selectedLayers); - }*/ + private saveInLocalStorage() { + localUserStore.setCustomCursorPosition(this.activeRow, this.selectedLayers); + } update(time: number, delta: number): void { super.update(time, delta); @@ -163,6 +158,7 @@ export class CustomizeScene extends ResizableScene { */ private createCustomizeLayer(x: number, y: number, layerNumber: number): void { this.containersRow[layerNumber] = []; + this.selectedLayers[layerNumber] = 0; let alpha = 0; let layerPosX = 0; for (let i = 0; i < LAYERS[layerNumber].length; i++) { From 78a4bf318998d76451384a18e25abb7f6875dfbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 20 Oct 2020 16:39:23 +0200 Subject: [PATCH 4/6] Adding custom character textures --- back/src/Controller/AuthenticateController.ts | 3 +- back/src/Controller/IoSocketController.ts | 40 +++++++------- back/src/Model/Websocket/ExSocketInterface.ts | 11 +++- back/src/Model/Websocket/ProtobufUtils.ts | 20 ++++++- back/src/Services/AdminApi.ts | 43 +++++++++++---- back/src/Services/SocketManager.ts | 45 ++++++++++++---- front/src/Connexion/ConnectionManager.ts | 8 +-- front/src/Connexion/ConnexionModels.ts | 5 +- front/src/Connexion/LocalUser.ts | 16 +++--- front/src/Connexion/RoomConnection.ts | 21 ++++++-- front/src/Phaser/Entity/Character.ts | 36 +++++++------ front/src/Phaser/Entity/body_character.ts | 10 ++++ front/src/Phaser/Game/AddPlayerInterface.ts | 3 +- front/src/Phaser/Game/GameScene.ts | 40 ++++++++++++-- front/src/Phaser/Login/CustomizeScene.ts | 49 ++++++++++++------ maps/characters/tenue-f-jolicode.png | Bin 0 -> 14003 bytes maps/characters/tenue-f-vanoix.png | Bin 0 -> 13572 bytes maps/characters/tenue-f.png | Bin 0 -> 12904 bytes maps/characters/tenue-m-jolicode.png | Bin 0 -> 13868 bytes maps/characters/tenue-m-vanoix.png | Bin 0 -> 13337 bytes maps/characters/tenue-m.png | Bin 0 -> 12676 bytes messages/messages.proto | 7 ++- 22 files changed, 262 insertions(+), 95 deletions(-) create mode 100644 maps/characters/tenue-f-jolicode.png create mode 100644 maps/characters/tenue-f-vanoix.png create mode 100644 maps/characters/tenue-f.png create mode 100644 maps/characters/tenue-m-jolicode.png create mode 100644 maps/characters/tenue-m-vanoix.png create mode 100644 maps/characters/tenue-m.png diff --git a/back/src/Controller/AuthenticateController.ts b/back/src/Controller/AuthenticateController.ts index db7cfb9f..bf68768d 100644 --- a/back/src/Controller/AuthenticateController.ts +++ b/back/src/Controller/AuthenticateController.ts @@ -31,7 +31,6 @@ export class AuthenticateController extends BaseController { res.onAborted(() => { console.warn('Login request was aborted'); }) - const host = req.getHeader('host'); const param = await res.json(); //todo: what to do if the organizationMemberToken is already used? @@ -45,6 +44,7 @@ export class AuthenticateController extends BaseController { const worldSlug = data.worldSlug; const roomSlug = data.roomSlug; const mapUrlStart = data.mapUrlStart; + const textures = data.textures; const authToken = jwtTokenManager.createJWTToken(userUuid); res.writeStatus("200 OK"); @@ -56,6 +56,7 @@ export class AuthenticateController extends BaseController { worldSlug, roomSlug, mapUrlStart, + textures })); } catch (e) { diff --git a/back/src/Controller/IoSocketController.ts b/back/src/Controller/IoSocketController.ts index 49b08bb7..f6253be5 100644 --- a/back/src/Controller/IoSocketController.ts +++ b/back/src/Controller/IoSocketController.ts @@ -1,4 +1,4 @@ -import {ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; //TODO fix import by "_Model/.." +import {CharacterLayer, ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; //TODO fix import by "_Model/.." import {GameRoomPolicyTypes} from "../Model/GameRoom"; import {PointInterface} from "../Model/Websocket/PointInterface"; import { @@ -18,10 +18,9 @@ import {UserMovesMessage} from "../Messages/generated/messages_pb"; import {TemplatedApp} from "uWebSockets.js" import {parse} from "query-string"; import {jwtTokenManager} from "../Services/JWTTokenManager"; -import {adminApi, fetchMemberDataByUuidResponse} from "../Services/AdminApi"; -import {socketManager} from "../Services/SocketManager"; +import {adminApi, CharacterTexture, FetchMemberDataByUuidResponse} from "../Services/AdminApi"; +import {SocketManager, socketManager} from "../Services/SocketManager"; import {emitInBatch, resetPing} from "../Services/IoSocketHelpers"; -import Jwt from "jsonwebtoken"; import {clientEventsEmitter} from "../Services/ClientEventsEmitter"; import {ADMIN_API_TOKEN} from "../Enum/EnvironmentVariable"; @@ -159,25 +158,28 @@ export class IoSocketController { characterLayers = [ characterLayers ]; } - const userUuid = await jwtTokenManager.getUserUuidFromToken(token); let memberTags: string[] = []; + let memberTextures: CharacterTexture[] = []; const room = await socketManager.getOrCreateRoom(roomId); - if (!room.anonymous && room.policyType !== GameRoomPolicyTypes.ANONYMUS_POLICY) { - try { - const userData = await adminApi.fetchMemberDataByUuid(userUuid); - memberTags = userData.tags; - if (room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && !room.canAccess(memberTags)) { - throw new Error('No correct tags') - } - console.log('access granted for user '+userUuid+' and room '+roomId); - } catch (e) { - console.log('access not granted for user '+userUuid+' and room '+roomId); - throw new Error('Client cannot acces this ressource.') + try { + const userData = await adminApi.fetchMemberDataByUuid(userUuid); + //console.log('USERDATA', userData) + memberTags = userData.tags; + memberTextures = userData.textures; + if (!room.anonymous && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && !room.canAccess(memberTags)) { + throw new Error('No correct tags') } + //console.log('access granted for user '+userUuid+' and room '+roomId); + } catch (e) { + console.log('access not granted for user '+userUuid+' and room '+roomId); + throw new Error('Client cannot acces this ressource.') } + // Generate characterLayers objects from characterLayers string[] + const characterLayerObjs: CharacterLayer[] = SocketManager.mergeCharacterLayersAndCustomTextures(characterLayers, memberTextures); + if (upgradeAborted.aborted) { console.log("Ouch! Client disconnected before we could upgrade it!"); /* You must not upgrade now */ @@ -192,8 +194,9 @@ export class IoSocketController { userUuid, roomId, name, - characterLayers, + characterLayers: characterLayerObjs, tags: memberTags, + textures: memberTextures, position: { x: x, y: y, @@ -233,7 +236,7 @@ export class IoSocketController { resetPing(client); //get data information and shwo messages - adminApi.fetchMemberDataByUuid(client.userUuid).then((res: fetchMemberDataByUuidResponse) => { + adminApi.fetchMemberDataByUuid(client.userUuid).then((res: FetchMemberDataByUuidResponse) => { if (!res.messages) { return; } @@ -311,6 +314,7 @@ export class IoSocketController { client.name = ws.name; client.tags = ws.tags; + client.textures = ws.textures; client.characterLayers = ws.characterLayers; client.roomId = ws.roomId; return client; diff --git a/back/src/Model/Websocket/ExSocketInterface.ts b/back/src/Model/Websocket/ExSocketInterface.ts index dd897e1c..205032bc 100644 --- a/back/src/Model/Websocket/ExSocketInterface.ts +++ b/back/src/Model/Websocket/ExSocketInterface.ts @@ -3,6 +3,12 @@ import {Identificable} from "./Identificable"; import {ViewportInterface} from "_Model/Websocket/ViewportMessage"; import {BatchMessage, SubMessage} from "../../Messages/generated/messages_pb"; import {WebSocket} from "uWebSockets.js" +import {CharacterTexture} from "../../Services/AdminApi"; + +export interface CharacterLayer { + name: string, + url: string|undefined +} export interface ExSocketInterface extends WebSocket, Identificable { token: string; @@ -10,7 +16,7 @@ export interface ExSocketInterface extends WebSocket, Identificable { //userId: number; // A temporary (autoincremented) identifier for this user userUuid: string; // A unique identifier for this user name: string; - characterLayers: string[]; + characterLayers: CharacterLayer[]; position: PointInterface; viewport: ViewportInterface; /** @@ -21,5 +27,6 @@ export interface ExSocketInterface extends WebSocket, Identificable { batchTimeout: NodeJS.Timeout|null; pingTimeout: NodeJS.Timeout|null; disconnecting: boolean, - tags: string[] + tags: string[], + textures: CharacterTexture[], } diff --git a/back/src/Model/Websocket/ProtobufUtils.ts b/back/src/Model/Websocket/ProtobufUtils.ts index 42adbd4c..c31eb9a8 100644 --- a/back/src/Model/Websocket/ProtobufUtils.ts +++ b/back/src/Model/Websocket/ProtobufUtils.ts @@ -1,6 +1,11 @@ import {PointInterface} from "./PointInterface"; -import {ItemEventMessage, PointMessage, PositionMessage} from "../../Messages/generated/messages_pb"; -import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface"; +import { + CharacterLayerMessage, + ItemEventMessage, + PointMessage, + PositionMessage +} from "../../Messages/generated/messages_pb"; +import {CharacterLayer, ExSocketInterface} from "_Model/Websocket/ExSocketInterface"; import Direction = PositionMessage.Direction; import {ItemEventMessageInterface} from "_Model/Websocket/ItemEventMessage"; import {PositionInterface} from "_Model/PositionInterface"; @@ -89,4 +94,15 @@ export class ProtobufUtils { return itemEventMessage; } + + public static toCharacterLayerMessages(characterLayers: CharacterLayer[]): CharacterLayerMessage[] { + return characterLayers.map(function(characterLayer): CharacterLayerMessage { + const message = new CharacterLayerMessage(); + message.setName(characterLayer.name); + if (characterLayer.url) { + message.setUrl(characterLayer.url); + } + return message; + }); + } } diff --git a/back/src/Services/AdminApi.ts b/back/src/Services/AdminApi.ts index 9f51fb2e..9c46a41b 100644 --- a/back/src/Services/AdminApi.ts +++ b/back/src/Services/AdminApi.ts @@ -1,5 +1,6 @@ import {ADMIN_API_TOKEN, ADMIN_API_URL} from "../Enum/EnvironmentVariable"; import Axios from "axios"; +import {v4} from "uuid"; export interface AdminApiData { organizationSlug: string @@ -9,12 +10,21 @@ export interface AdminApiData { tags: string[] policy_type: number userUuid: string - messages?: unknown[] + messages?: unknown[], + textures: CharacterTexture[] } -export interface fetchMemberDataByUuidResponse { +export interface CharacterTexture { + id: number, + level: number, + url: string, + rights: string +} + +export interface FetchMemberDataByUuidResponse { uuid: string; tags: string[]; + textures: CharacterTexture[]; messages: unknown[]; } @@ -43,16 +53,31 @@ class AdminApi { return res.data; } - async fetchMemberDataByUuid(uuid: string): Promise { + async fetchMemberDataByUuid(uuid: string): Promise { if (!ADMIN_API_URL) { return Promise.reject('No admin backoffice set!'); } - const res = await Axios.get(ADMIN_API_URL+'/api/membership/'+uuid, - { headers: {"Authorization" : `${ADMIN_API_TOKEN}`} } - ) - return res.data; + try { + const res = await Axios.get(ADMIN_API_URL+'/api/membership/'+uuid, + { headers: {"Authorization" : `${ADMIN_API_TOKEN}`} } + ) + return res.data; + } catch (e) { + if (e?.response?.status == 404) { + // If we get an HTTP 404, the token is invalid. Let's perform an anonymous login! + console.warn('Cannot find user with uuid "'+uuid+'". Performing an anonymous login instead.'); + return { + uuid: v4(), + tags: [], + textures: [], + messages: [], + } + } else { + throw e; + } + } } - + async fetchMemberDataByToken(organizationMemberToken: string): Promise { if (!ADMIN_API_URL) { return Promise.reject('No admin backoffice set!'); @@ -74,7 +99,7 @@ class AdminApi { ) return res.data; } - + reportPlayer(reportedUserUuid: string, reportedUserComment: string, reporterUserUuid: string) { return Axios.post(`${ADMIN_API_URL}/api/report`, { reportedUserUuid, diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 9fd343be..50bd149e 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -1,5 +1,5 @@ import {GameRoom} from "../Model/GameRoom"; -import {ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; +import {CharacterLayer, ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; import { GroupDeleteMessage, GroupUpdateMessage, @@ -23,6 +23,7 @@ import { WebRtcStartMessage, QueryJitsiJwtMessage, SendJitsiJwtMessage, + CharacterLayerMessage, SendUserMessage } from "../Messages/generated/messages_pb"; import {PointInterface} from "../Model/Websocket/PointInterface"; @@ -34,7 +35,7 @@ import {isSetPlayerDetailsMessage} from "../Model/Websocket/SetPlayerDetailsMess import {GROUP_RADIUS, JITSI_ISS, MINIMUM_DISTANCE, SECRET_JITSI_KEY} from "../Enum/EnvironmentVariable"; import {Movable} from "../Model/Movable"; import {PositionInterface} from "../Model/PositionInterface"; -import {adminApi} from "./AdminApi"; +import {adminApi, CharacterTexture} from "./AdminApi"; import Direction = PositionMessage.Direction; import {Gauge} from "prom-client"; import {emitError, emitInBatch} from "./IoSocketHelpers"; @@ -54,7 +55,7 @@ export interface AdminSocketData { users: AdminSocketUsersList, } -class SocketManager { +export class SocketManager { private Worlds: Map = new Map(); private sockets: Map = new Map(); private nbClientsGauge: Gauge; @@ -71,7 +72,7 @@ class SocketManager { help: 'Number of clients per room', labelNames: [ 'room' ] }); - + clientEventsEmitter.registerToClientJoin((clientUUid, roomId) => { this.nbClientsGauge.inc(); // Let's log server load when a user joins @@ -83,7 +84,7 @@ class SocketManager { console.log('A user left (', this.sockets.size, ' connected users)'); }); } - + getAdminSocketDataFor(roomId:string): AdminSocketData { const data:AdminSocketData = { rooms: {}, @@ -125,7 +126,7 @@ class SocketManager { const userJoinedMessage = new UserJoinedMessage(); userJoinedMessage.setUserid(thing.id); userJoinedMessage.setName(player.name); - userJoinedMessage.setCharacterlayersList(player.characterLayers); + userJoinedMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(player.characterLayers)); userJoinedMessage.setPosition(ProtobufUtils.toPositionMessage(player.position)); roomJoinedMessage.addUser(userJoinedMessage); @@ -251,8 +252,7 @@ class SocketManager { return; } client.name = playerDetails.name; - client.characterLayers = playerDetails.characterLayers; - + client.characterLayers = SocketManager.mergeCharacterLayersAndCustomTextures(playerDetails.characterLayers, client.textures); } handleSilentMessage(client: ExSocketInterface, silentMessage: SilentMessage) { @@ -438,7 +438,7 @@ class SocketManager { } userJoinedMessage.setUserid(clientUser.userId); userJoinedMessage.setName(clientUser.name); - userJoinedMessage.setCharacterlayersList(clientUser.characterLayers); + userJoinedMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(clientUser.characterLayers)); userJoinedMessage.setPosition(ProtobufUtils.toPositionMessage(clientUser.position)); const subMessage = new SubMessage(); @@ -691,6 +691,33 @@ class SocketManager { } return socket; } + + /** + * Merges the characterLayers received from the front (as an array of string) with the custom textures from the back. + */ + static mergeCharacterLayersAndCustomTextures(characterLayers: string[], memberTextures: CharacterTexture[]): CharacterLayer[] { + const characterLayerObjs: CharacterLayer[] = []; + for (const characterLayer of characterLayers) { + if (characterLayer.startsWith('customCharacterTexture')) { + const customCharacterLayerId: number = +characterLayer.substr(22); + for (const memberTexture of memberTextures) { + if (memberTexture.id == customCharacterLayerId) { + characterLayerObjs.push({ + name: characterLayer, + url: memberTexture.url + }) + break; + } + } + } else { + characterLayerObjs.push({ + name: characterLayer, + url: undefined + }) + } + } + return characterLayerObjs; + } } export const socketManager = new SocketManager(); diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index 26432df5..971903f1 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -21,7 +21,7 @@ class ConnectionManager { if(connexionType === GameConnexionTypes.register) { const organizationMemberToken = urlManager.getOrganizationToken(); const data = await Axios.post(`${API_URL}/register`, {organizationMemberToken}).then(res => res.data); - this.localUser = new LocalUser(data.userUuid, data.authToken); + this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); localUserStore.saveUser(this.localUser); const organizationSlug = data.organizationSlug; @@ -34,7 +34,7 @@ class ConnectionManager { } else if (connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { const localUser = localUserStore.getLocalUser(); - if (localUser && localUser.jwtToken && localUser.uuid) { + if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) { this.localUser = localUser; try { await this.verifyToken(localUser.jwtToken); @@ -78,12 +78,12 @@ class ConnectionManager { private async anonymousLogin(): Promise { const data = await Axios.post(`${API_URL}/anonymLogin`).then(res => res.data); - this.localUser = new LocalUser(data.userUuid, data.authToken); + this.localUser = new LocalUser(data.userUuid, data.authToken, []); localUserStore.saveUser(this.localUser); } public initBenchmark(): void { - this.localUser = new LocalUser('', 'test'); + this.localUser = new LocalUser('', 'test', []); } public connectToRoomSocket(roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface): Promise { diff --git a/front/src/Connexion/ConnexionModels.ts b/front/src/Connexion/ConnexionModels.ts index fd2149c5..d1d80aff 100644 --- a/front/src/Connexion/ConnexionModels.ts +++ b/front/src/Connexion/ConnexionModels.ts @@ -1,6 +1,7 @@ import {PlayerAnimationNames} from "../Phaser/Player/Animation"; import {UserSimplePeerInterface} from "../WebRtc/SimplePeer"; import {SignalData} from "simple-peer"; +import {BodyResourceDescriptionInterface} from "../Phaser/Entity/body_character"; export enum EventMessage{ WEBRTC_SIGNAL = "webrtc-signal", @@ -49,7 +50,7 @@ export class Point implements PointInterface{ export interface MessageUserPositionInterface { userId: number; name: string; - characterLayers: string[]; + characterLayers: BodyResourceDescriptionInterface[]; position: PointInterface; } @@ -61,7 +62,7 @@ export interface MessageUserMovedInterface { export interface MessageUserJoined { userId: number; name: string; - characterLayers: string[]; + characterLayers: BodyResourceDescriptionInterface[]; position: PointInterface } diff --git a/front/src/Connexion/LocalUser.ts b/front/src/Connexion/LocalUser.ts index 1411f66c..06d98b70 100644 --- a/front/src/Connexion/LocalUser.ts +++ b/front/src/Connexion/LocalUser.ts @@ -1,9 +1,11 @@ +export interface CharacterTexture { + id: number, + level: number, + url: string, + rights: string +} + export class LocalUser { - public uuid: string; - public jwtToken: string; - - constructor(uuid:string, jwtToken: string) { - this.uuid = uuid; - this.jwtToken = jwtToken; + constructor(public readonly uuid:string, public readonly jwtToken: string, public readonly textures: CharacterTexture[]) { } -} \ No newline at end of file +} diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 9d04cedd..7c57558a 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -22,7 +22,11 @@ import { WebRtcSignalToServerMessage, WebRtcStartMessage, ReportPlayerMessage, - TeleportMessageMessage, QueryJitsiJwtMessage, SendJitsiJwtMessage, SendUserMessage + TeleportMessageMessage, + QueryJitsiJwtMessage, + SendJitsiJwtMessage, + CharacterLayerMessage, + SendUserMessage } from "../Messages/generated/messages_pb" import {UserSimplePeerInterface} from "../WebRtc/SimplePeer"; @@ -36,6 +40,7 @@ import { ViewportInterface, WebRtcDisconnectMessageInterface, WebRtcSignalReceivedMessageInterface, } from "./ConnexionModels"; +import {BodyResourceDescriptionInterface} from "../Phaser/Entity/body_character"; export class RoomConnection implements RoomConnection { private readonly socket: WebSocket; @@ -169,10 +174,10 @@ export class RoomConnection implements RoomConnection { } } - public emitPlayerDetailsMessage(userName: string, characterLayersSelected: string[]) { + public emitPlayerDetailsMessage(userName: string, characterLayersSelected: BodyResourceDescriptionInterface[]) { const message = new SetPlayerDetailsMessage(); message.setName(userName); - message.setCharacterlayersList(characterLayersSelected); + message.setCharacterlayersList(characterLayersSelected.map((characterLayer) => characterLayer.name)); const clientToServerMessage = new ClientToServerMessage(); clientToServerMessage.setSetplayerdetailsmessage(message); @@ -277,10 +282,18 @@ export class RoomConnection implements RoomConnection { if (position === undefined) { throw new Error('Invalid JOIN_ROOM message'); } + + const characterLayers = message.getCharacterlayersList().map((characterLayer: CharacterLayerMessage): BodyResourceDescriptionInterface => { + return { + name: characterLayer.getName(), + img: characterLayer.getUrl() + } + }) + return { userId: message.getUserid(), name: message.getName(), - characterLayers: message.getCharacterlayersList(), + characterLayers, position: ProtobufClientUtils.toPointInterface(position) } } diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index 6f6f769e..a1ed30d5 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -61,22 +61,7 @@ export abstract class Character extends Container { this.sprites = new Map(); - for (const texture of textures) { - const sprite = new Sprite(scene, 0, 0, texture, frame); - sprite.setInteractive({useHandCursor: true}); - this.add(sprite); - this.getPlayerAnimations(texture).forEach(d => { - this.scene.anims.create({ - key: d.key, - frames: this.scene.anims.generateFrameNumbers(d.frameModel, {start: d.frameStart, end: d.frameEnd}), - frameRate: d.frameRate, - repeat: d.repeat - }); - }) - // Needed, otherwise, animations are not handled correctly. - this.scene.sys.updateList.add(sprite); - this.sprites.set(texture, sprite); - } + this.addTextures(textures, frame); /*this.teleportation = new Sprite(scene, -20, -10, 'teleportation', 3); this.teleportation.setInteractive(); @@ -107,6 +92,25 @@ export abstract class Character extends Container { this.playAnimation(direction, moving); } + public addTextures(textures: string[], frame?: string | number): void { + for (const texture of textures) { + const sprite = new Sprite(this.scene, 0, 0, texture, frame); + sprite.setInteractive({useHandCursor: true}); + this.add(sprite); + this.getPlayerAnimations(texture).forEach(d => { + this.scene.anims.create({ + key: d.key, + frames: this.scene.anims.generateFrameNumbers(d.frameModel, {start: d.frameStart, end: d.frameEnd}), + frameRate: d.frameRate, + repeat: d.repeat + }); + }) + // Needed, otherwise, animations are not handled correctly. + this.scene.sys.updateList.add(sprite); + this.sprites.set(texture, sprite); + } + } + private getPlayerAnimations(name: string): AnimationData[] { return [{ key: `${name}-${PlayerAnimationNames.WalkDown}`, diff --git a/front/src/Phaser/Entity/body_character.ts b/front/src/Phaser/Entity/body_character.ts index 6fbeaadb..fd5fa467 100644 --- a/front/src/Phaser/Entity/body_character.ts +++ b/front/src/Phaser/Entity/body_character.ts @@ -1,5 +1,6 @@ import LoaderPlugin = Phaser.Loader.LoaderPlugin; import {PLAYER_RESOURCES, PlayerResourceDescriptionInterface} from "./Character"; +import {CharacterTexture} from "../../Connexion/LocalUser"; export interface BodyResourceDescriptionInterface { name: string, @@ -312,6 +313,15 @@ export const loadAllLayers = (load: LoaderPlugin) => { } } +export const loadCustomTexture = (load: LoaderPlugin, texture: CharacterTexture) => { + const name = 'customCharacterTexture'+texture.id; + load.spritesheet( + name, + texture.url, + {frameWidth: 32, frameHeight: 32} + ); +} + export const OBJECTS: Array = [ {name:'layout_modes', img:'resources/objects/layout_modes.png'}, {name:'teleportation', img:'resources/objects/teleportation.png'}, diff --git a/front/src/Phaser/Game/AddPlayerInterface.ts b/front/src/Phaser/Game/AddPlayerInterface.ts index 91563dd0..e0c7df0f 100644 --- a/front/src/Phaser/Game/AddPlayerInterface.ts +++ b/front/src/Phaser/Game/AddPlayerInterface.ts @@ -1,8 +1,9 @@ import {PointInterface} from "../../Connexion/ConnexionModels"; +import {BodyResourceDescriptionInterface} from "../Entity/body_character"; export interface AddPlayerInterface { userId: number; name: string; - characterLayers: string[]; + characterLayers: BodyResourceDescriptionInterface[]; position: PointInterface; } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index f82a6ce2..b8632b06 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -32,7 +32,7 @@ import {RemotePlayer} from "../Entity/RemotePlayer"; import {Queue} from 'queue-typescript'; import {SimplePeer, UserSimplePeerInterface} from "../../WebRtc/SimplePeer"; import {ReconnectingSceneName} from "../Reconnecting/ReconnectingScene"; -import {loadAllLayers, loadObject, loadPlayerCharacters} from "../Entity/body_character"; +import {loadAllLayers, loadCustomTexture, loadObject, loadPlayerCharacters} from "../Entity/body_character"; import {CenterListener, layoutManager, LayoutMode} from "../../WebRtc/LayoutManager"; import Texture = Phaser.Textures.Texture; import Sprite = Phaser.GameObjects.Sprite; @@ -475,7 +475,6 @@ export class GameScene extends ResizableScene implements CenterListener { if (newValue === undefined) { this.stopJitsi(); } else { - console.log("JITSI_PRIVATE_MODE", JITSI_PRIVATE_MODE); if (JITSI_PRIVATE_MODE) { const adminTag = allProps.get("jitsiRoomAdminTag") as string|undefined; @@ -1022,7 +1021,7 @@ export class GameScene extends ResizableScene implements CenterListener { /** * Create new player */ - private doAddPlayer(addPlayerData : AddPlayerInterface) : void { + private async doAddPlayer(addPlayerData : AddPlayerInterface) : Promise { //check if exist player, if exist, move position if(this.MapPlayersByKey.has(addPlayerData.userId)){ this.updatePlayerPosition({ @@ -1031,6 +1030,20 @@ export class GameScene extends ResizableScene implements CenterListener { }); return; } + // Load textures (in case it is a custom texture) + const characterLayerList: string[] = []; + const loadPromises: Promise[] = []; + for (const characterLayer of addPlayerData.characterLayers) { + characterLayerList.push(characterLayer.name); + if (characterLayer.img) { + console.log('LOADING ', characterLayer.name, characterLayer.img) + loadPromises.push(this.loadSpritesheet(characterLayer.name, characterLayer.img)); + } + } + if (loadPromises.length > 0) { + this.load.start(); + } + //initialise player const player = new RemotePlayer( addPlayerData.userId, @@ -1038,7 +1051,7 @@ export class GameScene extends ResizableScene implements CenterListener { addPlayerData.position.x, addPlayerData.position.y, addPlayerData.name, - addPlayerData.characterLayers, + [], // Let's go with no textures and let's load textures when promises have returned. addPlayerData.position.direction, addPlayerData.position.moving ); @@ -1046,10 +1059,15 @@ export class GameScene extends ResizableScene implements CenterListener { this.MapPlayersByKey.set(player.userId, player); player.updatePosition(addPlayerData.position); + + await Promise.all(loadPromises); + + player.addTextures(characterLayerList, 1); //init collision /*this.physics.add.collider(this.CurrentPlayer, player, (CurrentPlayer: CurrentGamerInterface, MapPlayer: GamerInterface) => { CurrentPlayer.say("Hello, how are you ? "); });*/ + } /** @@ -1245,4 +1263,18 @@ export class GameScene extends ResizableScene implements CenterListener { CoWebsiteManager.closeCoWebsite(); mediaManager.showGameOverlay(); } + + private loadSpritesheet(name: string, url: string): Promise { + return new Promise(((resolve, reject) => { + this.load.spritesheet( + name, + url, + {frameWidth: 32, frameHeight: 32} + ); + this.load.on('filecomplete-spritesheet-'+name, () => { + console.log('RESOURCE LOADED!'); + resolve(); + }); + })) + } } diff --git a/front/src/Phaser/Login/CustomizeScene.ts b/front/src/Phaser/Login/CustomizeScene.ts index 7b4afa51..41ec95c9 100644 --- a/front/src/Phaser/Login/CustomizeScene.ts +++ b/front/src/Phaser/Login/CustomizeScene.ts @@ -2,12 +2,13 @@ import {EnableCameraSceneName} from "./EnableCameraScene"; import {TextField} from "../Components/TextField"; import Image = Phaser.GameObjects.Image; import Rectangle = Phaser.GameObjects.Rectangle; -import {LAYERS, loadAllLayers} from "../Entity/body_character"; +import {BodyResourceDescriptionInterface, LAYERS, loadAllLayers, loadCustomTexture} from "../Entity/body_character"; import Sprite = Phaser.GameObjects.Sprite; import Container = Phaser.GameObjects.Container; import {gameManager} from "../Game/GameManager"; import {ResizableScene} from "./ResizableScene"; import {localUserStore} from "../../Connexion/LocalUserStore"; +import {PlayerResourceDescriptionInterface} from "../Entity/Character"; export const CustomizeSceneName = "CustomizeScene"; @@ -36,6 +37,7 @@ export class CustomizeScene extends ResizableScene { private selectedLayers: number[] = [0]; private containersRow: Container[][] = []; private activeRow:number = 0; + private layers: BodyResourceDescriptionInterface[][] = []; constructor() { super({ @@ -51,6 +53,23 @@ export class CustomizeScene extends ResizableScene { //load all the png files loadAllLayers(this.load); + + // load custom layers + this.layers = LAYERS; + + const localUser = localUserStore.getLocalUser(); + + const textures = localUser?.textures; + if (textures) { + for (const texture of textures) { + loadCustomTexture(this.load, texture); + const name = 'customCharacterTexture'+texture.id; + this.layers[texture.level].unshift({ + name, + img: texture.url + }); + } + } } create() { @@ -94,7 +113,7 @@ export class CustomizeScene extends ResizableScene { let i = 0; for (const layerItem of this.selectedLayers) { if (layerItem !== undefined) { - layers.push(LAYERS[i][layerItem].name); + layers.push(this.layers[i][layerItem].name); } i++; } @@ -108,7 +127,7 @@ export class CustomizeScene extends ResizableScene { this.input.keyboard.on('keydown-LEFT', () => this.moveCursorHorizontally(-1)); this.input.keyboard.on('keydown-DOWN', () => this.moveCursorVertically(1)); this.input.keyboard.on('keydown-UP', () => this.moveCursorVertically(-1)); - + const customCursorPosition = localUserStore.getCustomCursorPosition(); if (customCursorPosition) { this.activeRow = customCursorPosition.activeRow; @@ -117,34 +136,34 @@ export class CustomizeScene extends ResizableScene { this.updateSelectedLayer(); } } - + private moveCursorHorizontally(index: number): void { this.selectedLayers[this.activeRow] += index; if (this.selectedLayers[this.activeRow] < 0) { this.selectedLayers[this.activeRow] = 0 - } else if(this.selectedLayers[this.activeRow] > LAYERS[this.activeRow].length - 1) { - this.selectedLayers[this.activeRow] = LAYERS[this.activeRow].length - 1 + } else if(this.selectedLayers[this.activeRow] > this.layers[this.activeRow].length - 1) { + this.selectedLayers[this.activeRow] = this.layers[this.activeRow].length - 1 } this.moveLayers(); this.updateSelectedLayer(); this.saveInLocalStorage(); } - + private moveCursorVertically(index:number): void { this.activeRow += index; if (this.activeRow < 0) { this.activeRow = 0 - } else if (this.activeRow > LAYERS.length - 1) { - this.activeRow = LAYERS.length - 1 + } else if (this.activeRow > this.layers.length - 1) { + this.activeRow = this.layers.length - 1 } this.moveLayers(); this.saveInLocalStorage(); } - + private saveInLocalStorage() { localUserStore.setCustomCursorPosition(this.activeRow, this.selectedLayers); } - + update(time: number, delta: number): void { super.update(time, delta); this.enterField.setVisible(!!(Math.floor(time / 500) % 2)); @@ -153,7 +172,7 @@ export class CustomizeScene extends ResizableScene { /** * @param x, the layer's vertical position * @param y, the layer's horizontal position - * @param layerNumber, index of the LAYERS array + * @param layerNumber, index of the this.layers array * create the layer and display it on the scene */ private createCustomizeLayer(x: number, y: number, layerNumber: number): void { @@ -161,7 +180,7 @@ export class CustomizeScene extends ResizableScene { this.selectedLayers[layerNumber] = 0; let alpha = 0; let layerPosX = 0; - for (let i = 0; i < LAYERS[layerNumber].length; i++) { + for (let i = 0; i < this.layers[layerNumber].length; i++) { const container = this.generateCharacter(300 + x + layerPosX, y, layerNumber, i); this.containersRow[layerNumber][i] = container; @@ -190,13 +209,13 @@ export class CustomizeScene extends ResizableScene { const children: Array = new Array(); for (let j = 0; j <= layerNumber; j++) { if (j === layerNumber) { - children.push(this.generateLayers(0, 0, LAYERS[j][selectedItem].name)); + children.push(this.generateLayers(0, 0, this.layers[j][selectedItem].name)); } else { const layer = this.selectedLayers[j]; if (layer === undefined) { continue; } - children.push(this.generateLayers(0, 0, LAYERS[j][layer].name)); + children.push(this.generateLayers(0, 0, this.layers[j][layer].name)); } } return children; diff --git a/maps/characters/tenue-f-jolicode.png b/maps/characters/tenue-f-jolicode.png new file mode 100644 index 0000000000000000000000000000000000000000..f3214968441f8b5ba6c9b75329bac2d01db2416d GIT binary patch literal 14003 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ÿUC&zTvaO<#el;VcY=UvXyRB0s#}qO3H?V~zku z#B?OKUegADZdoG@DCAPiBDYk>db%moZjO4?Cv&~xB6{?L*V$^GeDkRRK)B(ar>-4? z6jr7WoYSq5Cb+l&Cti)SB5Gm{Qt!NXVR1pTbLJa0xQPmwIq=;0?t{G6{@TVwH3^}Y zE;mhmgBBPt5N6G9x9bJZ9#*Cyz}ok0_T|bRW=b7gmFs&uT3q6E<+nwa`=6$4AWjjbYZ%*7pX#3!Y4kN8aX?PZF8sh|tF+ zWHZn`n`v=zafuX(tGT*hHMuPBu3-l*bNrRj5pRAFumG4*aPgupWhl}Vqp%UnF!CZs=n{M`B(MD+f z6@~$Tcqrv_0@{zAVOR-j1welO(4{5w^0Km+@GdZ*pd+}wxbSg`FVR*|jV)J8r7E7g_g_Z^QPO~+Y8Q2DOnseE5bro|$S+0g4w2iQi_)oSr^ zaWhk9SC-!|RRR10qvmiM%#dl>V-+J$DRp+_N73r=**dddu$|kisfC2=zl9f|Q)FwI z+bsr9qqX1@wPtlZxF3v_OsA*}e$BAk Ks`%Sz!nXT50Hw*oF(v$`np&CGzT`4!2 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 Nf{dzkjig!F{{c${J^}y$ literal 0 HcmV?d00001 diff --git a/maps/characters/tenue-f.png b/maps/characters/tenue-f.png new file mode 100644 index 0000000000000000000000000000000000000000..d42952ff0f8c78eea49414ced4b1cd5eb7e13b4e GIT binary patch literal 12904 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 literal 0 HcmV?d00001 diff --git a/maps/characters/tenue-m-vanoix.png b/maps/characters/tenue-m-vanoix.png new file mode 100644 index 0000000000000000000000000000000000000000..72a0713bc5650eeac0745ff72a10be8597cf0dc9 GIT binary patch literal 13337 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 Date: Tue, 20 Oct 2020 18:02:44 +0200 Subject: [PATCH 5/6] Fix webrct finish event delay --- front/src/WebRtc/MediaManager.ts | 3 +-- front/src/WebRtc/SimplePeer.ts | 31 +++++++++++++++++-------- front/src/WebRtc/VideoPeer.ts | 39 +++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index eb65b555..0cbfe123 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -455,8 +455,7 @@ export class MediaManager { addStreamRemoteVideo(userId: string, stream : MediaStream){ const remoteVideo = this.remoteVideo.get(userId); if (remoteVideo === undefined) { - console.error('Unable to find video for ', userId); - return; + throw `Unable to find video for ${userId}`; } remoteVideo.srcObject = stream; } diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index 6f38bc27..f1a66bcc 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -126,10 +126,19 @@ export class SimplePeer { * create peer connection to bind users */ private createPeerConnection(user : UserSimplePeerInterface) : VideoPeer | null{ - if( - this.PeerConnectionArray.has(user.userId) - ){ - console.log('Peer connection already exists to user '+user.userId) + const peerConnection = this.PeerConnectionArray.get(user.userId) + if(peerConnection){ + if(peerConnection.destroyed){ + peerConnection.toClose = true; + peerConnection.destroy(); + let peerConnexionDeleted = this.PeerConnectionArray.delete(user.userId); + if(!peerConnexionDeleted){ + throw 'Error to delete peer connection'; + } + this.createPeerConnection(user); + }else { + peerConnection.toClose = false; + } return null; } @@ -150,6 +159,7 @@ export class SimplePeer { mediaManager.addActiveVideo("" + user.userId, reportCallback, name); const peer = new VideoPeer(user.userId, user.initiator ? user.initiator : false, this.Connection); + 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', () => { @@ -200,16 +210,17 @@ export class SimplePeer { //mediaManager.removeActiveVideo(userId); const peer = this.PeerConnectionArray.get(userId); if (peer === undefined) { - console.warn("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 + peer.toClose = true; peer.destroy(); // 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. - //console.log('Closing connection with '+userId); - this.PeerConnectionArray.delete(userId); + this.closeScreenSharingConnection(userId); - //console.log('Nb users in peerConnectionArray '+this.PeerConnectionArray.size); + for (const peerConnectionListener of this.peerConnectionListeners) { peerConnectionListener.onDisconnect(userId); } @@ -228,7 +239,7 @@ export class SimplePeer { mediaManager.removeActiveScreenSharingVideo("" + userId); const peer = this.PeerScreenSharingConnectionArray.get(userId); if (peer === undefined) { - console.warn("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" @@ -294,7 +305,7 @@ export class SimplePeer { } } catch (e) { console.error(`receiveWebrtcSignal => ${data.userId}`, e); - //force delete and reconnect peer connexion + //force delete and recreate peer connexion this.PeerScreenSharingConnectionArray.delete(data.userId); this.receiveWebrtcScreenSharingSignal(data); } diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index 9331bea7..aa8a5d17 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -9,7 +9,10 @@ const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer'); * A peer connection used to transmit video / audio signals between 2 peers. */ export class VideoPeer extends Peer { - constructor(private userId: number, initiator: boolean, private connection: RoomConnection) { + public toClose: boolean = false; + public _connected: boolean = false; + + constructor(public userId: number, initiator: boolean, private connection: RoomConnection) { super({ initiator: initiator ? initiator : false, reconnectTimer: 10000, @@ -57,6 +60,8 @@ export class VideoPeer extends Peer { });*/ this.on('close', () => { + this._connected = false; + this.toClose = true; this.destroy(); }); @@ -67,6 +72,7 @@ export class VideoPeer extends Peer { }); this.on('connect', () => { + this._connected = true; mediaManager.isConnected("" + this.userId); console.info(`connect => ${this.userId}`); }); @@ -88,6 +94,10 @@ export class VideoPeer extends Peer { } }); + this.once('finish', () => { + this._onFinish(); + }); + this.pushVideoToRemoteUser(); } @@ -108,7 +118,15 @@ export class VideoPeer extends Peer { mediaManager.disabledVideoByUserId(this.userId); mediaManager.disabledMicrophoneByUserId(this.userId); } else { - mediaManager.addStreamRemoteVideo("" + this.userId, stream); + try { + mediaManager.addStreamRemoteVideo("" + this.userId, stream); + }catch (err){ + console.error(err); + //Force add streem video + setTimeout(() => { + this.stream(stream); + }, 500); + } } } @@ -117,16 +135,31 @@ export class VideoPeer extends Peer { */ public destroy(error?: Error): void { try { + this._connected = false + if(!this.toClose){ + return; + } mediaManager.removeActiveVideo("" + 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. - //console.log('Closing connection with '+userId); super.destroy(error); } catch (err) { console.error("VideoPeer::destroy", err) } } + _onFinish () { + if (this.destroyed) return + const destroySoon = () => { + this.destroy(); + } + if (this._connected) { + destroySoon(); + } else { + this.once('connect', destroySoon); + } + } + private pushVideoToRemoteUser() { try { const localStream: MediaStream | null = mediaManager.localStream; From 7fa999a1bda6c967bf63e45718ae94fe9d9f3d17 Mon Sep 17 00:00:00 2001 From: Gregoire Parant Date: Tue, 20 Oct 2020 18:03:10 +0200 Subject: [PATCH 6/6] Fix lint --- front/src/WebRtc/SimplePeer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index f1a66bcc..4039e10b 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -131,7 +131,7 @@ export class SimplePeer { if(peerConnection.destroyed){ peerConnection.toClose = true; peerConnection.destroy(); - let peerConnexionDeleted = this.PeerConnectionArray.delete(user.userId); + const peerConnexionDeleted = this.PeerConnectionArray.delete(user.userId); if(!peerConnexionDeleted){ throw 'Error to delete peer connection'; }