diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 790dbaaa..4cf2a8fb 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -20,7 +20,7 @@ jobs: # Create a slugified value of the branch - - uses: rlespinasse/github-slug-action@1.1.1 + - uses: rlespinasse/github-slug-action@3.1.0 - name: "Build and push front image" uses: docker/build-push-action@v1 @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v2 # Create a slugified value of the branch - - uses: rlespinasse/github-slug-action@master + - uses: rlespinasse/github-slug-action@3.1.0 - name: "Build and push back image" uses: docker/build-push-action@v1 @@ -66,7 +66,7 @@ jobs: uses: actions/checkout@v2 # Create a slugified value of the branch - - uses: rlespinasse/github-slug-action@master + - uses: rlespinasse/github-slug-action@3.1.0 - name: "Build and push back image" uses: docker/build-push-action@v1 @@ -90,7 +90,7 @@ jobs: # Create a slugified value of the branch - - uses: rlespinasse/github-slug-action@master + - uses: rlespinasse/github-slug-action@3.1.0 - name: "Build and push front image" uses: docker/build-push-action@v1 @@ -114,7 +114,7 @@ jobs: uses: actions/checkout@v2 # Create a slugified value of the branch - - uses: rlespinasse/github-slug-action@1.1.0 + - uses: rlespinasse/github-slug-action@3.1.0 - name: Deploy uses: thecodingmachine/deeployer@master @@ -137,7 +137,7 @@ jobs: check_for_duplicate_msg: true - name: Run Cypress tests - uses: cypress-io/github-action@v1 + uses: cypress-io/github-action@v2 if: ${{ env.GITHUB_REF_SLUG != 'master' }} env: CYPRESS_BASE_URL: https://play.${{ env.GITHUB_REF_SLUG }}.workadventure.test.thecodingmachine.com @@ -148,7 +148,7 @@ jobs: working-directory: e2e - name: Run Cypress tests in prod - uses: cypress-io/github-action@v1 + uses: cypress-io/github-action@v2 if: ${{ env.GITHUB_REF_SLUG == 'master' }} env: CYPRESS_BASE_URL: https://play.workadventu.re diff --git a/back/src/Controller/IoSocketController.ts b/back/src/Controller/IoSocketController.ts index b2c9ea7e..bb93fd50 100644 --- a/back/src/Controller/IoSocketController.ts +++ b/back/src/Controller/IoSocketController.ts @@ -20,9 +20,9 @@ import {parse} from "query-string"; import {jwtTokenManager} from "../Services/JWTTokenManager"; import {adminApi, CharacterTexture, FetchMemberDataByUuidResponse} from "../Services/AdminApi"; import {SocketManager, socketManager} from "../Services/SocketManager"; -import {emitInBatch, resetPing} from "../Services/IoSocketHelpers"; +import {emitInBatch} from "../Services/IoSocketHelpers"; import {clientEventsEmitter} from "../Services/ClientEventsEmitter"; -import {ADMIN_API_TOKEN, MAX_USERS_PER_ROOM} from "../Enum/EnvironmentVariable"; +import {ADMIN_API_TOKEN, MAX_USERS_PER_ROOM, ADMIN_API_URL, SOCKET_IDLE_TIMER} from "../Enum/EnvironmentVariable"; export class IoSocketController { private nextUserId: number = 1; @@ -43,6 +43,7 @@ export class IoSocketController { if (token !== ADMIN_API_TOKEN) { console.log('Admin access refused for token: '+token) res.writeStatus("401 Unauthorized").end('Incorrect token'); + return; } const roomId = query.roomId as string; @@ -110,6 +111,7 @@ export class IoSocketController { this.app.ws('/room', { /* Options */ //compression: uWS.SHARED_COMPRESSOR, + idleTimeout: SOCKET_IDLE_TIMER, maxPayloadLength: 16 * 1024 * 1024, maxBackpressure: 65536, // Maximum 64kB of data in the buffer. //idleTimeout: 10, @@ -164,23 +166,24 @@ export class IoSocketController { let memberTextures: CharacterTexture[] = []; const room = await socketManager.getOrCreateRoom(roomId); - //TODO http return status - /*if (room.isFull) { + if(room.isFull){ throw new Error('Room is full'); - }*/ - - 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') + } + if (ADMIN_API_URL) { + 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); + console.error(e); + throw new Error('Client cannot acces this ressource.') } - //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[] @@ -236,21 +239,19 @@ export class IoSocketController { }, /* Handlers */ open: (ws) => { - (async () => { - // Let's join the room - const client = this.initClient(ws); //todo: into the upgrade instead? + // Let's join the room + const client = this.initClient(ws); //todo: into the upgrade instead? + const room = socketManager.getRoomById(client.roomId); - const room = socketManager.getRoomById(client.roomId); - if (room && room.isFull) { - socketManager.emitCloseMessage(client, 302); - }else { - socketManager.handleJoinRoom(client); - resetPing(client); - } + if (room && room.isFull) { + socketManager.emitCloseMessage(client, 302); + }else { + socketManager.handleJoinRoom(client); + } - //get data information and shwo messages - try { - const res: FetchMemberDataByUuidResponse = await adminApi.fetchMemberDataByUuid(client.userUuid); + //get data information and show messages + if (ADMIN_API_URL) { + adminApi.fetchMemberDataByUuid(client.userUuid).then((res: FetchMemberDataByUuidResponse) => { if (!res.messages) { return; } @@ -262,10 +263,10 @@ export class IoSocketController { message: messageToSend.message }) }); - } catch (err) { + }).catch((err) => { console.error('fetchMemberDataByUuid => err', err); - } - })(); + }); + } }, message: (ws, arrayBuffer, isBinary): void => { diff --git a/back/src/Controller/MapController.ts b/back/src/Controller/MapController.ts index 28fbe6f4..c111ba64 100644 --- a/back/src/Controller/MapController.ts +++ b/back/src/Controller/MapController.ts @@ -3,7 +3,7 @@ import {BaseController} from "./BaseController"; import {parse} from "query-string"; import {adminApi} from "../Services/AdminApi"; -//todo: delete this + export class MapController extends BaseController{ constructor(private App : TemplatedApp) { @@ -34,18 +34,21 @@ export class MapController extends BaseController{ res.writeStatus("400 Bad request"); this.addCorsHeaders(res); res.end("Expected organizationSlug parameter"); + return; } if (typeof query.worldSlug !== 'string') { console.error('Expected worldSlug parameter'); res.writeStatus("400 Bad request"); 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"); + return; } (async () => { diff --git a/back/src/Enum/EnvironmentVariable.ts b/back/src/Enum/EnvironmentVariable.ts index 56c49284..3a2ac99e 100644 --- a/back/src/Enum/EnvironmentVariable.ts +++ b/back/src/Enum/EnvironmentVariable.ts @@ -3,13 +3,14 @@ const URL_ROOM_STARTED = "/Floor0/floor0.json"; const MINIMUM_DISTANCE = process.env.MINIMUM_DISTANCE ? Number(process.env.MINIMUM_DISTANCE) : 64; const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS) : 48; const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == 'true' : false; -const ADMIN_API_URL = process.env.ADMIN_API_URL || 'http://admin'; +const ADMIN_API_URL = process.env.ADMIN_API_URL || ''; const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || 'myapitoken'; const MAX_USERS_PER_ROOM = parseInt(process.env.MAX_USERS_PER_ROOM || '') || 600; const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80; const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined : process.env.JITSI_URL; const JITSI_ISS = process.env.JITSI_ISS || ''; const SECRET_JITSI_KEY = process.env.SECRET_JITSI_KEY || ''; +export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed export { SECRET_KEY, diff --git a/back/src/Model/Websocket/ExSocketInterface.ts b/back/src/Model/Websocket/ExSocketInterface.ts index 205032bc..c64a4952 100644 --- a/back/src/Model/Websocket/ExSocketInterface.ts +++ b/back/src/Model/Websocket/ExSocketInterface.ts @@ -25,7 +25,6 @@ export interface ExSocketInterface extends WebSocket, Identificable { emitInBatch: (payload: SubMessage) => void; batchedMessages: BatchMessage; batchTimeout: NodeJS.Timeout|null; - pingTimeout: NodeJS.Timeout|null; disconnecting: boolean, tags: string[], textures: CharacterTexture[], diff --git a/back/src/Services/GaugeManager.ts b/back/src/Services/GaugeManager.ts index f8af822b..80712856 100644 --- a/back/src/Services/GaugeManager.ts +++ b/back/src/Services/GaugeManager.ts @@ -6,8 +6,13 @@ class GaugeManager { private nbClientsPerRoomGauge: Gauge; private nbGroupsPerRoomGauge: Gauge; private nbGroupsPerRoomCounter: Counter; + private nbRoomsGauge: Gauge; constructor() { + this.nbRoomsGauge = new Gauge({ + name: 'workadventure_nb_rooms', + help: 'Number of active rooms' + }); this.nbClientsGauge = new Gauge({ name: 'workadventure_nb_sockets', help: 'Number of connected sockets', @@ -31,6 +36,13 @@ class GaugeManager { }); } + incNbRoomGauge(): void { + this.nbRoomsGauge.inc(); + } + decNbRoomGauge(): void { + this.nbRoomsGauge.dec(); + } + incNbClientPerRoomGauge(roomId: string): void { this.nbClientsGauge.inc(); this.nbClientsPerRoomGauge.inc({ room: roomId }); diff --git a/back/src/Services/IoSocketHelpers.ts b/back/src/Services/IoSocketHelpers.ts index 2166a53e..9c27c59a 100644 --- a/back/src/Services/IoSocketHelpers.ts +++ b/back/src/Services/IoSocketHelpers.ts @@ -18,22 +18,6 @@ export function emitInBatch(socket: ExSocketInterface, payload: SubMessage): voi socket.batchTimeout = null; }, 100); } - - // If we send a message, we don't need to keep the connection alive - resetPing(socket); -} - -export function resetPing(ws: ExSocketInterface): void { - if (ws.pingTimeout) { - clearTimeout(ws.pingTimeout); - } - ws.pingTimeout = setTimeout(() => { - if (ws.disconnecting) { - return; - } - ws.ping(); - resetPing(ws); - }, 29000); } export function emitError(Client: ExSocketInterface, message: string): void { @@ -47,4 +31,5 @@ export function emitError(Client: ExSocketInterface, message: string): void { Client.send(serverToClientMessage.serializeBinary().buffer, true); } console.warn(message); -} \ No newline at end of file +} + diff --git a/back/src/Services/JWTTokenManager.ts b/back/src/Services/JWTTokenManager.ts index f82fa001..8abb0e45 100644 --- a/back/src/Services/JWTTokenManager.ts +++ b/back/src/Services/JWTTokenManager.ts @@ -1,11 +1,11 @@ -import {ALLOW_ARTILLERY, SECRET_KEY} from "../Enum/EnvironmentVariable"; +import {ADMIN_API_URL, ALLOW_ARTILLERY, SECRET_KEY} from "../Enum/EnvironmentVariable"; import {uuid} from "uuidv4"; import Jwt from "jsonwebtoken"; import {TokenInterface} from "../Controller/AuthenticateController"; import {adminApi, AdminApiData} from "../Services/AdminApi"; class JWTTokenManager { - + public createJWTToken(userUuid: string) { return Jwt.sign({userUuid: userUuid}, SECRET_KEY, {expiresIn: '200d'}); //todo: add a mechanic to refresh or recreate token } @@ -48,17 +48,21 @@ class JWTTokenManager { return; } - //verify user in admin - adminApi.fetchCheckUserByToken(tokenInterface.userUuid).then(() => { - resolve(tokenInterface.userUuid); - }).catch((err) => { - //anonymous user - if(err.response && err.response.status && err.response.status === 404){ + if (ADMIN_API_URL) { + //verify user in admin + adminApi.fetchCheckUserByToken(tokenInterface.userUuid).then(() => { resolve(tokenInterface.userUuid); - return; - } - reject(new Error('Authentication error, invalid token structure. ' + err)); - }); + }).catch((err) => { + //anonymous user + if(err.response && err.response.status && err.response.status === 404){ + resolve(tokenInterface.userUuid); + return; + } + reject(err); + }); + } else { + resolve(tokenInterface.userUuid); + } }); }); } @@ -66,7 +70,7 @@ class JWTTokenManager { private isValidToken(token: object): token is TokenInterface { return !(typeof((token as TokenInterface).userUuid) !== 'string'); } - + } -export const jwtTokenManager = new JWTTokenManager(); \ No newline at end of file +export const jwtTokenManager = new JWTTokenManager(); diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index bfcd05c7..0e0440b8 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -353,6 +353,7 @@ export class SocketManager { world.leave(Client); if (world.isEmpty()) { this.Worlds.delete(Client.roomId); + gaugeManager.decNbRoomGauge(); } } //user leave previous room @@ -385,6 +386,7 @@ export class SocketManager { world.tags = data.tags world.policyType = Number(data.policy_type) } + gaugeManager.incNbRoomGauge(); this.Worlds.set(roomId, world); } return Promise.resolve(world) diff --git a/deeployer.libsonnet b/deeployer.libsonnet index 4edb4728..bad951f1 100644 --- a/deeployer.libsonnet +++ b/deeployer.libsonnet @@ -3,6 +3,7 @@ local namespace = env.GITHUB_REF_SLUG, local tag = namespace, local url = if namespace == "master" then "workadventu.re" else namespace+".workadventure.test.thecodingmachine.com", + local adminUrl = if namespace == "master" || namespace == "develop" || std.startsWith(namespace, "admin") then "https://admin."+url else null, "$schema": "https://raw.githubusercontent.com/thecodingmachine/deeployer/master/deeployer.schema.json", "version": "1.0", "containers": { @@ -16,11 +17,12 @@ "env": { "SECRET_KEY": "tempSecretKeyNeedsToChange", "ADMIN_API_TOKEN": env.ADMIN_API_TOKEN, - "ADMIN_API_URL": "https://admin."+url, "JITSI_ISS": env.JITSI_ISS, "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, - } + } + if adminUrl != null then { + "ADMIN_API_URL": adminUrl, + } else {} }, "front": { "image": "thecodingmachine/workadventure-front:"+tag, @@ -31,6 +33,7 @@ "ports": [80], "env": { "API_URL": "api."+url, + "ADMIN_URL": "admin."+url, "JITSI_URL": env.JITSI_URL, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "TURN_SERVER": "turn:coturn.workadventu.re:443,turns:coturn.workadventu.re:443", diff --git a/docker-compose.yaml b/docker-compose.yaml index 482dfbcb..7f21b16d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -27,6 +27,7 @@ services: HOST: "0.0.0.0" NODE_ENV: development API_URL: api.workadventure.localhost + ADMIN_URL: admin.workadventure.localhost STARTUP_COMMAND_1: yarn install TURN_SERVER: "turn:coturn.workadventu.re:443,turns:coturn.workadventu.re:443" TURN_USER: workadventure diff --git a/front/dist/resources/logos/boy.svg b/front/dist/resources/logos/boy.svg new file mode 100644 index 00000000..d6d9582e --- /dev/null +++ b/front/dist/resources/logos/boy.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/dist/resources/logos/discussion.svg b/front/dist/resources/logos/discussion.svg new file mode 100644 index 00000000..1b4572b3 --- /dev/null +++ b/front/dist/resources/logos/discussion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/dist/resources/style/style.css b/front/dist/resources/style/style.css index edd3ddf0..fc055941 100644 --- a/front/dist/resources/style/style.css +++ b/front/dist/resources/style/style.css @@ -1,6 +1,14 @@ +*{ + font-family: 'Open Sans', sans-serif; +} body{ overflow: hidden; } +body button:focus, +body img:focus, +body input:focus { + outline: -webkit-focus-ring-color auto 0; +} body .message-info{ width: 20%; height: auto; @@ -72,14 +80,16 @@ body .message-info.warning{ #div-myCamVideo { position: absolute; - right: 0; - bottom: 0; + right: 15px; + bottom: 15px; + border-radius: 15px 15px 15px 15px; } video#myCamVideo{ width: 15vw; -webkit-transform: scaleX(-1); transform: scaleX(-1); + border-radius: 15px 15px 15px 15px; /*width: 200px;*/ /*height: 113px;*/ } @@ -372,6 +382,7 @@ body { margin: 2%; transition: margin-left 0.2s, margin-right 0.2s, margin-bottom 0.2s, margin-top 0.2s, max-height 0.2s, max-width 0.2s; cursor: pointer; + border-radius: 15px 15px 15px 15px; } .sidebar > div:hover { @@ -384,6 +395,7 @@ body { justify-content: center; flex-direction: column; overflow: hidden; + border-radius: 15px; } .chat-mode { @@ -435,7 +447,7 @@ body { max-height: 80%; top: -80%; left: 10%; - background: #000000a6; + background: #333333; z-index: 200; transition: all 0.1s ease-out; } @@ -532,7 +544,7 @@ body { border: 1px solid black; background-color: #00000000; color: #ffda01; - border-radius: 10px; + border-radius: 15px; padding: 10px 30px; transition: all .2s ease; } @@ -627,6 +639,7 @@ div.modal-report-user{ left: calc(50% - 400px); top: 100px; background-color: #000000ad; + border-radius: 15px; } .modal-report-user textarea{ @@ -638,6 +651,7 @@ div.modal-report-user{ color: white; width: calc(100% - 60px); margin: 30px; + border-radius: 15px; } .modal-report-user img{ @@ -669,7 +683,7 @@ div.modal-report-user{ border: 1px solid black; background-color: #00000000; color: #ffda01; - border-radius: 10px; + border-radius: 15px; padding: 10px 30px; transition: all .2s ease; } @@ -701,3 +715,189 @@ div.modal-report-user{ max-width: calc(800px - 60px); /* size of modal - padding*/ } +/*MESSAGE*/ +.discussion{ + position: fixed; + left: -300px; + top: 0px; + width: 220px; + height: 100%; + background-color: #333333; + padding: 20px; + transition: all 0.5s ease; +} +.discussion.active{ + left: 0; +} +.discussion .active-btn{ + display: none; + cursor: pointer; + height: 50px; + width: 50px; + background-color: #2d2d2dba; + position: absolute; + top: calc(50% - 25px); + margin-left: 315px; + border-radius: 50%; + border: none; + transition: all 0.5s ease; +} +.discussion .active-btn.active{ + display: block; +} +.discussion .active-btn:hover { + transform: scale(1.1) rotateY(3.142rad); +} +.discussion .active-btn img{ + width: 26px; + height: 26px; + margin: 13px 5px; +} + +.discussion .close-btn{ + position: absolute; + top: 0; + right: 10px; + background: none; + border: none; + cursor: pointer; +} +.discussion .close-btn img{ + height: 15px; + right: 15px; +} + +.discussion p{ + color: white; + font-size: 22px; + padding-left: 10px; + margin: 0; +} + +.discussion .participants{ + height: 200px; + margin: 10px 0; +} + +.discussion .participants .participant{ + display: flex; + margin: 5px 10px; + background-color: #ffffff69; + padding: 5px; + border-radius: 15px; + cursor: pointer; +} + +.discussion .participants .participant:hover{ + background-color: #ffffff; +} +.discussion .participants .participant:hover p{ + color: black; +} + +.discussion .participants .participant:before { + content: ''; + height: 10px; + width: 10px; + background-color: #1e7e34; + position: absolute; + margin-left: 18px; + border-radius: 50%; + margin-top: 18px; +} + +.discussion .participants .participant img{ + width: 26px; + height: 26px; +} + +.discussion .participants .participant p{ + font-size: 16px; + margin-left: 10px; + margin-top: 2px; +} + +.discussion .participants .participant button.report-btn{ + cursor: pointer; + position: absolute; + background-color: #2d2d2dba; + right: 34px; + margin: 0px; + padding: 6px 0px; + border-radius: 15px; + border: none; + color: white; + width: 0px; + overflow: hidden; + transition: all .5s ease; +} + +.discussion .participants .participant:hover button.report-btn{ + width: 70px; +} + +.discussion .messages{ + position: absolute; + height: calc(100% - 360px); + overflow-x: hidden; + overflow-y: auto; + max-width: calc(100% - 40px); + width: calc(100% - 40px); +} + +.discussion .messages h2{ + color: white; +} + +.discussion .messages .message{ + margin: 5px; + float: right; + text-align: right; + width: 100%; +} + +.discussion .messages .message.me{ + float: left; + text-align: left; +} + +.discussion .messages .message p{ + font-size: 12px; +} + +.discussion .messages .message p.body{ + font-size: 16px; + overflow: hidden; + white-space: pre-wrap; + word-wrap: break-word; +} + +.discussion .send-message{ + position: absolute; + bottom: 45px; + width: 220px; + height: 26px; +} + +.discussion .send-message input{ + position: absolute; + width: calc(100% - 10px); + height: 20px; + background-color: #171717; + color: white; + border-radius: 15px; + border: none; + padding: 6px; +} + +.discussion .send-message img{ + position: absolute; + margin-right: 10px; + width: 20px; + height: 20px; + background-color: #ffffff69; +} +.discussion .send-message img:hover{ + background-color: #ffffff; +} + diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 5f2eff05..578b8bff 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -27,7 +27,9 @@ import { SendJitsiJwtMessage, CharacterLayerMessage, SendUserMessage, - CloseMessage + CloseMessage, + PingMessage, + SendUserMessage } from "../Messages/generated/messages_pb" import {UserSimplePeerInterface} from "../WebRtc/SimplePeer"; @@ -43,6 +45,8 @@ import { } from "./ConnexionModels"; import {BodyResourceDescriptionInterface} from "../Phaser/Entity/body_character"; +const manualPingDelay = 20000; + export class RoomConnection implements RoomConnection { private readonly socket: WebSocket; private userId: number|null = null; @@ -85,7 +89,9 @@ export class RoomConnection implements RoomConnection { this.socket.binaryType = 'arraybuffer'; this.socket.onopen = (ev) => { - //console.log('WS connected'); + //we manually ping every 20s to not be logged out by the server, even when the game is in background. + const pingMessage = new PingMessage(); + setInterval(() => this.socket.send(pingMessage.serializeBinary().buffer), manualPingDelay); }; this.socket.onmessage = (messageEvent) => { diff --git a/front/src/Enum/EnvironmentVariable.ts b/front/src/Enum/EnvironmentVariable.ts index 60f9cd3b..d3b6c809 100644 --- a/front/src/Enum/EnvironmentVariable.ts +++ b/front/src/Enum/EnvironmentVariable.ts @@ -1,12 +1,12 @@ const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true"; const API_URL = (process.env.API_PROTOCOL || (typeof(window) !== 'undefined' ? window.location.protocol : 'http:')) + '//' + (process.env.API_URL || "api.workadventure.localhost"); -const ADMIN_URL = API_URL.replace('api', 'admin'); +const ADMIN_URL = (process.env.API_PROTOCOL || (typeof(window) !== 'undefined' ? window.location.protocol : 'http:')) + '//' + (process.env.ADMIN_URL || "admin.workadventure.localhost"); const TURN_SERVER: string = process.env.TURN_SERVER || "turn:numb.viagenie.ca"; const TURN_USER: string = process.env.TURN_USER || 'g.parant@thecodingmachine.com'; const TURN_PASSWORD: string = process.env.TURN_PASSWORD || 'itcugcOHxle9Acqi$'; const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined : process.env.JITSI_URL; const JITSI_PRIVATE_MODE : boolean = process.env.JITSI_PRIVATE_MODE == "true"; -const RESOLUTION = 3; +const RESOLUTION = 2; const ZOOM_LEVEL = 1/*3/4*/; const POSITION_DELAY = 200; // Wait 200ms between sending position events const MAX_EXTRAPOLATION_TIME = 100; // Extrapolate a maximum of 250ms if no new movement is sent by the player diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index b894c432..7c6c475f 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -350,51 +350,17 @@ export class GameScene extends ResizableScene implements CenterListener { throw new Error('Your map MUST contain a layer of type "objectgroup" whose name is "floorLayer" that represents the layer characters are drawn at.'); } - // 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.room.hash) { - for (const layer of this.mapFile.layers) { - if (this.room.hash === layer.name && layer.type === 'tilelayer' && this.isStartLayer(layer)) { - const startPosition = this.startUser(layer); - this.startX = startPosition.x; - this.startY = startPosition.y; - } - } - } - 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. - for (const layer of this.mapFile.layers) { - if (layer.type === 'tilelayer' && layer.name === "start") { - const startPosition = this.startUser(layer); - this.startX = startPosition.x; - this.startY = startPosition.y; - } - } - } - } - // 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; - } + this.initStartXAndStartY(); //add entities this.Objects = new Array(); - //init event click - this.EventToClickOnTile(); - //initialise list of other player this.MapPlayers = this.physics.add.group({immovable: true}); //create input to move this.userInputManager = new UserInputManager(this); + mediaManager.setUserInputManager(this.userInputManager); //notify game manager can to create currentUser in map this.createCurrentPlayer(); @@ -474,35 +440,7 @@ export class GameScene extends ResizableScene implements CenterListener { // From now, this game scene will be notified of reposition events layoutManager.setListener(this); - - this.gameMap.onPropertyChange('openWebsite', (newValue, oldValue) => { - if (newValue === undefined) { - coWebsiteManager.closeCoWebsite(); - } else { - coWebsiteManager.loadCoWebsite(newValue as string); - } - }); - this.gameMap.onPropertyChange('jitsiRoom', (newValue, oldValue, allProps) => { - if (newValue === undefined) { - this.stopJitsi(); - } else { - if (JITSI_PRIVATE_MODE) { - const adminTag = allProps.get("jitsiRoomAdminTag") as string|undefined; - - this.connection.emitQueryJitsiJwtMessage(this.instance.replace('/', '-') + "-" + newValue, adminTag); - } else { - this.startJitsi(newValue as string); - } - } - }) - - this.gameMap.onPropertyChange('silent', (newValue, oldValue) => { - if (newValue === undefined || newValue === false || newValue === '') { - this.connection.setSilent(false); - } else { - this.connection.setSilent(true); - } - }); + this.triggerOnMapLayerPropertyChange(); const camera = this.cameras.main; @@ -650,7 +588,7 @@ export class GameScene extends ResizableScene implements CenterListener { }); // When connection is performed, let's connect SimplePeer - this.simplePeer = new SimplePeer(this.connection, !this.room.isPublic); + this.simplePeer = new SimplePeer(this.connection, !this.room.isPublic, this.GameManager.getPlayerName()); this.GlobalMessageManager = new GlobalMessageManager(this.connection); this.UserMessageManager = new UserMessageManager(this.connection); @@ -675,10 +613,12 @@ export class GameScene extends ResizableScene implements CenterListener { this.gameMap.setPosition(event.x, event.y); }) - this.scene.wake(); this.scene.sleep(ReconnectingSceneName); + //init user position and play trigger to check layers properties + this.gameMap.setPosition(this.CurrentPlayer.x, this.CurrentPlayer.y); + return connection; }).catch(error => { if (error === connexionErrorTypes.tooManyUsers) { @@ -688,7 +628,41 @@ export class GameScene extends ResizableScene implements CenterListener { }); } + private triggerOnMapLayerPropertyChange(){ + this.gameMap.onPropertyChange('openWebsite', (newValue, oldValue) => { + if (newValue === undefined) { + coWebsiteManager.closeCoWebsite(); + } else { + coWebsiteManager.loadCoWebsite(newValue as string); + } + }); + this.gameMap.onPropertyChange('jitsiRoom', (newValue, oldValue, allProps) => { + if (newValue === undefined) { + this.stopJitsi(); + } else { + if (JITSI_PRIVATE_MODE) { + const adminTag = allProps.get("jitsiRoomAdminTag") as string|undefined; + + this.connection.emitQueryJitsiJwtMessage(this.instance.replace('/', '-') + "-" + newValue, adminTag); + } else { + this.startJitsi(newValue as string); + } + } + }) + this.gameMap.onPropertyChange('silent', (newValue, oldValue) => { + if (newValue === undefined || newValue === false || newValue === '') { + this.connection.setSilent(false); + } else { + this.connection.setSilent(true); + } + }); + } + private switchLayoutMode(): void { + //if discussion is activated, this layout cannot be activated + if(mediaManager.activatedDiscussion){ + return; + } const mode = layoutManager.getLayoutMode(); if (mode === LayoutMode.Presentation) { layoutManager.switchLayoutMode(LayoutMode.VideoChat); @@ -701,6 +675,40 @@ export class GameScene extends ResizableScene implements CenterListener { } } + 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.room.hash) { + this.initPositionFromLayerName(this.room.hash); + } + 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("start"); + } + } + // 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.mapFile.layers) { + if (layerName === layer.name && layer.type === 'tilelayer' && (layerName === "start" || this.isStartLayer(layer))) { + const startPosition = this.startUser(layer); + this.startX = startPosition.x; + this.startY = startPosition.y; + } + } + } + private getExitUrl(layer: ITiledMapLayer): string|undefined { return this.getProperty(layer, "exitUrl") as string|undefined; } @@ -739,9 +747,6 @@ export class GameScene extends ResizableScene implements CenterListener { instance = this.instance; } - //console.log('existSceneUrl', exitSceneUrl); - //console.log('existSceneInstance', instance); - const absoluteExitSceneUrl = new URL(exitSceneUrl, this.MapUrlFile).href; const absoluteExitSceneUrlWithoutProtocol = absoluteExitSceneUrl.toString().substr(absoluteExitSceneUrl.toString().indexOf('://')+3); const roomId = '_/'+instance+'/'+absoluteExitSceneUrlWithoutProtocol; @@ -759,13 +764,9 @@ export class GameScene extends ResizableScene implements CenterListener { this.loadNextGame(layer, mapWidth, fullPath); } - /** - * - * @param layer - * @param mapWidth - */ //todo: push that into the gameManager private loadNextGame(layer: ITiledMapLayer, mapWidth: number, roomId: string){ + const room = new Room(roomId); gameManager.loadMap(room, this.scene); const exitSceneKey = roomId; @@ -798,9 +799,6 @@ export class GameScene extends ResizableScene implements CenterListener { } } - /** - * @param layer - */ private startUser(layer: ITiledMapLayer): PositionInterface { const tiles = layer.data; if (typeof(tiles) === 'string') { @@ -956,17 +954,6 @@ export class GameScene extends ResizableScene implements CenterListener { }); } - EventToClickOnTile(){ - // debug code to get a tile properties by clicking on it - /*this.input.on("pointerdown", (pointer: Phaser.Input.Pointer)=>{ - //pixel position toz tile position - const tile = this.Map.getTileAt(this.Map.worldToTileX(pointer.worldX), this.Map.worldToTileY(pointer.worldY)); - if(tile){ - this.CurrentPlayer.say("Your touch " + tile.layer.name); - } - });*/ - } - /** * @param time * @param delta The delta time in ms since the last frame. This is a smoothed and capped value based on the FPS rate. @@ -1011,21 +998,22 @@ export class GameScene extends ResizableScene implements CenterListener { }); const nextSceneKey = this.checkToExit(); - if (nextSceneKey) { - this.goToNextScene(nextSceneKey.key); + if (!nextSceneKey) return; + if (nextSceneKey.key !== this.scene.key) { + // We are completely destroying the current scene to avoid using a half-backed instance when coming back to the same map. + this.connection.closeConnection(); + this.simplePeer.unregister(); + this.scene.stop(); + this.scene.remove(this.scene.key); + this.scene.start(nextSceneKey.key); + } else { + //if the exit points to the current map, we simply teleport the user back to the startLayer + this.initPositionFromLayerName(this.room.hash || 'start'); + this.CurrentPlayer.x = this.startX; + this.CurrentPlayer.y = this.startY; } } - private goToNextScene(nextSceneKey: string): void { - - // We are completely destroying the current scene to avoid using a half-backed instance when coming back to the same map. - this.connection.closeConnection(); - this.simplePeer.unregister(); - this.scene.stop(); - this.scene.remove(this.scene.key); - this.scene.start(nextSceneKey); - } - private checkToExit(): {key: string, hash: string} | null { const x = Math.floor(this.CurrentPlayer.x / 32); const y = Math.floor(this.CurrentPlayer.y / 32); @@ -1124,11 +1112,6 @@ export class GameScene extends ResizableScene implements CenterListener { 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 ? "); - });*/ - } /** @@ -1171,7 +1154,6 @@ export class GameScene extends ResizableScene implements CenterListener { // We do not update the player position directly (because it is sent only every 200ms). // Instead we use the PlayersPositionInterpolator that will do a smooth animation over the next 200ms. const playerMovement = new PlayerMovement({ x: player.x, y: player.y }, this.currentTick, message.position, this.currentTick + POSITION_DELAY); - //console.log('Target position: ', player.x, player.y); this.playersPositionInterpolator.updatePlayerPosition(player.userId, playerMovement); } @@ -1220,11 +1202,6 @@ export class GameScene extends ResizableScene implements CenterListener { /** * Sends to the server an event emitted by one of the ActionableItems. - * - * @param itemId - * @param eventName - * @param state - * @param parameters */ emitActionableEvent(itemId: number, eventName: string, state: unknown, parameters: unknown) { this.connection.emitActionableEvent(itemId, eventName, state, parameters); diff --git a/front/src/Phaser/Login/EnableCameraScene.ts b/front/src/Phaser/Login/EnableCameraScene.ts index 32037858..56502704 100644 --- a/front/src/Phaser/Login/EnableCameraScene.ts +++ b/front/src/Phaser/Login/EnableCameraScene.ts @@ -7,6 +7,7 @@ import {mediaManager} from "../../WebRtc/MediaManager"; import {RESOLUTION} from "../../Enum/EnvironmentVariable"; import {SoundMeter} from "../Components/SoundMeter"; import {SoundMeterSprite} from "../Components/SoundMeterSprite"; +import {HtmlUtils} from "../../WebRtc/HtmlUtils"; export const EnableCameraSceneName = "EnableCameraScene"; enum LoginTextures { @@ -93,7 +94,7 @@ export class EnableCameraScene extends Phaser.Scene { this.login(); }); - this.getElementByIdOrFail('webRtcSetup').classList.add('active'); + HtmlUtils.getElementByIdOrFail('webRtcSetup').classList.add('active'); const mediaPromise = mediaManager.getCamera(); mediaPromise.then(this.getDevices.bind(this)); @@ -150,10 +151,10 @@ export class EnableCameraScene extends Phaser.Scene { * Function called each time a camera is changed */ private setupStream(stream: MediaStream): void { - const img = this.getElementByIdOrFail('webRtcSetupNoVideo'); + const img = HtmlUtils.getElementByIdOrFail('webRtcSetupNoVideo'); img.style.display = 'none'; - const div = this.getElementByIdOrFail('myCamVideoSetup'); + const div = HtmlUtils.getElementByIdOrFail('myCamVideoSetup'); div.srcObject = stream; this.soundMeter.connectToSource(stream, new window.AudioContext()); @@ -164,7 +165,7 @@ export class EnableCameraScene extends Phaser.Scene { private updateWebCamName(): void { if (this.camerasList.length > 1) { - const div = this.getElementByIdOrFail('myCamVideoSetup'); + const div = HtmlUtils.getElementByIdOrFail('myCamVideoSetup'); let label = this.camerasList[this.cameraSelected].label; // remove text in parenthesis @@ -211,10 +212,10 @@ export class EnableCameraScene extends Phaser.Scene { } private reposition(): void { - let div = this.getElementByIdOrFail('myCamVideoSetup'); + let div = HtmlUtils.getElementByIdOrFail('myCamVideoSetup'); let bounds = div.getBoundingClientRect(); if (!div.srcObject) { - div = this.getElementByIdOrFail('webRtcSetup'); + div = HtmlUtils.getElementByIdOrFail('webRtcSetup'); bounds = div.getBoundingClientRect(); } @@ -255,7 +256,7 @@ export class EnableCameraScene extends Phaser.Scene { } private login(): void { - this.getElementByIdOrFail('webRtcSetup').style.display = 'none'; + HtmlUtils.getElementByIdOrFail('webRtcSetup').style.display = 'none'; this.soundMeter.stop(); window.removeEventListener('resize', this.repositionCallback); @@ -276,13 +277,4 @@ export class EnableCameraScene extends Phaser.Scene { } this.updateWebCamName(); } - - private getElementByIdOrFail(id: string): T { - const elem = document.getElementById(id); - if (elem === null) { - throw new Error("Cannot find HTML element with id '"+id+"'"); - } - // FIXME: does not check the type of the returned type - return elem as T; - } } diff --git a/front/src/Phaser/UserInput/UserInputManager.ts b/front/src/Phaser/UserInput/UserInputManager.ts index fa7080b5..636783bc 100644 --- a/front/src/Phaser/UserInput/UserInputManager.ts +++ b/front/src/Phaser/UserInput/UserInputManager.ts @@ -40,7 +40,9 @@ export class UserInputManager { initKeyBoardEvent(){ this.KeysCode = [ {event: UserInputEvent.MoveUp, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Z, false) }, + {event: UserInputEvent.MoveUp, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W, false) }, {event: UserInputEvent.MoveLeft, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q, false) }, + {event: UserInputEvent.MoveLeft, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A, false) }, {event: UserInputEvent.MoveDown, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S, false) }, {event: UserInputEvent.MoveRight, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D, false) }, diff --git a/front/src/WebRtc/CoWebsiteManager.ts b/front/src/WebRtc/CoWebsiteManager.ts index ab63e60a..b625bf6e 100644 --- a/front/src/WebRtc/CoWebsiteManager.ts +++ b/front/src/WebRtc/CoWebsiteManager.ts @@ -16,31 +16,35 @@ class CoWebsiteManager { private opened: iframeStates = iframeStates.closed; private observers = new Array(); + /** + * Quickly going in and out of an iframe trigger can create conflicts between the iframe states. + * So we use this promise to queue up every cowebsite state transition + */ + private currentOperationPromise: Promise = Promise.resolve(); + private cowebsiteDiv: HTMLDivElement; - private close(): HTMLDivElement { - const cowebsiteDiv = HtmlUtils.getElementByIdOrFail(cowebsiteDivId); - cowebsiteDiv.classList.remove('loaded'); //edit the css class to trigger the transition - cowebsiteDiv.classList.add('hidden'); + constructor() { + this.cowebsiteDiv = HtmlUtils.getElementByIdOrFail(cowebsiteDivId); + } + + private close(): void { + this.cowebsiteDiv.classList.remove('loaded'); //edit the css class to trigger the transition + this.cowebsiteDiv.classList.add('hidden'); this.opened = iframeStates.closed; - return cowebsiteDiv; } - private load(): HTMLDivElement { - const cowebsiteDiv = HtmlUtils.getElementByIdOrFail(cowebsiteDivId); - cowebsiteDiv.classList.remove('hidden'); //edit the css class to trigger the transition - cowebsiteDiv.classList.add('loading'); + private load(): void { + this.cowebsiteDiv.classList.remove('hidden'); //edit the css class to trigger the transition + this.cowebsiteDiv.classList.add('loading'); this.opened = iframeStates.loading; - return cowebsiteDiv; } - private open(): HTMLDivElement { - const cowebsiteDiv = HtmlUtils.getElementByIdOrFail(cowebsiteDivId); - cowebsiteDiv.classList.remove('loading', 'hidden'); //edit the css class to trigger the transition + private open(): void { + this.cowebsiteDiv.classList.remove('loading', 'hidden'); //edit the css class to trigger the transition this.opened = iframeStates.opened; - return cowebsiteDiv; } public loadCoWebsite(url: string): void { - const cowebsiteDiv = this.load(); - cowebsiteDiv.innerHTML = ''; + this.load(); + this.cowebsiteDiv.innerHTML = ''; const iframe = document.createElement('iframe'); iframe.id = 'cowebsite-iframe'; @@ -48,40 +52,42 @@ class CoWebsiteManager { const onloadPromise = new Promise((resolve) => { iframe.onload = () => resolve(); }); - cowebsiteDiv.appendChild(iframe); + this.cowebsiteDiv.appendChild(iframe); const onTimeoutPromise = new Promise((resolve) => { setTimeout(() => resolve(), 2000); }); - Promise.race([onloadPromise, onTimeoutPromise]).then(() => { + this.currentOperationPromise = this.currentOperationPromise.then(() =>Promise.race([onloadPromise, onTimeoutPromise])).then(() => { this.open(); setTimeout(() => { this.fire(); }, animationTime) - }); + }).catch(() => this.closeCoWebsite()); } /** * Just like loadCoWebsite but the div can be filled by the user. */ public insertCoWebsite(callback: (cowebsite: HTMLDivElement) => Promise): void { - const cowebsiteDiv = this.load(); - callback(cowebsiteDiv).then(() => { + this.load(); + this.currentOperationPromise = this.currentOperationPromise.then(() => callback(this.cowebsiteDiv)).then(() => { this.open(); setTimeout(() => { this.fire(); - }, animationTime) - }); + }, animationTime); + }).catch(() => this.closeCoWebsite()); } public closeCoWebsite(): Promise { - return new Promise((resolve, reject) => { - const cowebsiteDiv = this.close(); + this.currentOperationPromise = this.currentOperationPromise.then(() => new Promise((resolve, reject) => { + if(this.opened === iframeStates.closed) resolve(); //this method may be called twice, in case of iframe error for example + this.close(); this.fire(); setTimeout(() => { + this.cowebsiteDiv.innerHTML = ''; resolve(); - setTimeout(() => cowebsiteDiv.innerHTML = '', 500) }, animationTime) - }); + })); + return this.currentOperationPromise; } public getGameSize(): {width: number, height: number} { @@ -104,6 +110,7 @@ class CoWebsiteManager { } } + //todo: is it still useful to allow any kind of observers? public onStateChange(observer: CoWebsiteStateChangedCallback) { this.observers.push(observer); } diff --git a/front/src/WebRtc/DiscussionManager.ts b/front/src/WebRtc/DiscussionManager.ts new file mode 100644 index 00000000..097bf3a3 --- /dev/null +++ b/front/src/WebRtc/DiscussionManager.ts @@ -0,0 +1,231 @@ +import {HtmlUtils} from "./HtmlUtils"; +import {MediaManager, ReportCallback, UpdatedLocalStreamCallback} from "./MediaManager"; +import {UserInputManager} from "../Phaser/UserInput/UserInputManager"; +export type SendMessageCallback = (message:string) => void; + +export class DiscussionManager { + private mainContainer: HTMLDivElement; + + private divDiscuss?: HTMLDivElement; + private divParticipants?: HTMLDivElement; + private nbpParticipants?: HTMLParagraphElement; + private divMessages?: HTMLParagraphElement; + private buttonActiveDiscussion?: HTMLButtonElement; + + private participants: Map = new Map(); + + private activeDiscussion: boolean = false; + + private sendMessageCallBack : Map = new Map(); + + private userInputManager?: UserInputManager; + + constructor(private mediaManager: MediaManager, name: string) { + this.mainContainer = HtmlUtils.getElementByIdOrFail('main-container'); + this.createDiscussPart(name); + } + + private createDiscussPart(name: string) { + this.divDiscuss = document.createElement('div'); + this.divDiscuss.classList.add('discussion'); + + const buttonCloseDiscussion: HTMLButtonElement = document.createElement('button'); + this.buttonActiveDiscussion = document.createElement('button'); + buttonCloseDiscussion.classList.add('close-btn'); + buttonCloseDiscussion.innerHTML = ``; + buttonCloseDiscussion.addEventListener('click', () => { + this.hideDiscussion(); + this.showButtonDiscussionBtn(); + }); + this.buttonActiveDiscussion.classList.add('active-btn'); + this.buttonActiveDiscussion.innerHTML = ``; + this.buttonActiveDiscussion.addEventListener('click', () => { + this.showDiscussionPart(); + }); + this.divDiscuss.appendChild(buttonCloseDiscussion); + this.divDiscuss.appendChild(this.buttonActiveDiscussion); + + 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.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|string, + name: string|undefined, + img?: string|undefined, + isMe: boolean = false, + reportCallback?: ReportCallback + ) { + 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) { + const reportBanUserAction: HTMLButtonElement = document.createElement('button'); + reportBanUserAction.classList.add('report-btn') + reportBanUserAction.innerText = 'Report'; + reportBanUserAction.addEventListener('click', () => { + if(reportCallback) { + this.mediaManager.showReportModal(`${userId}`, name ?? '', reportCallback); + }else{ + console.info('report feature is not activated!'); + } + }); + divParticipant.appendChild(reportBanUserAction); + } + + this.divParticipants?.appendChild(divParticipant); + + this.participants.set(userId, divParticipant); + this.showButtonDiscussionBtn(); + + 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 = 'Moi'; + } + pMessage.innerHTML = `${name} + + ${date.getHours()}:${date.getMinutes()} + `; + divMessage.appendChild(pMessage); + + const userMessage: HTMLParagraphElement = document.createElement('p'); + userMessage.innerText = message; + userMessage.classList.add('body'); + divMessage.appendChild(userMessage); + + this.divMessages?.appendChild(divMessage); + } + + 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 + if(this.participants.size === 1){ + this.hideButtonDiscussionBtn(); + } + + this.sendMessageCallBack.delete(userId); + } + + public onSendMessageCallback(userId: string|number, callback: SendMessageCallback): void { + this.sendMessageCallBack.set(userId, callback); + } + + get activatedDiscussion(){ + return this.activeDiscussion; + } + + private showButtonDiscussionBtn(){ + //if it's first participant, show discussion button + if(this.activatedDiscussion || this.participants.size === 1) { + return; + } + this.buttonActiveDiscussion?.classList.add('active'); + } + + private showDiscussion(){ + this.activeDiscussion = true; + if(this.userInputManager) { + this.userInputManager.clearAllInputKeyboard(); + } + this.divDiscuss?.classList.add('active'); + } + + private hideDiscussion(){ + this.activeDiscussion = false; + if(this.userInputManager) { + this.userInputManager.initKeyBoardEvent(); + } + this.divDiscuss?.classList.remove('active'); + } + + private hideButtonDiscussionBtn(){ + this.buttonActiveDiscussion?.classList.remove('active'); + } + + public setUserInputManager(userInputManager : UserInputManager){ + this.userInputManager = userInputManager; + } + + public showDiscussionPart(){ + this.showDiscussion(); + this.hideButtonDiscussionBtn(); + } +} \ No newline at end of file diff --git a/front/src/WebRtc/JitsiFactory.ts b/front/src/WebRtc/JitsiFactory.ts index 45b9b3cf..88e247d7 100644 --- a/front/src/WebRtc/JitsiFactory.ts +++ b/front/src/WebRtc/JitsiFactory.ts @@ -50,8 +50,9 @@ class JitsiFactory { delete options.jwt; } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { options.onload = () => resolve(); //we want for the iframe to be loaded before triggering animations. + setTimeout(() => resolve(), 2000); //failsafe in case the iframe is deleted before loading or too long to load this.jitsiApi = new window.JitsiMeetExternalAPI(domain, options); this.jitsiApi.executeCommand('displayName', playerName); diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index ab6d9a3b..ce1878fb 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -1,5 +1,7 @@ import {DivImportance, layoutManager} from "./LayoutManager"; import {HtmlUtils} from "./HtmlUtils"; +import {DiscussionManager, SendMessageCallback} from "./DiscussionManager"; +import {UserInputManager} from "../Phaser/UserInput/UserInputManager"; declare const navigator:any; // eslint-disable-line @typescript-eslint/no-explicit-any const videoConstraint: boolean|MediaTrackConstraints = { @@ -38,57 +40,65 @@ export class MediaManager { private cinemaBtn: HTMLDivElement; private monitorBtn: HTMLDivElement; + private discussionManager: DiscussionManager; + + private userInputManager?: UserInputManager; + + private hasCamera = true; + constructor() { - this.myCamVideo = this.getElementByIdOrFail('myCamVideo'); - this.webrtcInAudio = this.getElementByIdOrFail('audio-webrtc-in'); + this.myCamVideo = HtmlUtils.getElementByIdOrFail('myCamVideo'); + this.webrtcInAudio = HtmlUtils.getElementByIdOrFail('audio-webrtc-in'); this.webrtcInAudio.volume = 0.2; - this.microphoneBtn = this.getElementByIdOrFail('btn-micro'); - this.microphoneClose = this.getElementByIdOrFail('microphone-close'); + this.microphoneBtn = HtmlUtils.getElementByIdOrFail('btn-micro'); + this.microphoneClose = HtmlUtils.getElementByIdOrFail('microphone-close'); this.microphoneClose.style.display = "none"; this.microphoneClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableMicrophone(); //update tracking }); - this.microphone = this.getElementByIdOrFail('microphone'); + this.microphone = HtmlUtils.getElementByIdOrFail('microphone'); this.microphone.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableMicrophone(); //update tracking }); - this.cinemaBtn = this.getElementByIdOrFail('btn-video'); - this.cinemaClose = this.getElementByIdOrFail('cinema-close'); + this.cinemaBtn = HtmlUtils.getElementByIdOrFail('btn-video'); + this.cinemaClose = HtmlUtils.getElementByIdOrFail('cinema-close'); this.cinemaClose.style.display = "none"; this.cinemaClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableCamera(); //update tracking }); - this.cinema = this.getElementByIdOrFail('cinema'); + this.cinema = HtmlUtils.getElementByIdOrFail('cinema'); this.cinema.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableCamera(); //update tracking }); - this.monitorBtn = this.getElementByIdOrFail('btn-monitor'); - this.monitorClose = this.getElementByIdOrFail('monitor-close'); + this.monitorBtn = HtmlUtils.getElementByIdOrFail('btn-monitor'); + this.monitorClose = HtmlUtils.getElementByIdOrFail('monitor-close'); this.monitorClose.style.display = "block"; this.monitorClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableScreenSharing(); //update tracking }); - this.monitor = this.getElementByIdOrFail('monitor'); + this.monitor = HtmlUtils.getElementByIdOrFail('monitor'); this.monitor.style.display = "none"; this.monitor.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableScreenSharing(); //update tracking }); + + this.discussionManager = new DiscussionManager(this,''); } public onUpdateLocalStream(callback: UpdatedLocalStreamCallback): void { @@ -126,16 +136,19 @@ export class MediaManager { } public showGameOverlay(){ - const gameOverlay = this.getElementByIdOrFail('game-overlay'); + const gameOverlay = HtmlUtils.getElementByIdOrFail('game-overlay'); gameOverlay.classList.add('active'); } public hideGameOverlay(){ - const gameOverlay = this.getElementByIdOrFail('game-overlay'); + const gameOverlay = HtmlUtils.getElementByIdOrFail('game-overlay'); gameOverlay.classList.remove('active'); } public enableCamera() { + if(!this.hasCamera){ + return; + } this.cinemaClose.style.display = "none"; this.cinemaBtn.classList.remove("disabled"); this.cinema.style.display = "block"; @@ -146,13 +159,7 @@ export class MediaManager { } public async disableCamera() { - this.cinemaClose.style.display = "block"; - this.cinema.style.display = "none"; - this.cinemaBtn.classList.add("disabled"); - this.constraintsMedia.video = false; - this.myCamVideo.srcObject = null; - this.stopCamera(); - + this.disabledCameraView(); if (this.constraintsMedia.audio !== false) { const stream = await this.getCamera(); this.triggerUpdatedLocalStreamCallbacks(stream); @@ -161,6 +168,15 @@ export class MediaManager { } } + private disabledCameraView(){ + this.cinemaClose.style.display = "block"; + this.cinema.style.display = "none"; + this.cinemaBtn.classList.add("disabled"); + this.constraintsMedia.video = false; + this.myCamVideo.srcObject = null; + this.stopCamera(); + } + public enableMicrophone() { this.microphoneClose.style.display = "none"; this.microphone.style.display = "block"; @@ -267,24 +283,33 @@ export class MediaManager { } } - try { - const stream = await navigator.mediaDevices.getUserMedia(this.constraintsMedia); + return this.getLocalStream().catch(() => { + console.info('Error get camera, trying with video option at null'); + this.disabledCameraView(); + return this.getLocalStream().then((stream : MediaStream) => { + this.hasCamera = false; + return stream; + }).catch((err) => { + console.info("error get media ", this.constraintsMedia.video, this.constraintsMedia.audio, err); + throw err; + }); + }); + //TODO resize remote cam + /*console.log(this.localStream.getTracks()); + let videoMediaStreamTrack = this.localStream.getTracks().find((media : MediaStreamTrack) => media.kind === "video"); + let {width, height} = videoMediaStreamTrack.getSettings(); + console.info(`${width}x${height}`); // 6*/ + } + + private getLocalStream() : Promise { + return navigator.mediaDevices.getUserMedia(this.constraintsMedia).then((stream : MediaStream) => { this.localStream = stream; this.myCamVideo.srcObject = this.localStream; - return stream; - - //TODO resize remote cam - /*console.log(this.localStream.getTracks()); - let videoMediaStreamTrack = this.localStream.getTracks().find((media : MediaStreamTrack) => media.kind === "video"); - let {width, height} = videoMediaStreamTrack.getSettings(); - console.info(`${width}x${height}`); // 6*/ - } catch (err) { - console.info("error get media ", this.constraintsMedia.video, this.constraintsMedia.audio, err); - this.localStream = null; + }).catch((err: Error) => { throw err; - } + }); } /** @@ -355,14 +380,17 @@ export class MediaManager { layoutManager.add(DivImportance.Normal, userId, html); if (reportCallBack) { - const reportBtn = this.getElementByIdOrFail(`report-${userId}`); + const reportBtn = HtmlUtils.getElementByIdOrFail(`report-${userId}`); reportBtn.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.showReportModal(userId, userName, reportCallBack); }); } - this.remoteVideo.set(userId, this.getElementByIdOrFail(userId)); + this.remoteVideo.set(userId, HtmlUtils.getElementByIdOrFail(userId)); + + //permit to create participant in discussion part + this.addNewParticipant(userId, userName, undefined, reportCallBack); } addScreenSharingActiveVideo(userId: string, divImportance: DivImportance = DivImportance.Important){ @@ -376,7 +404,7 @@ export class MediaManager { layoutManager.add(divImportance, userId, html); - this.remoteVideo.set(userId, this.getElementByIdOrFail(userId)); + this.remoteVideo.set(userId, HtmlUtils.getElementByIdOrFail(userId)); } disabledMicrophoneByUserId(userId: number){ @@ -437,6 +465,9 @@ export class MediaManager { removeActiveVideo(userId: string){ layoutManager.remove(userId); this.remoteVideo.delete(userId); + + //permit to remove user in discussion part + this.removeParticipant(userId); } removeActiveScreenSharingVideo(userId: string) { this.removeActiveVideo(`screen-sharing-${userId}`) @@ -499,18 +530,9 @@ export class MediaManager { return color; } - private getElementByIdOrFail(id: string): T { - const elem = document.getElementById(id); - if (elem === null) { - throw new Error("Cannot find HTML element with id '"+id+"'"); - } - // FIXME: does not check the type of the returned type - return elem as T; - } - - private showReportModal(userId: string, userName: string, reportCallBack: ReportCallback){ + public showReportModal(userId: string, userName: string, reportCallBack: ReportCallback){ //create report text area - const mainContainer = this.getElementByIdOrFail('main-container'); + const mainContainer = HtmlUtils.getElementByIdOrFail('main-container'); const divReport = document.createElement('div'); divReport.classList.add('modal-report-user'); @@ -565,7 +587,34 @@ export class MediaManager { mainContainer.appendChild(divReport); } + public addNewParticipant(userId: number|string, name: string|undefined, img?: string, reportCallBack?: ReportCallback){ + this.discussionManager.addParticipant(userId, name, img, false, reportCallBack); + } + public removeParticipant(userId: number|string){ + this.discussionManager.removeParticipant(userId); + } + + public addNewMessage(name: string, message: string, isMe: boolean = false){ + this.discussionManager.addMessage(name, message, isMe); + + //when there are new message, show discussion + if(!this.discussionManager.activatedDiscussion) { + this.discussionManager.showDiscussionPart(); + } + } + + public addSendMessageCallback(userId: string|number, callback: SendMessageCallback){ + this.discussionManager.onSendMessageCallback(userId, callback); + } + + get activatedDiscussion(){ + return this.discussionManager.activatedDiscussion; + } + + public setUserInputManager(userInputManager : UserInputManager){ + this.discussionManager.setUserInputManager(userInputManager); + } } export const mediaManager = new MediaManager(); diff --git a/front/src/WebRtc/ScreenSharingPeer.ts b/front/src/WebRtc/ScreenSharingPeer.ts index 3efee1c3..a6cf679b 100644 --- a/front/src/WebRtc/ScreenSharingPeer.ts +++ b/front/src/WebRtc/ScreenSharingPeer.ts @@ -2,6 +2,7 @@ import * as SimplePeerNamespace from "simple-peer"; import {mediaManager} from "./MediaManager"; import {TURN_SERVER, TURN_USER, TURN_PASSWORD} from "../Enum/EnvironmentVariable"; import {RoomConnection} from "../Connexion/RoomConnection"; +import {MESSAGE_TYPE_CONSTRAINT} from "./VideoPeer"; const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer'); @@ -148,6 +149,6 @@ export class ScreenSharingPeer extends Peer { public stopPushingScreenSharingToRemoteUser(stream: MediaStream) { this.removeStream(stream); - this.write(new Buffer(JSON.stringify({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 eb2ee42b..195b57b3 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -10,7 +10,7 @@ import { UpdatedLocalStreamCallback } from "./MediaManager"; import {ScreenSharingPeer} from "./ScreenSharingPeer"; -import {VideoPeer} from "./VideoPeer"; +import {MESSAGE_TYPE_CONSTRAINT, MESSAGE_TYPE_MESSAGE, VideoPeer} from "./VideoPeer"; import {RoomConnection} from "../Connexion/RoomConnection"; export interface UserSimplePeerInterface{ @@ -38,7 +38,7 @@ export class SimplePeer { private readonly stopLocalScreenSharingStreamCallback: StopScreenSharingCallback; private readonly peerConnectionListeners: Array = new Array(); - constructor(private Connection: RoomConnection, private enableReporting: boolean) { + 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.sendLocalVideoStreamCallback = this.sendLocalVideoStream.bind(this); this.sendLocalScreenSharingStreamCallback = this.sendLocalScreenSharingStream.bind(this); @@ -145,6 +145,12 @@ export class SimplePeer { mediaManager.addActiveVideo("" + user.userId, reportCallback, name); const peer = new VideoPeer(user.userId, user.initiator ? user.initiator : false, this.Connection); + + //permit to send message + mediaManager.addSendMessageCallback(user.userId,(message: string) => { + peer.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_MESSAGE, name: this.myName.toUpperCase(), 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! @@ -318,7 +324,7 @@ export class SimplePeer { throw new Error('While adding media, cannot find user with ID ' + userId); } const localStream: MediaStream | null = mediaManager.localStream; - PeerConnection.write(new Buffer(JSON.stringify(mediaManager.constraintsMedia))); + PeerConnection.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...mediaManager.constraintsMedia}))); if(!localStream){ return; diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index fb34f29e..f8bfa3f9 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -5,6 +5,8 @@ import {RoomConnection} from "../Connexion/RoomConnection"; const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer'); +export const MESSAGE_TYPE_CONSTRAINT = 'constraint'; +export const MESSAGE_TYPE_MESSAGE = 'message'; /** * A peer connection used to transmit video / audio signals between 2 peers. */ @@ -78,19 +80,27 @@ export class VideoPeer extends Peer { }); this.on('data', (chunk: Buffer) => { - const constraint = JSON.parse(chunk.toString('utf8')); - console.log("data", constraint); - if (constraint.audio) { - mediaManager.enabledMicrophoneByUserId(this.userId); - } else { - mediaManager.disabledMicrophoneByUserId(this.userId); + const message = JSON.parse(chunk.toString('utf8')); + console.log("data", message); + + if(message.type === MESSAGE_TYPE_CONSTRAINT) { + const constraint = message; + if (constraint.audio) { + mediaManager.enabledMicrophoneByUserId(this.userId); + } else { + mediaManager.disabledMicrophoneByUserId(this.userId); + } + + if (constraint.video || constraint.screen) { + mediaManager.enabledVideoByUserId(this.userId); + } else { + this.stream(undefined); + mediaManager.disabledVideoByUserId(this.userId); + } } - if (constraint.video || constraint.screen) { - mediaManager.enabledVideoByUserId(this.userId); - } else { - this.stream(undefined); - mediaManager.disabledVideoByUserId(this.userId); + if(message.type === 'message') { + mediaManager.addNewMessage(message.name, message.message); } }); @@ -163,7 +173,7 @@ export class VideoPeer extends Peer { private pushVideoToRemoteUser() { try { const localStream: MediaStream | null = mediaManager.localStream; - this.write(new Buffer(JSON.stringify(mediaManager.constraintsMedia))); + this.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...mediaManager.constraintsMedia}))); if(!localStream){ return; diff --git a/front/webpack.config.js b/front/webpack.config.js index 218b7374..f03c4fbc 100644 --- a/front/webpack.config.js +++ b/front/webpack.config.js @@ -45,7 +45,7 @@ module.exports = { new webpack.ProvidePlugin({ Phaser: 'phaser' }), - new webpack.EnvironmentPlugin(['API_URL', 'DEBUG_MODE', 'TURN_SERVER', 'TURN_USER', 'TURN_PASSWORD', 'JITSI_URL', 'JITSI_PRIVATE_MODE']) + new webpack.EnvironmentPlugin(['API_URL', 'ADMIN_URL', 'DEBUG_MODE', 'TURN_SERVER', 'TURN_USER', 'TURN_PASSWORD', 'JITSI_URL', 'JITSI_PRIVATE_MODE']) ], }; diff --git a/maps/characters/tenue_sg_1.png b/maps/characters/tenue_sg_1.png new file mode 100644 index 00000000..e334340e Binary files /dev/null and b/maps/characters/tenue_sg_1.png differ diff --git a/maps/characters/tenue_sg_2.png b/maps/characters/tenue_sg_2.png new file mode 100644 index 00000000..fe717a46 Binary files /dev/null and b/maps/characters/tenue_sg_2.png differ diff --git a/messages/messages.proto b/messages/messages.proto index 1a2bc071..5fb7be16 100644 --- a/messages/messages.proto +++ b/messages/messages.proto @@ -38,6 +38,10 @@ message CharacterLayerMessage { /*********** CLIENT TO SERVER MESSAGES *************/ +message PingMessage { + +} + message SetPlayerDetailsMessage { string name = 1; repeated string characterLayers = 2; diff --git a/website/dist/choose-map.html b/website/dist/choose-map.html index a2366f1d..5fb1d8f7 100644 --- a/website/dist/choose-map.html +++ b/website/dist/choose-map.html @@ -36,6 +36,15 @@ +

CHOOSE YOUR MAP !

diff --git a/website/dist/create-map.html b/website/dist/create-map.html index d34cea77..0a398f45 100644 --- a/website/dist/create-map.html +++ b/website/dist/create-map.html @@ -36,6 +36,15 @@ +

CREATE YOUR MAP !

@@ -44,7 +53,7 @@
- < class="col"> +

Tools you will need

In order to build your own map for WorkAdventure, you need:

    diff --git a/website/dist/index.html b/website/dist/index.html index 08805709..df76814d 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -10,6 +10,9 @@ gtag('js', new Date()); gtag('config', 'UA-10196481-11'); + if (window.location.host.endsWith("localhost")){ + window['ga-disable-UA-10196481-11'] = true; + } @@ -33,12 +36,12 @@ - + - + @@ -46,7 +49,7 @@ - + @@ -82,32 +85,42 @@
-

Your workplace
but better

-

You are impatient to discover this new world? Click on "Play online" and meet new people or share this adventure with your colleagues and friends by clicking on "Private mode"

+

Meet your teammates

+

+ WorkAdventure preserves your social interaction while COVID is still out there. +

+

+ Stay connected with your teamworkers, by creating your own online workspace to work remotely. +

+

+ Stay connected with your clients by providing a dedicated digital place to organize meetings, workshops. +

+

+ Stay connected with your future collaborators by organizing online event. +

@@ -158,18 +171,19 @@

Click the button below to come and say hi!

- START IN PUBLIC MODE + TRY IT NOW !

You can also create a private room with your friends or your team !

-

To try, press button private mode

-

- - START IN PRIVATE MODE -

+

To try, press button work in private

+

+ + + CHOOSE YOU OWN MAP +

Don’t forget to activate your mic and camera, let’s play

@@ -179,7 +193,6 @@